code-foundry 1.2.1 → 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.
@@ -3,21 +3,48 @@
3
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
4
  import { dirname, join, resolve } from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
- import { detectLanguages, detectPackageManager, detectProfile, recommendRunners } from '../lib/profile.mjs'
7
- import { configured, gitWorkflow, includesValue, isStagingRelease, readConfig } from '../lib/config.mjs'
6
+ import {
7
+ detectLanguages,
8
+ detectPackageManager,
9
+ detectProfile,
10
+ recommendRunners,
11
+ } from '../lib/profile.mjs'
12
+ import {
13
+ configured,
14
+ gitWorkflow,
15
+ includesValue,
16
+ isStagingRelease,
17
+ readConfig,
18
+ } from '../lib/config.mjs'
8
19
  import { buildReleaseConfig, buildReleaseManifest } from '../lib/release-manifest.mjs'
9
20
  import { customWorkflowFiles, overlayPolicy } from '../lib/overlay.mjs'
10
21
 
11
22
  const standardFiles = [
12
- '.editorconfig', '.gitattributes', '.gitignore', 'release-please-config.json',
23
+ '.editorconfig',
24
+ '.gitattributes',
25
+ '.gitignore',
26
+ 'release-please-config.json',
13
27
  'docs/EXTENSIONS.md',
14
- '.githooks/pre-commit', 'AGENTS.md', 'LICENSE', 'NOTICE', 'ruff.toml', '.prettierrc', '.prettierignore',
15
- '.github/CODEOWNERS', '.github/CODE_OF_CONDUCT.md', '.github/CONTRIBUTING.md',
16
- '.github/PULL_REQUEST_TEMPLATE.md', '.github/SECURITY.md', '.github/dependabot.yml',
17
- '.github/ISSUE_TEMPLATE/bug_report.yml', '.github/ISSUE_TEMPLATE/config.yml',
28
+ '.githooks/pre-commit',
29
+ 'AGENTS.md',
30
+ 'LICENSE',
31
+ 'NOTICE',
32
+ 'ruff.toml',
33
+ '.oxfmtrc.json',
34
+ '.oxlintrc.json',
35
+ '.github/CODEOWNERS',
36
+ '.github/CODE_OF_CONDUCT.md',
37
+ '.github/CONTRIBUTING.md',
38
+ '.github/PULL_REQUEST_TEMPLATE.md',
39
+ '.github/SECURITY.md',
40
+ '.github/dependabot.yml',
41
+ '.github/ISSUE_TEMPLATE/bug_report.yml',
42
+ '.github/ISSUE_TEMPLATE/config.yml',
18
43
  '.github/ISSUE_TEMPLATE/feature_request.yml',
19
- '.github/workflows/validation.yml', '.github/workflows/draft-pr.yml',
20
- '.github/workflows/release-pr.yml', '.github/workflows/release.yml',
44
+ '.github/workflows/validation.yml',
45
+ '.github/workflows/draft-pr.yml',
46
+ '.github/workflows/release-pr.yml',
47
+ '.github/workflows/release.yml',
21
48
  '.github/workflows/opencode-security.yml',
22
49
  ]
23
50
 
