code-foundry 1.5.0 → 1.5.1

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.
@@ -25,5 +25,9 @@ runs:
25
25
  uses: github/codeql-action/analyze@v4
26
26
  with:
27
27
  category: ${{ inputs.category }}
28
+ # Pin pull-request uploads to the contributor's head SHA/ref so a
29
+ # moving merge ref cannot race the required code-scanning context.
30
+ ref: ${{ github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
31
+ sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
28
32
  upload-database: false
29
33
  wait-for-processing: false
@@ -285,6 +285,10 @@ jobs:
285
285
  uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
286
286
  with:
287
287
  category: /language:rust/${{ steps.scope.outputs.scope_id }}
288
+ # Keep uploads attached to the PR head rather than the mutable
289
+ # merge ref used by GitHub's pull_request event.
290
+ ref: ${{ github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
291
+ sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
288
292
  upload-database: false
289
293
  wait-for-processing: false
290
294
  - name: Not applicable
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.5.0...v1.5.1) (2026-09-08)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * sync-caller-runtime-pin ([#484](https://github.com/0xPlayerOne/code-foundry/issues/484)) ([e1a216a](https://github.com/0xPlayerOne/code-foundry/commit/e1a216a9dcebd040ef0eacdb20791f360a921908))
9
+
3
10
  ## [1.5.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.4.1...v1.5.0) (2026-09-07)
4
11
 
5
12
 
package/README.md CHANGED
@@ -91,15 +91,16 @@ See [Workflow and CI conventions](docs/WORKFLOWS.md) for triggers, required
91
91
  checks, runners, coverage, caching, and custom workflow extensions.
92
92
 
93
93
  The contribution policy defaults to the `direct` workflow: feature PRs squash
94
- into `main`, and Release Please version PRs rebase into `main`
95
- (`release_merge_strategy: rebase`). Repositories with a preview/staging
96
- environment opt into `git_workflow: staging-release`, where feature PRs squash
97
- into `staging`, the promotion PR rebases into `main` (`merge_strategy:
98
- rebase`), and Release Please version PRs rebase into `main`. Release automation
99
- never defaults to a merge method and never merges with `--admin`;
100
- `code-foundry doctor` and `code-foundry sync` fail closed on any other
101
- strategy. GitHub Stacks is not part of this topology and does not reduce the
102
- required workflow runs.
94
+ into `main`, and Release Please version PRs use the configured
95
+ `release_merge_strategy` (rebase by default, or squash when opted in).
96
+ Repositories with a preview/staging environment opt into
97
+ `git_workflow: staging-release`, where feature PRs squash into `staging`, the
98
+ promotion PR rebases into `main` (`merge_strategy: rebase`), and Release Please
99
+ version PRs rebase into `main`. Release automation never defaults to a merge
100
+ method and never merges with `--admin`; `code-foundry doctor` and
101
+ `code-foundry sync` fail closed on any strategy outside the selected topology.
102
+ GitHub Stacks is not part of this topology and does not reduce the required
103
+ workflow runs.
103
104
 
104
105
  ## Releases and publishing
105
106
 
@@ -77,8 +77,9 @@ present.
77
77
  - `direct` (default): feature branches open pull requests directly into
78
78
  `main`. Validation and security scans run on every PR. No `staging` branch
79
79
  exists, no promotion caller is generated, and `merge_strategy` is not
80
- enforced. Dependabot updates target `main`. This is the right choice when a
81
- repository has no preview or staging environment.
80
+ enforced. Release Please version PRs use `release_merge_strategy` (rebase by
81
+ default; squash is also allowed). Dependabot updates target `main`. This is
82
+ the right choice when a repository has no preview or staging environment.
82
83
  - `staging-release` (opt-in): feature branches squash into `staging`, a
83
84
  promotion PR rebases validated changes into `main` (`merge_strategy:
84
85
  rebase`), and Release Please version PRs rebase into `main`
package/docs/RELEASES.md CHANGED
@@ -24,10 +24,11 @@ topology, feature and fix branches land on `staging` with **squash** merges,
24
24
  the `staging` → `main` promotion PR merges with **rebase** (`merge_strategy:
25
25
  rebase`), and Release Please version PRs merge with **rebase**
26
26
  (`release_merge_strategy: rebase`). In the `direct` topology, feature and fix
27
- branches squash straight into `main` and only Release Please version PRs merge
28
- with **rebase**; `merge_strategy` is not enforced. Release automation never
29
- defaults to a merge method and never merges with `--admin`: the release
30
- workflow fails closed unless `release_merge_strategy` is exactly `rebase`.
27
+ branches squash straight into `main` and Release Please version PRs use the
28
+ configured `release_merge_strategy` (**rebase** by default, or **squash** when
29
+ opted in). `merge_strategy` is not enforced. Release automation never defaults
30
+ to a merge method and never merges with `--admin`: `staging-release` accepts
31
+ only rebase for release PRs, while `direct` accepts rebase or squash.
31
32
 
32
33
  The release workflow opens or updates a versioned PR after changes reach
33
34
  `main`. Merging that PR updates the changelog, creates the Git tag and GitHub
@@ -45,21 +46,21 @@ release_type: auto # auto, node, python, rust, simple, or none
45
46
  npm_publish: false # true only for an npm package
46
47
  git_workflow: direct # direct (default) or staging-release
47
48
  merge_strategy: rebase # staging-release only: staging -> main promotion PRs rebase
48
- release_merge_strategy: rebase # required: Release Please version PRs rebase only
49
+ release_merge_strategy: rebase # required; direct also allows squash
49
50
  ```
50
51
 
51
52
  `git_workflow: staging-release` is opt-in; without it, repositories use the
52
53
  `direct` flow and no promotion PR exists. When the staging-release topology is
53
54
  selected, `merge_strategy` applies to promotion PRs (`staging` into `main`)
54
55
  and `release_merge_strategy` to Release Please version PRs; feature PRs into
55
- `staging` use squash merges. `code-foundry doctor` and `code-foundry sync`
56
- reject any non-`rebase` `merge_strategy` only when `staging-release` is
57
- configured, and always reject a non-`rebase` `release_merge_strategy`; the
58
- release workflow fails closed instead of falling back to `merge`. Both keep
59
- `main` fully linear, which is what makes the post-release reconciliation
60
- possible. The final branch trees are inspected before mutation; validated
61
- main-only changes are inherited by `staging`, while unpromoted staging work is
62
- replayed on top of `main`.
56
+ `staging` use squash merges. `code-foundry doctor`, `code-foundry sync`, and
57
+ the release workflow reject any non-`rebase` `merge_strategy` or release
58
+ strategy in `staging-release`; `direct` accepts `rebase` or `squash` for
59
+ release PRs and never falls back to `merge`. Both keep `main` fully linear,
60
+ which is what makes the post-release reconciliation possible. The final
61
+ branch trees are inspected before mutation; validated main-only changes are
62
+ inherited by `staging`, while unpromoted staging work is replayed on top of
63
+ `main`.
63
64
 
64
65
  Promotion never opens a pull request directly from a divergent `staging`
65
66
  history. The workflow creates or refreshes a deterministic promotion branch
@@ -135,6 +136,6 @@ already passed).
135
136
 
136
137
  1. Merge tested changes into `main` (direct: feature PRs; staging-release: promote `staging` into `main`).
137
138
  2. Review the generated Release Please PR and changelog.
138
- 3. Merge the release PR with the repository's configured `release_merge_strategy` (**rebase**; the release workflow fails closed on any other value).
139
+ 3. Merge the release PR with the repository's configured `release_merge_strategy` (**rebase** by default; **squash** is also valid for direct topology).
139
140
  4. Confirm the GitHub Release and any package publication.
140
141
  5. staging-release only: synchronize `staging` with the new `main` release commit.
package/docs/WORKFLOWS.md CHANGED
@@ -100,15 +100,17 @@ succeeds.
100
100
  ## Merge methods
101
101
 
102
102
  The merge audit pins one merge method per transition. `code-foundry doctor`
103
- and `code-foundry sync` fail closed on any other strategy, and the release
104
- workflow refuses to run unless its strategy is exactly `rebase`.
105
-
106
- | Transition | Merge method | Enforcement |
107
- | ---------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
- | Feature/fix PR into `main` (direct topology) | Squash | Contribution policy; see `CONTRIBUTING.md` |
109
- | Feature/fix PR into `staging` (staging-release topology) | Squash | Contribution policy; see `CONTRIBUTING.md` |
110
- | `staging` → `main` promotion PR (staging-release topology) | Rebase (`merge_strategy: rebase`) | Code Foundry creates a one-commit head with `main` as its parent and the exact validated `staging` tree; `merge_strategy` must be `rebase`, and merge commits are rejected |
111
- | Release Please version PR into `main` | Rebase (`release_merge_strategy: rebase`) | Release automation fails closed unless `rebase`; never defaults to `merge`, never uses `--admin` |
103
+ and `code-foundry sync` validate release strategy against the repository
104
+ topology. Staging-release release PRs require `rebase`; direct release PRs
105
+ allow `rebase` or `squash`. The release workflow fails closed on any other
106
+ strategy and never falls back to `merge`.
107
+
108
+ | Transition | Merge method | Enforcement |
109
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
110
+ | Feature/fix PR into `main` (direct topology) | Squash | Contribution policy; see `CONTRIBUTING.md` |
111
+ | Feature/fix PR into `staging` (staging-release topology) | Squash | Contribution policy; see `CONTRIBUTING.md` |
112
+ | `staging` → `main` promotion PR (staging-release topology) | Rebase (`merge_strategy: rebase`) | Code Foundry creates a one-commit head with `main` as its parent and the exact validated `staging` tree; `merge_strategy` must be `rebase`, and merge commits are rejected |
113
+ | Release Please version PR into `main` | Configured (`release_merge_strategy`: rebase default; squash direct only) | Release automation fails closed on unsupported topology/strategy; never defaults to `merge`, never uses `--admin` |
112
114
 
113
115
  The promotion rows above apply only to `staging-release`; `direct`
114
116
  repositories never generate a promotion caller and `merge_strategy` is not
@@ -123,14 +125,18 @@ additional required checks after earlier ones have passed (for example
123
125
  merge policy-blocked. The mergeability poll is bounded and fails closed on
124
126
  conflicts or timeout; releases without an automation token remain manual.
125
127
 
126
- Keeping `main` linear — rebase promotions and rebase release PRs — is what
127
- lets the post-release reconciliation fast-forward or replay `staging` safely
128
- in the `staging-release` topology. `direct` repositories have no reconciliation
129
- step: releases merge straight into `main` with `release_merge_strategy: rebase`.
130
-
131
- Protect `main` with the aggregate `Validation / Gate` and squash-only pull
132
- requests. In the `staging-release` topology, protect `staging` the same way
133
- with a single GitHub Actions integration path. That path uses the
128
+ Keeping `main` linear — rebase promotions and release PRs in the
129
+ `staging-release` topology, or a single-commit rebase/squash release PR in the
130
+ `direct` topology — is what lets the post-release reconciliation fast-forward
131
+ or replay `staging` safely. `direct` repositories have no reconciliation step:
132
+ releases merge straight into `main` with the configured
133
+ `release_merge_strategy`.
134
+
135
+ Protect `main` with the aggregate `Validation / Gate`. Require squash for
136
+ feature/fix pull requests and permit the configured Release Please method:
137
+ rebase in `staging-release`, or rebase/squash in `direct`. In the
138
+ `staging-release` topology, protect `staging` the same way with a single GitHub
139
+ Actions integration path. That path uses the
134
140
  GitHub Actions integration token by default, and optionally an SSH deploy key
135
141
  when `STAGING_DEPLOY_KEY` is configured. The deploy key is required only when
136
142
  a personal-repository ruleset for `staging` enforces a Deploy Key bypass for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
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": {
@@ -122,9 +122,14 @@ export function doctor(root, options = {}) {
122
122
  )
123
123
  }
124
124
  const releaseMergeStrategy = config.release_merge_strategy ?? ''
125
- if (includesValue(features, 'release') && releaseMergeStrategy !== 'rebase') {
125
+ const allowedReleaseStrategies =
126
+ workflow === 'staging-release' ? ['rebase'] : ['rebase', 'squash']
127
+ if (
128
+ includesValue(features, 'release') &&
129
+ !allowedReleaseStrategies.includes(releaseMergeStrategy)
130
+ ) {
126
131
  error(
127
- `release_merge_strategy must be "rebase" for automated release merges; got "${releaseMergeStrategy || '(unset; release automation never defaults to merge)'}".`
132
+ `release_merge_strategy must be "rebase"${workflow === 'staging-release' ? '' : ' or "squash" (direct topology)'} for automated release merges; got "${releaseMergeStrategy || '(unset; release automation never defaults to merge)'}".`
128
133
  )
129
134
  }
130
135
  for (const name of ['validation', 'draft-pr', 'release-pr', 'release']) {
@@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } fro
4
4
  import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { spawnSync } from 'node:child_process'
7
- import { syncRepository } from './sync.mjs'
7
+ import { readPackageVersion, syncRepository } from './sync.mjs'
8
8
 
9
9
  /** @typedef {{ path: string, repository: string, runtimeRef: string, dirty: boolean, configured: boolean, gitWorkflow: string }} FleetRepository */
10
10
 
@@ -32,6 +32,13 @@ export function discoverRepositories(root) {
32
32
 
33
33
  /** @param {string} root @param {string} source @param {{ createPr?: boolean, dryRun?: boolean, force?: boolean, version: string, exclude?: string[] }} options */
34
34
  export function upgradeFleet(root, source, options) {
35
+ const sourceVersion = `v${readPackageVersion(source)}`
36
+ const version = options.version ?? sourceVersion
37
+ if (options.version && options.version !== sourceVersion) {
38
+ throw new Error(
39
+ `fleet upgrade target ${options.version} does not match the runtime source checkout (${sourceVersion}); refresh the code-foundry checkout to ${options.version} before upgrading so the rendered callers and the declared runtime agree.`
40
+ )
41
+ }
35
42
  const repositories = discoverRepositories(root)
36
43
  const report = []
37
44
  for (const repository of repositories) {
@@ -80,7 +87,7 @@ export function upgradeFleet(root, source, options) {
80
87
  })
81
88
  continue
82
89
  }
83
- report.push(upgradeRepository(repository, source, options.version))
90
+ report.push(upgradeRepository(repository, source, version))
84
91
  }
85
92
  console.log(JSON.stringify(report, null, 2))
86
93
  return report
@@ -121,7 +128,12 @@ function upgradeRepository(repository, source, version) {
121
128
  status: 'skipped',
122
129
  reason: add.stderr.trim() || 'unable to create isolated worktree',
123
130
  }
124
- const result = syncRepository({ target: temporary, source, force: false })
131
+ const result = syncRepository({
132
+ target: temporary,
133
+ source,
134
+ force: false,
135
+ runtimeRef: version,
136
+ })
125
137
  const configFile = join(temporary, '.github/code-foundry.yml')
126
138
  if (existsSync(configFile)) {
127
139
  const current = readFileSync(configFile, 'utf8')
@@ -96,7 +96,7 @@ const legacyFiles = [
96
96
  '.github/licenses/AGPL-3.0-or-later.txt',
97
97
  ]
98
98
 
99
- /** @typedef {{ target: string, source: string, dryRun?: boolean, force?: boolean, init?: boolean }} SyncOptions */
99
+ /** @typedef {{ target: string, source: string, dryRun?: boolean, force?: boolean, init?: boolean, runtimeRef?: string }} SyncOptions */
100
100
 
101
101
  /** @param {SyncOptions} options */
102
102
  export function syncRepository(options) {
@@ -137,7 +137,11 @@ export function syncRepository(options) {
137
137
  const features = configured(config.features, 'all')
138
138
  const runtimeRepository = configured(config.runtime_repository, '0xPlayerOne/code-foundry')
139
139
  const sourceRuntimeRef = `v${readPackageVersion(source)}`
140
- let runtimeRef = configured(config.runtime_ref, sourceRuntimeRef)
140
+ // An explicit runtime ref (fleet upgrade) is authoritative: the rendered
141
+ // callers and the config pin must agree with it, otherwise an upgrade would
142
+ // declare one runtime while shipping another.
143
+ const targetRuntimeRef = options.runtimeRef ?? sourceRuntimeRef
144
+ let runtimeRef = options.runtimeRef ?? configured(config.runtime_ref, sourceRuntimeRef)
141
145
  const toolchain = configured(config.toolchain, 'auto')
142
146
  const overlays = overlayPolicy(target, config)
143
147
  const rustCodeql = validateRustCodeqlConfig(config)
@@ -177,15 +181,17 @@ export function syncRepository(options) {
177
181
  const changed = []
178
182
 
179
183
  // Keep normal semver pins current during sync while preserving intentional
180
- // refs such as `main`, `staging`, or a custom immutable SHA.
184
+ // refs such as `main`, `staging`, or a custom immutable SHA. An explicit
185
+ // runtime ref (fleet upgrade) is authoritative and overrides even those so
186
+ // the rendered callers and the config pin land on the same runtime.
181
187
  if (
182
188
  existingConfig.runtime_ref &&
183
- /^v\d+\.\d+\.\d+$/.test(existingConfig.runtime_ref) &&
184
- existingConfig.runtime_ref !== sourceRuntimeRef
189
+ existingConfig.runtime_ref !== targetRuntimeRef &&
190
+ (options.runtimeRef !== undefined || /^v\d+\.\d+\.\d+$/.test(existingConfig.runtime_ref))
185
191
  ) {
186
- runtimeRef = sourceRuntimeRef
192
+ runtimeRef = targetRuntimeRef
187
193
  const current = readFileSync(configPath, 'utf8')
188
- const updated = current.replace(/^runtime_ref:\s*.*$/m, `runtime_ref: ${sourceRuntimeRef}`)
194
+ const updated = current.replace(/^runtime_ref:\s*.*$/m, `runtime_ref: ${targetRuntimeRef}`)
189
195
  if (updated !== current) {
190
196
  changed.push('.github/code-foundry.yml')
191
197
  writeOrReport(configPath, updated, dryRun)
@@ -829,6 +835,103 @@ function mergeGitignore(baseline, existing) {
829
835
  : baseline
830
836
  }
831
837
 
838
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
839
+ function isJsonObject(value) {
840
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
841
+ }
842
+
843
+ /** @param {string} source @returns {string} */
844
+ function stripJsonComments(source) {
845
+ const output = []
846
+ let inString = false
847
+ for (let index = 0; index < source.length; index += 1) {
848
+ const char = source[index]
849
+ const next = source[index + 1]
850
+ if (inString) {
851
+ output.push(char)
852
+ if (char === '\\' && next !== undefined) {
853
+ output.push(next)
854
+ index += 1
855
+ } else if (char === '"') {
856
+ inString = false
857
+ }
858
+ continue
859
+ }
860
+ if (char === '"') {
861
+ inString = true
862
+ output.push(char)
863
+ continue
864
+ }
865
+ if (char === '/' && next === '/') {
866
+ output.push(' ', ' ')
867
+ index += 2
868
+ while (index < source.length && source[index] !== '\n' && source[index] !== '\r') {
869
+ output.push(' ')
870
+ index += 1
871
+ }
872
+ index -= 1
873
+ continue
874
+ }
875
+ if (char === '/' && next === '*') {
876
+ output.push(' ', ' ')
877
+ index += 2
878
+ while (index < source.length) {
879
+ const commentChar = source[index]
880
+ const commentNext = source[index + 1]
881
+ if (commentChar === '*' && commentNext === '/') {
882
+ output.push(' ', ' ')
883
+ index += 1
884
+ break
885
+ }
886
+ output.push(commentChar === '\n' || commentChar === '\r' ? commentChar : ' ')
887
+ index += 1
888
+ }
889
+ continue
890
+ }
891
+ output.push(char)
892
+ }
893
+ return output.join('')
894
+ }
895
+
896
+ /** @param {string} source @returns {string} */
897
+ function stripJsonTrailingCommas(source) {
898
+ const output = []
899
+ let inString = false
900
+ for (let index = 0; index < source.length; index += 1) {
901
+ const char = source[index]
902
+ if (inString) {
903
+ output.push(char)
904
+ if (char === '\\') {
905
+ const escaped = source[index + 1]
906
+ if (escaped !== undefined) {
907
+ output.push(escaped)
908
+ index += 1
909
+ }
910
+ } else if (char === '"') {
911
+ inString = false
912
+ }
913
+ continue
914
+ }
915
+ if (char === '"') {
916
+ inString = true
917
+ output.push(char)
918
+ continue
919
+ }
920
+ if (char === ',') {
921
+ let nextIndex = index + 1
922
+ while (/\s/.test(source[nextIndex] ?? '')) nextIndex += 1
923
+ if (source[nextIndex] === '}' || source[nextIndex] === ']') continue
924
+ }
925
+ output.push(char)
926
+ }
927
+ return output.join('')
928
+ }
929
+
930
+ /** @param {string} source @returns {unknown} */
931
+ function parseJsonc(source) {
932
+ return JSON.parse(stripJsonTrailingCommas(stripJsonComments(source)))
933
+ }
934
+
832
935
  /**
833
936
  * Merge a baseline Oxc config (.oxfmtrc.json / .oxlintrc.json) with the
834
937
  * consumer's current config. The baseline owns every key it defines, except
@@ -836,7 +939,9 @@ function mergeGitignore(baseline, existing) {
836
939
  * migrations or by the repository) are preserved so repeated syncs never
837
940
  * churn them. Keys the baseline does not define (e.g. a repository-owned
838
941
  * `overrides` block) belong to the consumer and are preserved as well, so
839
- * a sync never silently drops repository-owned configuration.
942
+ * a sync never silently drops repository-owned configuration. Consumer
943
+ * category values are merged last so explicit category overrides survive the
944
+ * baseline's default category levels.
840
945
  *
841
946
  * When the merged semantics already match the consumer file, the exact
842
947
  * existing bytes are returned untouched: rewriting canonical JSON here
@@ -848,23 +953,28 @@ function mergeIgnorePatternsConfig(baseline, existing) {
848
953
  /** @type {Record<string, any>} */
849
954
  let consumer = {}
850
955
  try {
851
- consumer = JSON.parse(existing)
852
- if (consumer === null || typeof consumer !== 'object' || Array.isArray(consumer))
853
- return baseline
956
+ const parsed = parseJsonc(existing)
957
+ if (!isJsonObject(parsed)) return baseline
958
+ consumer = parsed
854
959
  } catch {
855
960
  return baseline
856
961
  }
857
962
  /** @type {Record<string, any>} */
858
963
  let config = {}
859
964
  try {
860
- config = JSON.parse(baseline)
861
- if (config === null || typeof config !== 'object' || Array.isArray(config)) return baseline
965
+ const parsed = parseJsonc(baseline)
966
+ if (!isJsonObject(parsed)) return baseline
967
+ config = parsed
862
968
  } catch {
863
969
  return baseline
864
970
  }
865
971
  /** @type {Record<string, any>} */
866
972
  const merged = { ...config }
867
973
  for (const [key, value] of Object.entries(consumer)) {
974
+ if (key === 'categories' && isJsonObject(merged.categories) && isJsonObject(value)) {
975
+ merged.categories = { ...merged.categories, ...value }
976
+ continue
977
+ }
868
978
  if (!(key in merged)) merged[key] = value
869
979
  }
870
980
  const extra = Array.isArray(consumer.ignorePatterns) ? consumer.ignorePatterns : []
@@ -1014,7 +1124,7 @@ function renderConfigLine(key, value) {
1014
1124
  return needsQuotes ? `${key}: '${value.replace(/'/g, "''")}'` : `${key}: ${value}`
1015
1125
  }
1016
1126
  /** @param {string} root */
1017
- function readPackageVersion(root) {
1127
+ export function readPackageVersion(root) {
1018
1128
  try {
1019
1129
  return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version ?? '0.0.0'
1020
1130
  } catch {