@@ -29,8 +56,12 @@ const standardFiles = [
29
56
  const LEGACY_GENERATED_CALLERS = ['ci', 'test', 'security', 'codeql']
30
57
 
31
58
  const protectedFiles = new Set([
32
- 'AGENTS.md', '.github/CODE_OF_CONDUCT.md', '.github/CONTRIBUTING.md',
33
- '.github/PULL_REQUEST_TEMPLATE.md', '.github/SECURITY.md', 'NOTICE',
59
+ 'AGENTS.md',
60
+ '.github/CODE_OF_CONDUCT.md',
61
+ '.github/CONTRIBUTING.md',
62
+ '.github/PULL_REQUEST_TEMPLATE.md',
63
+ '.github/SECURITY.md',
64
+ 'NOTICE',
34
65
  ])
35
66
 
36
67
  /** @type {Record<string, string>} */
@@ -38,18 +69,31 @@ const licenseFiles = {
38
69
  'gpl-3.0-or-later': 'GPL-3.0-or-later.txt',
39
70
  'agpl-3.0-or-later': 'AGPL-3.0-or-later.txt',
40
71
  'apache-2.0': 'APACHE-2.0.txt',
41
- 'mit': 'MIT.txt',
72
+ mit: 'MIT.txt',
42
73
  }
43
74
 
44
75
  const legacyFiles = [
45
- '.github/code-foundry.yml.example', '.github/template.yml', '.github/template.yml.example',
46
- '.github/scripts/bootstrap.sh', '.github/scripts/changed-files.sh', '.github/scripts/ci.sh',
47
- '.github/scripts/codeql-languages.sh', '.github/scripts/doctor.sh', '.github/scripts/format-fast-path.sh',
48
- '.github/scripts/init-repo.sh', '.github/scripts/pre-commit.sh', '.github/scripts/profile.sh',
49
- '.github/scripts/security.sh', '.github/scripts/sitecustomize.py', '.github/scripts/sync-codeowners.sh',
50
- '.github/scripts/sync-protection.sh', '.github/scripts/sync-template.sh', '.github/scripts/turbo-cache-probe.sh',
76
+ '.github/code-foundry.yml.example',
77
+ '.github/template.yml',
78
+ '.github/template.yml.example',
79
+ '.github/scripts/bootstrap.sh',
80
+ '.github/scripts/changed-files.sh',
81
+ '.github/scripts/ci.sh',
82
+ '.github/scripts/codeql-languages.sh',
83
+ '.github/scripts/doctor.sh',
84
+ '.github/scripts/format-fast-path.sh',
85
+ '.github/scripts/init-repo.sh',
86
+ '.github/scripts/pre-commit.sh',
87
+ '.github/scripts/profile.sh',
88
+ '.github/scripts/security.sh',
89
+ '.github/scripts/sitecustomize.py',
90
+ '.github/scripts/sync-codeowners.sh',
91
+ '.github/scripts/sync-protection.sh',
92
+ '.github/scripts/sync-template.sh',
93
+ '.github/scripts/turbo-cache-probe.sh',
51
94
  '.github/licenses/MIT.txt',
52
- '.github/licenses/GPL-3.0-or-later.txt', '.github/licenses/AGPL-3.0-or-later.txt',
95
+ '.github/licenses/GPL-3.0-or-later.txt',
96
+ '.github/licenses/AGPL-3.0-or-later.txt',
53
97
  ]
54
98
 
55
99
  /** @typedef {{ target: string, source: string, dryRun?: boolean, force?: boolean, init?: boolean }} SyncOptions */
@@ -62,13 +106,17 @@ export function syncRepository(options) {
62
106
  const force = options.force ?? false
63
107
  const configPath = join(target, '.github/code-foundry.yml')
64
108
  const existingConfig = readConfig(configPath)
65
- if (!Object.keys(existingConfig).length && !options.init) throw new Error('Missing .github/code-foundry.yml; run init first.')
109
+ if (!Object.keys(existingConfig).length && !options.init)
110
+ throw new Error('Missing .github/code-foundry.yml; run init first.')
66
111
  const defaults = createDefaultConfig(target, source)
67
112
  let config = { ...defaults, ...existingConfig }
68
113
  // Resolve and validate the license policy before any sync writes occur
69
114
  // (including config/default additions) so an unsupported policy fails
70
115
  // fast without leaving partially generated files behind.
71
- const license = configured(config.license, existsSync(join(target, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later')
116
+ const license = configured(
117
+ config.license,
118
+ existsSync(join(target, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later'
119
+ )
72
120
  const licenseFile = licenseFiles[license]
73
121
  if (license !== 'preserve' && license !== 'none' && !licenseFile) {
74
122
  const supported = [...Object.keys(licenseFiles), 'preserve', 'none'].join(', ')
@@ -108,17 +156,25 @@ export function syncRepository(options) {
108
156
  }
109
157
  const mergeStrategy = configured(config.merge_strategy, 'rebase')
110
158
  if (workflow === 'staging-release' && mergeStrategy !== 'rebase') {
111
- throw new Error(`Unsupported merge_strategy: ${mergeStrategy}; the staging-release topology requires rebase for staging to main promotions.`)
159
+ throw new Error(
160
+ `Unsupported merge_strategy: ${mergeStrategy}; the staging-release topology requires rebase for staging to main promotions.`
161
+ )
112
162
  }
113
163
  const releaseMergeStrategy = configured(config.release_merge_strategy, '')
114
164
  if (includesValue(features, 'release') && releaseMergeStrategy !== 'rebase') {
115
- throw new Error(`Unsupported release_merge_strategy: ${releaseMergeStrategy || '(unset)'}; release automation requires rebase for Release Please version pull requests and never defaults to merge.`)
165
+ throw new Error(
166
+ `Unsupported release_merge_strategy: ${releaseMergeStrategy || '(unset)'}; release automation requires rebase for Release Please version pull requests and never defaults to merge.`
167
+ )
116
168
  }
117
169
  const changed = []
118
170
 
119
171
  // Keep normal semver pins current during sync while preserving intentional
120
172
  // refs such as `main`, `staging`, or a custom immutable SHA.
121
- if (existingConfig.runtime_ref && /^v\d+\.\d+\.\d+$/.test(existingConfig.runtime_ref) && existingConfig.runtime_ref !== sourceRuntimeRef) {
173
+ if (
174
+ existingConfig.runtime_ref &&
175
+ /^v\d+\.\d+\.\d+$/.test(existingConfig.runtime_ref) &&
176
+ existingConfig.runtime_ref !== sourceRuntimeRef
177
+ ) {
122
178
  runtimeRef = sourceRuntimeRef
123
179
  const current = readFileSync(configPath, 'utf8')
124
180
  const updated = current.replace(/^runtime_ref:\s*.*$/m, `runtime_ref: ${sourceRuntimeRef}`)
@@ -137,7 +193,12 @@ export function syncRepository(options) {
137
193
  const existing = readFileSync(destination, 'utf8')
138
194
  if (!isLegacyManagedDoc(file, existing)) continue
139
195
  }
140
- if ((file === 'LICENSE' || file === 'NOTICE') && license === 'preserve' && existsSync(destination)) continue
196
+ if (
197
+ (file === 'LICENSE' || file === 'NOTICE') &&
198
+ license === 'preserve' &&
199
+ existsSync(destination)
200
+ )
201
+ continue
141
202
  if ((file === 'LICENSE' || file === 'NOTICE') && license === 'none') continue
142
203
  // An explicit license policy makes the license block below the single
143
204
  // owner of LICENSE; copying the runtime's own root LICENSE here would
@@ -149,7 +210,9 @@ export function syncRepository(options) {
149
210
  content = Buffer.from(renderReleaseConfig(target, sourceFile))
150
211
  }
151
212
  if (file.endsWith('.yml') && file.startsWith('.github/workflows/')) {
152
- content = Buffer.from(renderWorkflow(content.toString('utf8'), config, runtimeRepository, runtimeRef, rustCodeql))
213
+ content = Buffer.from(
214
+ renderWorkflow(content.toString('utf8'), config, runtimeRepository, runtimeRef, rustCodeql)
215
+ )
153
216
  }
154
217
  if (file === '.github/dependabot.yml') {
155
218
  content = Buffer.from(renderDependabot(content.toString('utf8'), config, languages))
@@ -158,10 +221,14 @@ export function syncRepository(options) {
158
221
  content = Buffer.from(renderContributionDocs(content.toString('utf8'), file, config))
159
222
  }
160
223
  if (file === '.gitignore' && existsSync(destination)) {
161
- content = Buffer.from(mergeGitignore(content.toString('utf8'), readFileSync(destination, 'utf8')))
224
+ content = Buffer.from(
225
+ mergeGitignore(content.toString('utf8'), readFileSync(destination, 'utf8'))
226
+ )
162
227
  }
163
- if (file === '.prettierignore' && existsSync(destination)) {
164
- content = Buffer.from(mergeIgnoreFile(content.toString('utf8'), readFileSync(destination, 'utf8')))
228
+ if (file === '.oxfmtrc.json' && existsSync(destination)) {
229
+ content = Buffer.from(
230
+ mergeOxfmtConfig(content.toString('utf8'), readFileSync(destination, 'utf8'))
231
+ )
165
232
  }
166
233
  if (!existsSync(destination) || !buffersEqual(content, readFileSync(destination))) {
167
234
  changed.push(file)
@@ -174,7 +241,8 @@ export function syncRepository(options) {
174
241
  if (!existsSync(destination)) continue
175
242
  if (isGeneratedEventCaller(readFileSync(destination, 'utf8'), stem, runtimeRepository)) {
176
243
  changed.push(`.github/workflows/${stem}.yml`)
177
- if (dryRun) console.log(`Would remove generated legacy caller ${stem}.yml; validation.yml replaces it.`)
244
+ if (dryRun)
245
+ console.log(`Would remove generated legacy caller ${stem}.yml; validation.yml replaces it.`)
178
246
  else rmSync(destination, { force: true })
179
247
  } else {
180
248
  console.log(`Preserved ${stem}.yml: not recognized as a Code Foundry-generated caller.`)
@@ -186,25 +254,39 @@ export function syncRepository(options) {
186
254
  // linger dormant (it triggers on pushes to a branch that does not exist).
187
255
  if (!isStagingRelease(config.git_workflow)) {
188
256
  const promotion = join(target, '.github/workflows/release-pr.yml')
189
- if (existsSync(promotion) && isGeneratedEventCaller(readFileSync(promotion, 'utf8'), 'release-pr', runtimeRepository)) {
257
+ if (
258
+ existsSync(promotion) &&
259
+ isGeneratedEventCaller(readFileSync(promotion, 'utf8'), 'release-pr', runtimeRepository)
260
+ ) {
190
261
  changed.push('.github/workflows/release-pr.yml')
191
- if (dryRun) console.log('Would remove generated release-pr caller; the direct topology targets pull requests at main.')
262
+ if (dryRun)
263
+ console.log(
264
+ 'Would remove generated release-pr caller; the direct topology targets pull requests at main.'
265
+ )
192
266
  else rmSync(promotion, { force: true })
193
267
  }
194
268
  }
195
269
 
196
- const releaseManifest = buildReleaseManifest(target, mergeReleaseConfig(target, sourcePath(source, 'release-please-config.json')))
270
+ const releaseManifest = buildReleaseManifest(
271
+ target,
272
+ mergeReleaseConfig(target, sourcePath(source, 'release-please-config.json'))
273
+ )
197
274
  if (releaseManifest) {
198
275
  const manifestPath = join(target, '.release-please-manifest.json')
199
276
  /** @type {Record<string, string>} */
200
277
  let existingManifest = {}
201
278
  if (existsSync(manifestPath)) {
202
- try { existingManifest = JSON.parse(readFileSync(manifestPath, 'utf8')) }
203
- catch { existingManifest = {} }
279
+ try {
280
+ existingManifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
281
+ } catch {
282
+ existingManifest = {}
283
+ }
204
284
  }
205
285
  /** @type {Record<string, string>} */
206
286
  const mergedManifest = {}
207
- for (const directory of [...new Set([...Object.keys(releaseManifest), ...Object.keys(existingManifest)])].sort()) {
287
+ for (const directory of [
288
+ ...new Set([...Object.keys(releaseManifest), ...Object.keys(existingManifest)]),
289
+ ].sort()) {
208
290
  mergedManifest[directory] = existingManifest[directory] ?? releaseManifest[directory]
209
291
  }
210
292
  const content = `${JSON.stringify(mergedManifest, null, 2)}\n`
@@ -218,11 +300,15 @@ export function syncRepository(options) {
218
300
  const sourceLicense = join(source, '.github/licenses', licenseFile)
219
301
  if (!existsSync(sourceLicense)) throw new Error(`License template missing: ${sourceLicense}`)
220
302
  const licenseContent = readFileSync(sourceLicense)
221
- if (!existsSync(join(target, 'LICENSE')) || !buffersEqual(licenseContent, readFileSync(join(target, 'LICENSE')))) {
303
+ if (
304
+ !existsSync(join(target, 'LICENSE')) ||
305
+ !buffersEqual(licenseContent, readFileSync(join(target, 'LICENSE')))
306
+ ) {
222
307
  changed.push('LICENSE')
223
308
  writeOrReport(join(target, 'LICENSE'), licenseContent, dryRun)
224
309
  }
225
- if (!existsSync(join(target, 'NOTICE'))) writeOrReport(join(target, 'NOTICE'), readFileSync(join(source, 'NOTICE')), dryRun)
310
+ if (!existsSync(join(target, 'NOTICE')))
311
+ writeOrReport(join(target, 'NOTICE'), readFileSync(join(source, 'NOTICE')), dryRun)
226
312
  }
227
313
 
228
314
  for (const file of legacyFiles) {
@@ -233,21 +319,53 @@ export function syncRepository(options) {
233
319
  else rmSync(destination, { force: true })
234
320
  }
235
321
  }
236
- for (const file of ['ruff.toml', '.prettierrc', '.prettierignore']) {
237
- const relevant = file === 'ruff.toml' ? includesValue(languages, 'python') : includesValue(languages, 'typescript')
322
+ for (const file of ['ruff.toml', '.oxfmtrc.json', '.oxlintrc.json']) {
323
+ const relevant =
324
+ file === 'ruff.toml'
325
+ ? includesValue(languages, 'python')
326
+ : includesValue(languages, 'typescript')
238
327
  if (!relevant && existsSync(join(target, file))) {
239
328
  changed.push(file)
240
329
  if (dryRun) console.log(`Would remove irrelevant language configuration ${file}`)
241
330
  else rmSync(join(target, file), { force: true })
242
331
  }
243
332
  }
244
- if (includesValue(languages, 'typescript') && existsSync(join(target, '.prettierignore'))) {
245
- const prettierIgnore = readFileSync(join(target, '.prettierignore'), 'utf8')
246
- const present = new Set(prettierIgnore.split(/\r?\n/).map((line) => line.trim()))
247
- const missing = ['.github/.code-foundry', '.github/actions/'].filter((entry) => !present.has(entry))
248
- if (missing.length) {
249
- writeOrReport(join(target, '.prettierignore'), `${prettierIgnore.trimEnd()}\n${missing.join('\n')}\n`, dryRun)
333
+ if (includesValue(languages, 'typescript')) {
334
+ // The Oxfmt baseline supersedes .prettierrc/.prettierignore. Entries the
335
+ // consumer added beyond the baseline survive the migration inside the
336
+ // .oxfmtrc.json ignorePatterns list.
337
+ const customPatterns = []
338
+ const legacyIgnore = join(target, '.prettierignore')
339
+ if (existsSync(legacyIgnore)) {
340
+ const baselineEntries = new Set(['CHANGELOG.md', '.github/.code-foundry', '.github/actions/'])
341
+ for (const line of readFileSync(legacyIgnore, 'utf8').split(/\r?\n/)) {
342
+ const entry = line.trim()
343
+ if (!entry || entry.startsWith('#') || baselineEntries.has(entry)) continue
344
+ customPatterns.push(entry)
345
+ }
250
346
  changed.push('.prettierignore')
347
+ if (dryRun) console.log('Would remove superseded .prettierignore')
348
+ else rmSync(legacyIgnore, { force: true })
349
+ }
350
+ const legacyConfig = join(target, '.prettierrc')
351
+ if (existsSync(legacyConfig)) {
352
+ changed.push('.prettierrc')
353
+ if (dryRun) console.log('Would remove superseded .prettierrc')
354
+ else rmSync(legacyConfig, { force: true })
355
+ }
356
+ ensureOxfmtIgnorePatterns(
357
+ target,
358
+ ['.github/.code-foundry', '.github/actions/', ...customPatterns],
359
+ changed,
360
+ dryRun
361
+ )
362
+ } else {
363
+ for (const file of ['.prettierrc', '.prettierignore']) {
364
+ if (existsSync(join(target, file))) {
365
+ changed.push(file)
366
+ if (dryRun) console.log(`Would remove irrelevant language configuration ${file}`)
367
+ else rmSync(join(target, file), { force: true })
368
+ }
251
369
  }
252
370
  }
253
371
  if (!dryRun && existsSync(join(target, '.githooks/pre-commit'))) {
@@ -267,20 +385,33 @@ export function syncRepository(options) {
267
385
  * Package and release-type policy is detected from the repository itself and
268
386
  * must never leak from the runtime template.
269
387
  */
270
- const RELEASE_BASELINE_KEYS = ['$schema', 'bump-minor-pre-major', 'changelog-sections', 'include-component-in-tag']
388
+ const RELEASE_BASELINE_KEYS = [
389
+ '$schema',
390
+ 'bump-minor-pre-major',
391
+ 'changelog-sections',
392
+ 'include-component-in-tag',
393
+ ]
271
394
 
272
395
  /** @param {string} target @param {string} sourceFile @returns {Record<string, any>} */
273
396
  function mergeReleaseConfig(target, sourceFile) {
274
397
  /** @type {Record<string, any>} */
275
398
  let baseline = {}
276
- try { baseline = JSON.parse(readFileSync(sourceFile, 'utf8')) }
277
- catch { baseline = {} }
278
- const safeBaseline = Object.fromEntries(RELEASE_BASELINE_KEYS.filter((key) => key in baseline).map((key) => [key, baseline[key]]))
399
+ try {
400
+ baseline = JSON.parse(readFileSync(sourceFile, 'utf8'))
401
+ } catch {
402
+ baseline = {}
403
+ }
404
+ const safeBaseline = Object.fromEntries(
405
+ RELEASE_BASELINE_KEYS.filter((key) => key in baseline).map((key) => [key, baseline[key]])
406
+ )
279
407
  const destination = join(target, 'release-please-config.json')
280
408
  let existing = baseline
281
409
  if (existsSync(destination)) {
282
- try { existing = JSON.parse(readFileSync(destination, 'utf8')) }
283
- catch { existing = baseline }
410
+ try {
411
+ existing = JSON.parse(readFileSync(destination, 'utf8'))
412
+ } catch {
413
+ existing = baseline
414
+ }
284
415
  }
285
416
  return buildReleaseConfig(target, { ...safeBaseline, ...existing })
286
417
  }
@@ -293,9 +424,11 @@ function renderReleaseConfig(target, sourceFile) {
293
424
  /** @param {string} file @param {string} languages @param {string} features @param {Record<string, string>} config */
294
425
  function shouldInclude(file, languages, features, config) {
295
426
  if (file === 'ruff.toml') return includesValue(languages, 'python')
296
- if (file === '.prettierrc' || file === '.prettierignore') return includesValue(languages, 'typescript')
427
+ if (file === '.oxfmtrc.json' || file === '.oxlintrc.json')
428
+ return includesValue(languages, 'typescript')
297
429
  if (file === '.github/dependabot.yml') return includesValue(features, 'dependabot')
298
- if (file === '.github/workflows/opencode-security.yml') return ['true', 'auto'].includes(config.opencode_security ?? 'false')
430
+ if (file === '.github/workflows/opencode-security.yml')
431
+ return ['true', 'auto'].includes(config.opencode_security ?? 'false')
299
432
  const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
300
433
  // The staging promotion caller only exists in the staging-release topology;
301
434
  // direct repositories open feature branches into main and need no promotion.
@@ -303,7 +436,10 @@ function shouldInclude(file, languages, features, config) {
303
436
  // The tiered validation caller supersedes the legacy ci/test/security/codeql
304
437
  // event callers, so legacy feature names keep selecting it.
305
438
  if (workflow === 'validation') {
306
- return includesValue(features, 'validation') || LEGACY_GENERATED_CALLERS.some((legacy) => includesValue(features, legacy))
439
+ return (
440
+ includesValue(features, 'validation') ||
441
+ LEGACY_GENERATED_CALLERS.some((legacy) => includesValue(features, legacy))
442
+ )
307
443
  }
308
444
  return !workflow || includesValue(features, workflow)
309
445
  }
@@ -332,14 +468,20 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
332
468
  const localPrefix = 'uses: ./.github/workflows/'
333
469
  const remotePrefix = `uses: ${repository}/.github/workflows/`
334
470
  let rendered = content.replaceAll(localPrefix, remotePrefix)
335
- rendered = rendered.replace(new RegExp(`${escapeRegExp(remotePrefix)}([^\\s@]+)`, 'g'), `$&@${ref}`)
471
+ rendered = rendered.replace(
472
+ new RegExp(`${escapeRegExp(remotePrefix)}([^\\s@]+)`, 'g'),
473
+ `$&@${ref}`
474
+ )
336
475
  // Pin every runtime reference in the rendered caller: the orchestrator input
337
476
  // in the `with:` block and the mode job's runtime checkout. Self templates
338
477
  // use ${{ github.sha }} and are only rewritten when rendered for consumers.
339
478
  rendered = rendered.replace(/^(\s+runtime-ref:)\s+.*$/gm, `$1 ${ref}`)
340
479
  rendered = rendered.replace(/^(\s+ref:)\s+\$\{\{\s*github\.sha\s*\}\}\s*$/gm, `$1 ${ref}`)
341
480
  rendered = rendered.replace(/^(\s+runtime-repository:)\s+.*$/gm, `$1 ${repository}`)
342
- rendered = rendered.replace(new RegExp(`^(\\s+repository:)\\s+0xPlayerOne\\/code-foundry\\s*$`, 'gm'), `$1 ${repository}`)
481
+ rendered = rendered.replace(
482
+ new RegExp(`^(\\s+repository:)\\s+0xPlayerOne\\/code-foundry\\s*$`, 'gm'),
483
+ `$1 ${repository}`
484
+ )
343
485
  /** @type {Record<string, string|undefined>} */
344
486
  const runners = {
345
487
  ci: config.ci_runner ?? config.runner,
@@ -361,7 +503,10 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
361
503
  // The draft PR caller states the PR base explicitly so the shared
362
504
  // reusable workflow creates pull requests against the repository's
363
505
  // configured integration branch (staging) or main (direct).
364
- rendered = rendered.replace(/^(\s+base:)\s+.*$/m, `$1 ${isStagingRelease(config.git_workflow) ? 'staging' : 'main'}`)
506
+ rendered = rendered.replace(
507
+ /^(\s+base:)\s+.*$/m,
508
+ `$1 ${isStagingRelease(config.git_workflow) ? 'staging' : 'main'}`
509
+ )
365
510
  }
366
511
  const runner = workflow ? runners[workflow] : undefined
367
512
  if (runner) rendered = rendered.replace(/^(\s+runner:)\s+.*$/m, `$1 ${runner}`)
@@ -514,10 +659,7 @@ const DIRECT_DOC_REPLACEMENTS = {
514
659
  'git switch staging\ngit pull --ff-only origin staging',
515
660
  'git switch main\ngit pull --ff-only origin main',
516
661
  ],
517
- [
518
- '1. Start from an up-to-date `staging` branch.',
519
- '1. Start from an up-to-date `main` branch.',
520
- ],
662
+ ['1. Start from an up-to-date `staging` branch.', '1. Start from an up-to-date `main` branch.'],
521
663
  [
522
664
  '8. Push the branch and open a pull request into `staging`.',
523
665
  '8. Push the branch and open a pull request into `main`.',
@@ -526,10 +668,7 @@ const DIRECT_DOC_REPLACEMENTS = {
526
668
  '10. Merge with a squash after required checks pass and the change is ready; feature PRs land on `staging` with squash merges.',
527
669
  '10. Merge with a squash after required checks pass and the change is ready; feature PRs land on `main` with squash merges.',
528
670
  ],
529
- [
530
- '3. Branch from the upstream `staging` branch.',
531
- '3. Branch from the upstream `main` branch.',
532
- ],
671
+ ['3. Branch from the upstream `staging` branch.', '3. Branch from the upstream `main` branch.'],
533
672
  [
534
673
  '7. Push to the fork and open a pull request targeting `staging`.',
535
674
  '7. Push to the fork and open a pull request targeting `main`.',
@@ -542,20 +681,14 @@ const DIRECT_DOC_REPLACEMENTS = {
542
681
  '| Change | Target | Merge method | Merge gate |\n| ---------------------------- | --------- | ----------------------------------------------- | --------------------------------------------------------- |\n| Working branch | `staging` | Squash | All applicable required checks pass |\n| `staging` → `main` promotion | `main` | Rebase (`merge_strategy`) | Current staging checks, release review, and rollout notes |\n| Release Please version PR | `main` | Rebase (`release_merge_strategy`, fails closed) | Validation gate and release policy pass |\n',
543
682
  '| Change | Target | Merge method | Merge gate |\n|----------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| Working branch | `main` | Squash | All applicable required checks pass |\n| Release Please version PR | `main` | Rebase (`release_merge_strategy`, fails closed) | Validation gate and release policy pass |',
544
683
  ],
545
- [
546
- '1. Create a focused branch from `staging`.',
547
- '1. Create a focused branch from `main`.',
548
- ],
684
+ ['1. Create a focused branch from `staging`.', '1. Create a focused branch from `main`.'],
549
685
  ],
550
686
  '.github/SECURITY.md': [
551
687
  [
552
688
  'The latest commit on `staging` receives security patches. Patches are promoted to `main` through the next release cycle.',
553
689
  'The latest commit on `main` receives security patches.',
554
690
  ],
555
- [
556
- '| `staging` | ✅ |\n',
557
- '',
558
- ],
691
+ ['| `staging` | ✅ |\n', ''],
559
692
  ],
560
693
  }
561
694
 
@@ -606,7 +739,9 @@ function validateRustCodeqlConfig(config) {
606
739
  }
607
740
 
608
741
  /** @param {string} value */
609
- function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
742
+ function escapeRegExp(value) {
743
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
744
+ }
610
745
 
611
746
  /**
612
747
  * Recognize a Code Foundry-generated legacy event caller (the consumer copies
@@ -626,49 +761,112 @@ export function isGeneratedEventCaller(content, stem, runtimeRepository) {
626
761
  if (/^\s*(runs-on|steps):/m.test(text)) return false
627
762
  if (!text.includes('runtime-repository:')) return false
628
763
  if (!new RegExp(`^ ${stem}:`, 'm').test(text)) return false
629
- return new RegExp(`uses:\\s*${escapeRegExp(runtimeRepository)}/\\.github/workflows/${stem}\\.yml@`).test(text)
764
+ return new RegExp(
765
+ `uses:\\s*${escapeRegExp(runtimeRepository)}/\\.github/workflows/${stem}\\.yml@`
766
+ ).test(text)
630
767
  }
631
768
 
632
769
  /** @param {string} baseline @param {string} existing */
633
770
  function mergeGitignore(baseline, existing) {
634
771
  const marker = '# Repository-specific rules'
635
- const custom = existing.includes(marker) ? existing.slice(existing.indexOf(marker) + marker.length).trim() : ''
772
+ const custom = existing.includes(marker)
773
+ ? existing.slice(existing.indexOf(marker) + marker.length).trim()
774
+ : ''
636
775
  return custom ? `${baseline.trimEnd()}\n\n${marker}\n${custom}\n` : baseline
637
776
  }
638
777
 
639
- /** @param {string} baseline @param {string} existing */
640
- function mergeIgnoreFile(baseline, existing) {
641
- const marker = '# Repository-specific rules'
642
- const baselineLines = new Set(baseline.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))
643
- const custom = existing.split(/\r?\n/)
644
- .map((line) => line.trimEnd())
645
- .filter((line) => line.trim() && line.trim() !== marker && !baselineLines.has(line.trim()))
646
- return custom.length ? `${baseline.trimEnd()}\n\n${marker}\n${custom.join('\n')}\n` : baseline
778
+ /**
779
+ * Merge the baseline Oxfmt config with the consumer's current config. The
780
+ * baseline owns every key except `ignorePatterns`, where consumer-specific
781
+ * patterns (added by sync migrations or by the repository) are preserved so
782
+ * repeated syncs never churn them.
783
+ * @param {string} baseline @param {string} existing @returns {string}
784
+ */
785
+ function mergeOxfmtConfig(baseline, existing) {
786
+ /** @type {string[]} */
787
+ let extra = []
788
+ try {
789
+ const parsed = JSON.parse(existing)
790
+ if (Array.isArray(parsed.ignorePatterns)) extra = parsed.ignorePatterns
791
+ } catch {
792
+ return baseline
793
+ }
794
+ if (extra.length === 0) return baseline
795
+ let config
796
+ try {
797
+ config = JSON.parse(baseline)
798
+ } catch {
799
+ return baseline
800
+ }
801
+ const base = Array.isArray(config.ignorePatterns) ? config.ignorePatterns : []
802
+ const missing = extra.filter((pattern) => !base.includes(pattern))
803
+ if (missing.length === 0) return baseline
804
+ config.ignorePatterns = [...base, ...missing]
805
+ return `${JSON.stringify(config, null, 2)}\n`
806
+ }
807
+
808
+ /**
809
+ * Ensure the consumer's .oxfmtrc.json carries every baseline ignore pattern
810
+ * (plus any migrated custom patterns). Missing patterns are appended to the
811
+ * existing list; the file is left untouched when nothing is missing or the
812
+ * config is absent/unparseable.
813
+ * @param {string} target @param {string[]} patterns @param {string[]} changed @param {boolean} dryRun
814
+ */
815
+ function ensureOxfmtIgnorePatterns(target, patterns, changed, dryRun) {
816
+ const path = join(target, '.oxfmtrc.json')
817
+ if (patterns.length === 0 || !existsSync(path)) return
818
+ let config
819
+ try {
820
+ config = JSON.parse(readFileSync(path, 'utf8'))
821
+ } catch {
822
+ return
823
+ }
824
+ const existing = Array.isArray(config.ignorePatterns) ? config.ignorePatterns : []
825
+ const merged = [...existing]
826
+ for (const pattern of patterns) {
827
+ if (!merged.includes(pattern)) merged.push(pattern)
828
+ }
829
+ if (merged.length === existing.length) return
830
+ config.ignorePatterns = merged
831
+ changed.push('.oxfmtrc.json')
832
+ writeOrReport(path, `${JSON.stringify(config, null, 2)}\n`, dryRun)
647
833
  }
648
834
 
649
835
  /** @param {string} file @param {string} content */
650
836
  function isLegacyManagedDoc(file, content) {
651
837
  if (file === 'AGENTS.md') {
652
- return content.includes('.github/scripts/bootstrap.sh') && content.includes('bash .github/scripts/ci.sh')
838
+ return (
839
+ content.includes('.github/scripts/bootstrap.sh') &&
840
+ content.includes('bash .github/scripts/ci.sh')
841
+ )
653
842
  }
654
843
  if (file === '.github/CONTRIBUTING.md') {
655
- return content.includes('.github/scripts/bootstrap.sh') && content.includes('.github/template.yml')
844
+ return (
845
+ content.includes('.github/scripts/bootstrap.sh') && content.includes('.github/template.yml')
846
+ )
656
847
  }
657
848
  return false
658
849
  }
659
850
 
660
851
  /** @param {string} target @param {string[]} args */
661
- function git(target, args) { spawnSync('git', args, { cwd: target, stdio: 'ignore' }) }
852
+ function git(target, args) {
853
+ spawnSync('git', args, { cwd: target, stdio: 'ignore' })
854
+ }
662
855
 
663
856
  /** @param {string} file @param {Buffer|string} content @param {boolean} dryRun */
664
857
  function writeOrReport(file, content, dryRun) {
665
- if (dryRun) { console.log(`Would sync ${file}`); return }
858
+ if (dryRun) {
859
+ console.log(`Would sync ${file}`)
860
+ return
861
+ }
666
862
  mkdirSync(dirname(file), { recursive: true })
667
863
  writeFileSync(file, content)
668
864
  }
669
865
 
670
866
  /** @param {Buffer} a @param {Buffer} b */
671
- function buffersEqual(a, b) { return a.equals(b) }
867
+ function buffersEqual(a, b) {
868
+ return a.equals(b)
869
+ }
672
870
 
673
871
  /** @param {string} root @param {string} source @returns {Record<string,string>} */
674
872
  function createDefaultConfig(root, source) {
@@ -676,31 +874,61 @@ function createDefaultConfig(root, source) {
676
874
  const packageManager = detectPackageManager(root)
677
875
  const runners = recommendRunners(root)
678
876
  return {
679
- version: '1', profile: detectProfile(root), languages, features: 'all', codeql: 'auto', dependency_review: 'auto', package_manager: packageManager,
680
- codeql_rust_shards: '["all"]', codeql_rust_threads: '1', codeql_rust_max_parallel: '1',
681
- runtime_repository: '0xPlayerOne/code-foundry', runtime_ref: `v${readPackageVersion(source)}`,
877
+ version: '1',
878
+ profile: detectProfile(root),
879
+ languages,
880
+ features: 'all',
881
+ codeql: 'auto',
882
+ dependency_review: 'auto',
883
+ package_manager: packageManager,
884
+ codeql_rust_shards: '["all"]',
885
+ codeql_rust_threads: '1',
886
+ codeql_rust_max_parallel: '1',
887
+ runtime_repository: '0xPlayerOne/code-foundry',
888
+ runtime_ref: `v${readPackageVersion(source)}`,
682
889
  ...runners,
683
- toolchain: 'auto', staging_validation_mode: 'fast',
684
- prune_standard: 'false', cache_packages: 'auto', cache_build: 'auto', coverage_minimum: '80', turbo_remote: 'auto',
685
- release_type: detectPackageManager(root) === 'none' ? 'auto' : 'node', npm_publish: 'false',
686
- post_release: 'false', post_release_workflow: '', post_release_mode: 'auto',
687
- opencode_security: 'false', opencode_security_model: '',
688
- sync_mode: 'overlay', custom_workflows: 'preserve',
689
- license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later', git_workflow: 'direct', merge_strategy: 'rebase', release_merge_strategy: 'rebase',
890
+ toolchain: 'auto',
891
+ staging_validation_mode: 'fast',
892
+ prune_standard: 'false',
893
+ cache_packages: 'auto',
894
+ cache_build: 'auto',
895
+ coverage_minimum: '80',
896
+ turbo_remote: 'auto',
897
+ release_type: detectPackageManager(root) === 'none' ? 'auto' : 'node',
898
+ npm_publish: 'false',
899
+ post_release: 'false',
900
+ post_release_workflow: '',
901
+ post_release_mode: 'auto',
902
+ opencode_security: 'false',
903
+ opencode_security_model: '',
904
+ sync_mode: 'overlay',
905
+ custom_workflows: 'preserve',
906
+ license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later',
907
+ git_workflow: 'direct',
908
+ merge_strategy: 'rebase',
909
+ release_merge_strategy: 'rebase',
690
910
  }
691
911
  }
692
912
 
693
913
  /** @param {Record<string,string>} config */
694
- function renderConfig(config) { return `${Object.entries(config).map(([key, value]) => renderConfigLine(key, value)).join('\n')}\n` }
914
+ function renderConfig(config) {
915
+ return `${Object.entries(config)
916
+ .map(([key, value]) => renderConfigLine(key, value))
917
+ .join('\n')}\n`
918
+ }
695
919
  /** @param {string} key @param {string} value */
696
920
  function renderConfigLine(key, value) {
697
921
  if (value === '') return `${key}:`
698
922
  // Quote values that YAML would parse as collections or that would trip
699
- // prettier's formatter (e.g. codeql_rust_shards: '["all"]').
700
- const needsQuotes = /^[\[\]{]|[:,#]\s|\s#/.test(value)
923
+ // the formatter (e.g. codeql_rust_shards: '["all"]').
924
+ const needsQuotes = /^[[\]{]|[:,#]\s|\s#/.test(value)
701
925
  return needsQuotes ? `${key}: '${value.replace(/'/g, "''")}'` : `${key}: ${value}`
702
926
  }
703
927
  /** @param {string} root */
704
928
  function readPackageVersion(root) {
705
- try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version ?? '0.0.0' } catch { return '0.0.0' }
929
+ try {
930
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version ?? '0.0.0'
931
+ } catch {
932
+ return '0.0.0'
933
+ }
706
934
  }