@skitterbyte/skitterspec-jira 0.0.0-stage → 2.0.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.
Files changed (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +606 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +88 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,850 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Config loader for the per-spec isolation feature (`/spec-env`).
5
+ *
6
+ * Reads `specs/.core/env.config.json` from the project root and normalises it
7
+ * over frozen defaults. The feature is strictly opt-in: when the file is absent
8
+ * the loader never throws — it returns the defaults with `present:false`, which
9
+ * every caller treats as "feature unused".
10
+ *
11
+ * Mirrors the shape/idiom of `assets/scripts/lib/config.js` (frozen defaults,
12
+ * merge known keys only, forward-compatible on unknown keys). Zero-dependency.
13
+ *
14
+ * Shape (see specs/.core/env.config.md for field docs):
15
+ * {
16
+ * mode: "worktree" | "checkout", // where a spec's branch is built —
17
+ * // its own worktree (default), or the primary checkout in place.
18
+ * // NOTE: unrelated to `seedFiles.mode`, which is symlink|copy.
19
+ * worktree: { root, folderPattern },
20
+ * docker: { enabled, composeFile, projectNamePattern, portBase,
21
+ * portsPerSpec, envFile, backupCommand },
22
+ * seedFiles:{ mode, files } | [ ".env", ... ], // gitignored files copied/
23
+ * // symlinked from the main checkout into a fresh worktree before
24
+ * // setup runs (mode: "symlink" default | "copy"); empty = none
25
+ * setup: [ "cmd" | { run, when }, ... ], // bootstrap commands run in
26
+ * // the worktree right after `git worktree add` (e.g. install
27
+ * // deps); `when` is ignored there — normalised to strings, with
28
+ * // the triggered entries also kept as `setupTriggers`
29
+ * afterLand:[ "cmd" | { run, when } | { setup: true }, ... ], // commands
30
+ * // run in the PRIMARY checkout after a land (integrate's ff,
31
+ * // hotfix land's main cherry-pick), e.g. a data migration;
32
+ * // `when` globs gate an entry on the land's changed files, and
33
+ * // `{ setup: true }` expands to `setupTriggers`; literal, no
34
+ * // token expansion; normalised to `{ run, when }`; empty = none
35
+ * dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
36
+ * // servers started on the spec's port block (empty = none)
37
+ * proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
38
+ * registry: ".spec-env/registry.json",
39
+ * branch: { pattern, identifierField }, // git branch naming (provider-neutral)
40
+ * spec: { companionPaths: [ "path", ... ] }, // paths that belong to a
41
+ * // spec alongside its own folder (provider-neutral; {slug} and
42
+ * // {identifier} expand); empty = the spec folder only
43
+ * baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
44
+ * guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed },
45
+ * teardown: { deleteRemoteBranch },
46
+ * live: { migrations: [ "glob", ... ] } // migration globs → `live take`
47
+ * // refuses a branch that changes them (code-only v1)
48
+ * hotfix: { bump, cherryPickMain, targets } // `hotfix land`: patch-bump the
49
+ * // deploy tag, also cherry-pick onto the base branch (main), and
50
+ * // onto any extra base tags in `targets` (test/demo lines)
51
+ * }
52
+ */
53
+
54
+ const { createHash } = require('node:crypto')
55
+ const { readFileSync, realpathSync } = require('node:fs')
56
+ const { join, resolve } = require('node:path')
57
+
58
+ const CONFIG_FILE = join('specs', '.core', 'env.config.json')
59
+
60
+ // The window `servePort: "auto"` derives a port from. 7700-7799 keeps the
61
+ // familiar neighbourhood — 7777, the old shared default, is inside it — while
62
+ // giving every repo on a machine its own slot without anyone configuring one.
63
+ //
64
+ // A hundred slots is a SMALL chance of two repos landing together, not no
65
+ // chance. That case is not papered over: the server still refuses the busy
66
+ // port and names `servePort` as the durable fix. Walking up to the next free
67
+ // port would remove the refusal and keep the staleness, because the port would
68
+ // then depend on which repo started first.
69
+ const PORT_BASE = 7700
70
+ const PORT_SPAN = 100
71
+
72
+ /**
73
+ * The repo path the derivation hashes — `realpath`ed ONCE, here.
74
+ *
75
+ * A symlinked spelling of one tree (`/tmp` → `/private/tmp` on macOS, a
76
+ * convenience symlink into a worktree root) is the same repo, and hashing the
77
+ * two spellings separately would hand out two different ports for it — two
78
+ * different URLs, one of them dead. Resolving once is what makes the port a
79
+ * property of the tree rather than of how you typed it.
80
+ *
81
+ * WHAT WOULD FOOL THIS: `realpathSync` throws on a path that does not exist, so
82
+ * it falls back to `resolve`. That is the cannot-tell branch and it is routed
83
+ * to inaction (.claude/rules/negative-checks.md rule 4) — an absolute path is
84
+ * still deterministic, so the worst case is a stable port for a directory that
85
+ * is not there, never a crash on an unrelated command.
86
+ */
87
+ function servePortRoot(dir) {
88
+ try {
89
+ return realpathSync(resolve(dir))
90
+ } catch {
91
+ return resolve(dir)
92
+ }
93
+ }
94
+
95
+ /**
96
+ * `PORT_BASE + hash(path) % PORT_SPAN` — a PURE function of the path.
97
+ *
98
+ * Purity is the whole point, not an implementation detail. A port that is
99
+ * merely *free* is not a port a link handed out yesterday can still resolve, so
100
+ * nothing here may read the registry, `.spec-env/`, or any other state on disk:
101
+ * a port that depends on a file changes when the file is deleted, and this
102
+ * exists precisely so it does not change. The same tree gets the same port
103
+ * across a restart, a reboot, and a `--stop`.
104
+ *
105
+ * sha256 rather than a hand-rolled hash because its distribution is not
106
+ * something this file has to argue for — with a hundred slots, clustering is
107
+ * the only way the derivation could fail at its job.
108
+ */
109
+ function derivedServePort(root) {
110
+ const digest = createHash('sha256').update(root).digest()
111
+ return PORT_BASE + (digest.readUInt32BE(0) % PORT_SPAN)
112
+ }
113
+
114
+ /**
115
+ * The port `spec-env review serve` should use, and HOW it was chosen.
116
+ *
117
+ * Three sources, in precedence order: `--port` on this run, an explicit number
118
+ * in `review.servePort`, else the derivation. The `source` rides along so
119
+ * `--status` can answer "why is this on 7742?" from the tool rather than from
120
+ * reading this file.
121
+ *
122
+ * Returns `{ port, source, root? }`; `root` is the resolved path that was
123
+ * hashed, present only on a derived port.
124
+ */
125
+ function resolveServePort(config, dir, override) {
126
+ const flag = Number(override)
127
+ if (override != null && override !== '' && Number.isFinite(flag)) {
128
+ return { port: flag, source: 'flag' }
129
+ }
130
+ const configured = config && config.review ? config.review.servePort : undefined
131
+ if (typeof configured === 'number' && Number.isFinite(configured)) {
132
+ return { port: configured, source: 'configured' }
133
+ }
134
+ const root = servePortRoot(dir)
135
+ return { port: derivedServePort(root), source: 'derived', root }
136
+ }
137
+
138
+ /** One sentence naming how a resolved port was chosen, for `--status`. */
139
+ function servePortReason(source) {
140
+ if (source === 'flag') return 'this run only, from --port'
141
+ if (source === 'configured') return 'pinned by review.servePort'
142
+ if (source === 'derived') return "derived from this repo's path"
143
+ return ''
144
+ }
145
+
146
+ const DEFAULT_CONFIG = Object.freeze({
147
+ // Where a spec's branch is built. "worktree" gives every spec its own checkout
148
+ // — parallel specs, `main` left free — at the cost of a terminal session per
149
+ // spec. "checkout" builds the branch in the primary checkout instead: one spec
150
+ // at a time, in the terminal you are already sitting in.
151
+ //
152
+ // Defaults to "worktree" so no installed repo changes behaviour on upgrade.
153
+ // Never inferred from whether `dev`/`docker` are configured: a repo with no
154
+ // dev servers may still want parallel specs, and absence of configuration is
155
+ // not evidence of intent (see .claude/rules/negative-checks.md).
156
+ mode: 'worktree',
157
+ worktree: Object.freeze({ root: '../{repo}-wt', folderPattern: '{slug}' }),
158
+ docker: Object.freeze({
159
+ enabled: true,
160
+ composeFile: 'docker-compose.yml',
161
+ projectNamePattern: '{repoSlug}_{slug}',
162
+ portBase: 3000,
163
+ portsPerSpec: 10,
164
+ envFile: '.env',
165
+ backupCommand: '',
166
+ }),
167
+ // Gitignored files seeded from the main checkout into a fresh worktree by
168
+ // `spec-env up`, right after `git worktree add` and before `setup` runs — so a
169
+ // fresh worktree has the .env / local overrides that setup steps depend on.
170
+ // `mode` is "symlink" (default, stays in sync with main) or "copy" (an
171
+ // independent copy). `files` is a list of repo-relative paths. Default: none.
172
+ seedFiles: Object.freeze({ mode: 'symlink', files: Object.freeze([]) }),
173
+ // Bootstrap commands run in the worktree by `spec-env up`, right after
174
+ // `git worktree add` (before Docker/dev), on every provision. Array of shell
175
+ // strings (e.g. "pnpm install"); {slug}/{branch}/… expand. Default: none.
176
+ setup: Object.freeze([]),
177
+ // The `setup` entries that declared `when` globs, as `{ run, when }`. Derived
178
+ // at load, never configured directly: `afterLand`'s `{ setup: true }` expands
179
+ // to these, so a setup step can re-run after a land that changes its inputs.
180
+ setupTriggers: Object.freeze([]),
181
+ // Commands run in the primary checkout after a land, printed by `spec-env
182
+ // integrate` (including its already-landed no-op) and by `hotfix land` when
183
+ // it cherry-picks onto the base branch. The skill runs them; the engine only
184
+ // plans. Literal strings — no {slug}/{port} tokens, since they run on the base
185
+ // branch where a spec's tokens mean nothing. Normalised to `{ run, when }`,
186
+ // `when` null for an always-run entry. Default: none.
187
+ afterLand: Object.freeze([]),
188
+ // Host dev servers started on the spec's port block by `spec-env dev up`.
189
+ // Each: { name, command, portVar, health?, frontPort? }. Default: none.
190
+ dev: Object.freeze([]),
191
+ // Front-door proxy (`spec-env connect`): a bundled Node reverse proxy that
192
+ // exposes one connected spec's frontPort processes on the canonical ports.
193
+ proxy: Object.freeze({ enabled: true, host: '127.0.0.1' }),
194
+ registry: '.spec-env/registry.json',
195
+ // Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
196
+ // when a tracker provider is linked, {identifier}; `identifierField` names the
197
+ // 00-overview.md frontmatter field a provider writes the ticket id into (empty
198
+ // = no identifier, so patterns referencing {identifier} fall back to type/slug).
199
+ branch: Object.freeze({ pattern: '{type}/{slug}', identifierField: '' }),
200
+ // Paths that belong to a spec ALONGSIDE its own `specs/<bucket>/<name>/` folder
201
+ // — a tracker provider's per-spec snapshot, for instance. Provider-neutral by
202
+ // design: the base engine must not know that any particular tracker exists, so
203
+ // the project declares the shape and `{slug}` / `{identifier}` expand exactly as
204
+ // they do in `branch.pattern` ({identifier} via `branch.identifierField`).
205
+ // Default: none, so a spec owns only its own folder.
206
+ spec: Object.freeze({ companionPaths: Object.freeze([]) }),
207
+ // Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
208
+ baseBranch: '',
209
+ // `mainIsLandingZone` defaults ON wherever isolation is configured, matching
210
+ // the review gate's precedent: the push toward not working on the base branch
211
+ // is the normal path, and stepping off it is the deliberate act. That does
212
+ // change behaviour for a project on upgrade, which is the honest cost — the
213
+ // refusal names both exits (`/no-spec` and `/allow-main`) and this key.
214
+ guards: Object.freeze({
215
+ refuseTeardownIfDirty: true,
216
+ refuseTeardownIfUnpushed: true,
217
+ mainIsLandingZone: true,
218
+ }),
219
+ // Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
220
+ // `spec-env down` does about a branch the USER published by hand — nothing
221
+ // publishes one at provisioning, so there is often no remote ref at all and
222
+ // the plan then says nothing: "prompt" (default)
223
+ // plans the delete in its own confirm-first section for the skill to ask about,
224
+ // "never" omits it, "always" folds it into the run-blind command list. Only ever
225
+ // planned for a LANDED branch — see teardown.js.
226
+ teardown: Object.freeze({ deleteRemoteBranch: 'prompt' }),
227
+
228
+ // `reader` decides how a diff's location is WORDED, and nothing else. It
229
+ // once also decided whether the engine served — a gate that produced the
230
+ // `file://` link on a local machine, where a `file://` page cannot POST and
231
+ // so the verdict buttons had nowhere to go. `serve` owns that now.
232
+ // It never decides to PUBLISH: publishing
233
+ // leaves a page this tooling cannot remove, so it stays an explicit ask.
234
+ // `detect` sniffs; `local`/`remote` are the operator's own answer and are
235
+ // believed without sniffing, because they know where they are reading and no
236
+ // signal can outrank that.
237
+ // `commitWith` names the skill a COMMITTING verdict hands off to. `/commit`
238
+ // ships with skittership, a different package — so it may not be installed,
239
+ // and skitterspec must never vendor a copy of it.
240
+ //
241
+ // There is no off switch, and that is deliberate: `"none"` existed and was
242
+ // removed, because it produced the one thing a review page must not have —
243
+ // a verdict that records itself and does nothing. A review is the guard in
244
+ // front of an action; recording an approval for SOMEONE ELSE to act on is a
245
+ // different mechanism, not a value of this key.
246
+ //
247
+ // `required` decides whether a phase that ended owes a verdict before its
248
+ // work can be committed or the next phase built. It defaults TRUE: the push
249
+ // toward reading the diff is the point, and a project that would rather not
250
+ // be pushed says so once. It is the only key anything reads to decide
251
+ // whether the gate refuses, so turning it off turns off the hook with it.
252
+ // `serve` decides whether a render stands the local server up: `always`
253
+ // (the default) or `never`. It replaced `serveOnRemote`, whose name would now
254
+ // claim to govern remote renders while governing every one of them — and a
255
+ // config key that lies is what made a wrong fix look right. A legacy
256
+ // `serveOnRemote: false` is still read as `serve: "never"`.
257
+ //
258
+ // THE BIND IS NOT THIS KEY'S BUSINESS, and that separation is what keeps
259
+ // serving-everywhere free of new exposure: `reader` still decides it, so a
260
+ // remote reader binds every interface exactly as before and a local or
261
+ // unknown one binds loopback. Serving more never means listening wider.
262
+ // `allowNetwork` and `allowRemote` are the two tiers a project permits. They
263
+ // exist because the engine CANNOT KNOW where the reader is sitting and kept
264
+ // being asked to guess: detection reported `unknown` on a local session and
265
+ // produced a page whose buttons cannot POST, reported `remote` and produced a
266
+ // LAN URL a phone off the network could not reach, and flipped mid-session
267
+ // and changed the address underneath a reader. Each was fixed on its own; the
268
+ // next reader position would have produced a fourth.
269
+ //
270
+ // So the reader picks instead. `allowNetwork` decides THE BIND — on binds
271
+ // every interface, off binds loopback — which is the last decision detection
272
+ // had. `allowRemote` PERMITS publishing; it never publishes, because each
273
+ // publish leaves a page skitterspec cannot delete.
274
+ review: Object.freeze({
275
+ reader: 'detect',
276
+ servePort: 'auto',
277
+ serve: 'always',
278
+ allowNetwork: true,
279
+ allowRemote: false,
280
+ commitWith: '/commit',
281
+ required: true,
282
+ // External code reviewers whose findings render as CHECKS on the page.
283
+ // Empty by default and never written by `init`: configuring one sends the
284
+ // worktree's diff to whatever the command talks to, which is a decision
285
+ // only the project can make. Two entry shapes — `{use}` names a bundled
286
+ // adapter, `{name, command, format}` is bring-your-own.
287
+ reviewers: Object.freeze([]),
288
+ }),
289
+ // Live overlay (`spec-env live`). `migrations` is a list of globs marking
290
+ // migration files; a branch that changes any of them is treated as stateful and
291
+ // `live take` refuses it (code-only v1). Default: none (nothing is stateful).
292
+ live: Object.freeze({ migrations: Object.freeze([]) }),
293
+ // Hotfix landing (`spec-env hotfix land`). `bump` is the version-bump strategy
294
+ // for the new deploy tag: "patch" (X.Y.Z -> X.Y.(Z+1)) or "hotfix-suffix"
295
+ // (X.Y.Z -> X.Y.Z-hotfix.N). The loader keeps whatever string it is given and
296
+ // `nextTag` refuses an unknown one at land time — a loader that threw would
297
+ // break every verb over a key only one of them reads. `cherryPickMain` also cherry-picks
298
+ // the fix onto the base branch for the next release (default true). `targets` is
299
+ // an optional default list of extra base tags to also patch (test/demo lines);
300
+ // `--also <tag>` on the command adds more at run time. Default: patch, main, none.
301
+ // `releaseWith` is a project's own release command, run in the hotfix
302
+ // worktree in place of the built-in tagger — e.g. skittership's
303
+ // `node scripts/release-hotfix.cjs --json`. Empty (the default) = built-in.
304
+ hotfix: Object.freeze({
305
+ bump: 'patch',
306
+ cherryPickMain: true,
307
+ targets: Object.freeze([]),
308
+ releaseWith: '',
309
+ }),
310
+ })
311
+
312
+ function isObject(value) {
313
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
314
+ }
315
+
316
+ /**
317
+ * List the keys in a parsed config that `mergeConfig` will not read, as dotted
318
+ * paths (`open`, `review.readr`). Advisory only — nothing here refuses, and the
319
+ * merge below is unchanged: unknown keys are still dropped, they are just no
320
+ * longer dropped in silence.
321
+ *
322
+ * The known set is DEFAULT_CONFIG itself rather than a list written out beside
323
+ * it. That is not a shortcut: every key `mergeConfig` reads is necessarily a key
324
+ * of the defaults, because a key with no default has nothing to merge onto — so
325
+ * a hand-written list would be a second copy of the same contract, and a second
326
+ * copy is how the two come to disagree. Adding a key to `mergeConfig` without
327
+ * adding its default is already impossible; this check inherits that.
328
+ *
329
+ * It descends only where the DEFAULT is a plain object. An array's elements are
330
+ * data, not keys — `dev` entries, `setup` commands, `spec.companionPaths`,
331
+ * `live.migrations`, `hotfix.targets` — and walking into them would report every
332
+ * path in the list as an unknown key.
333
+ *
334
+ * WHAT IT DELIBERATELY DOES NOT REPORT: a KNOWN key whose value was rejected for
335
+ * its type or for not matching an enum (`mode: "Checkout"`,
336
+ * `teardown.deleteRemoteBranch: "yes"`). Those already fall through to a
337
+ * documented conservative default, each with a comment saying why, and folding
338
+ * them in here would change what this line means from "I ignored a key you wrote"
339
+ * to "I disagreed with a value you wrote".
340
+ */
341
+ function collectUnknownKeys(parsed, known = DEFAULT_CONFIG, prefix = '') {
342
+ if (!isObject(parsed)) return []
343
+ const out = []
344
+ for (const key of Object.keys(parsed)) {
345
+ const dotted = prefix ? `${prefix}.${key}` : key
346
+ if (!Object.prototype.hasOwnProperty.call(known, key)) {
347
+ out.push(dotted)
348
+ continue
349
+ }
350
+ if (isObject(known[key]) && isObject(parsed[key])) {
351
+ out.push(...collectUnknownKeys(parsed[key], known[key], dotted))
352
+ }
353
+ }
354
+ return out
355
+ }
356
+
357
+ // A fresh, deeply-mutable copy of the defaults to merge onto.
358
+ function defaults() {
359
+ return {
360
+ mode: DEFAULT_CONFIG.mode,
361
+ worktree: { ...DEFAULT_CONFIG.worktree },
362
+ docker: { ...DEFAULT_CONFIG.docker },
363
+ seedFiles: { mode: DEFAULT_CONFIG.seedFiles.mode, files: [] },
364
+ setup: [],
365
+ setupTriggers: [],
366
+ afterLand: [],
367
+ dev: [],
368
+ proxy: { ...DEFAULT_CONFIG.proxy },
369
+ registry: DEFAULT_CONFIG.registry,
370
+ branch: { ...DEFAULT_CONFIG.branch },
371
+ spec: { companionPaths: [] },
372
+ baseBranch: DEFAULT_CONFIG.baseBranch,
373
+ guards: { ...DEFAULT_CONFIG.guards },
374
+ teardown: { ...DEFAULT_CONFIG.teardown },
375
+ review: { ...DEFAULT_CONFIG.review, reviewers: [] },
376
+ live: { migrations: [] },
377
+ hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
378
+ }
379
+ }
380
+
381
+ // Copy a typed field from parsed[key] onto base[key] when it matches `type`.
382
+ // Strings are trimmed and must be non-empty to override.
383
+ function assign(base, parsed, key, type) {
384
+ const v = parsed[key]
385
+ if (type === 'string') {
386
+ if (typeof v === 'string' && v.trim()) base[key] = v.trim()
387
+ } else if (type === 'string?') {
388
+ // string that may be intentionally empty (e.g. backupCommand)
389
+ if (typeof v === 'string') base[key] = v
390
+ } else if (type === 'boolean') {
391
+ if (typeof v === 'boolean') base[key] = v
392
+ } else if (type === 'number') {
393
+ if (typeof v === 'number' && Number.isFinite(v)) base[key] = v
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Normalise a parsed `dev` array into well-formed process entries. Each entry
399
+ * needs non-empty string `name`, `command`, and `portVar`; `health` (string) and
400
+ * `frontPort` (finite number) are optional. Malformed entries are dropped
401
+ * (lenient, like the rest of the loader) so a stray entry can't crash provisioning.
402
+ */
403
+ function normalizeDev(parsed) {
404
+ const out = []
405
+ for (const raw of parsed) {
406
+ if (!isObject(raw)) continue
407
+ const name = typeof raw.name === 'string' ? raw.name.trim() : ''
408
+ const command = typeof raw.command === 'string' ? raw.command.trim() : ''
409
+ const portVar = typeof raw.portVar === 'string' ? raw.portVar.trim() : ''
410
+ if (!name || !command || !portVar) continue
411
+ const entry = { name, command, portVar }
412
+ if (typeof raw.health === 'string' && raw.health.trim()) entry.health = raw.health.trim()
413
+ if (typeof raw.frontPort === 'number' && Number.isFinite(raw.frontPort)) {
414
+ entry.frontPort = raw.frontPort
415
+ }
416
+ out.push(entry)
417
+ }
418
+ return out
419
+ }
420
+
421
+ // Keep only trimmed, non-empty strings from an array of file paths (lenient).
422
+ function normalizeFileList(parsed) {
423
+ const out = []
424
+ for (const raw of parsed) {
425
+ if (typeof raw !== 'string') continue
426
+ const file = raw.trim()
427
+ if (file) out.push(file)
428
+ }
429
+ return out
430
+ }
431
+
432
+ // How long a reviewer gets before it is killed, in seconds. A generous default:
433
+ // these tools take 30s-3min, and the render they precede is about to wait on a
434
+ // human for far longer.
435
+ const DEFAULT_REVIEWER_TIMEOUT = 180
436
+
437
+ // Output formats the runner can parse. `rdjsonl` is the native contract
438
+ // (reviewdog's interchange shape); a bundled adapter brings its own parser and
439
+ // declares no format at all.
440
+ const REVIEWER_FORMATS = ['rdjsonl']
441
+
442
+ /**
443
+ * Read one `review.reviewers` entry. Returns the normalised entry, or `null`
444
+ * with a reason — the caller decides whether to keep it or report it.
445
+ *
446
+ * Two shapes, and they are told apart by which key is present rather than by
447
+ * guessing: `{use}` names a bundled adapter (the engine owns its command line
448
+ * and its parser), `{name, command}` is bring-your-own. An entry carrying both
449
+ * is refused BY NAME rather than resolved in some order — it is two
450
+ * instructions, and picking one would run a reviewer the author did not ask for.
451
+ *
452
+ * WHETHER A NAMED ADAPTER EXISTS IS NOT CHECKED HERE, deliberately: the adapter
453
+ * registry lives in the runner, and this module must not depend on it to answer
454
+ * a question about shape. The runner reports an unknown `use` as its own
455
+ * outcome.
456
+ */
457
+ function readReviewer(raw) {
458
+ if (!isObject(raw)) return { entry: null, reason: 'not an object' }
459
+ const use = typeof raw.use === 'string' ? raw.use.trim() : ''
460
+ const command = typeof raw.command === 'string' ? raw.command.trim() : ''
461
+ if (use && command) return { entry: null, reason: 'carries both use and command — write one' }
462
+ // A number that is not finite and positive is not a timeout. Falling through
463
+ // to the default is safe in a way `0` would not be: a zero timeout kills every
464
+ // reviewer instantly and would read, on the page, as one that never ran.
465
+ const t = raw.timeout
466
+ const timeout = typeof t === 'number' && Number.isFinite(t) && t > 0 ? t : DEFAULT_REVIEWER_TIMEOUT
467
+ if (use) {
468
+ const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : use
469
+ return { entry: { name, use, command: null, format: null, timeout }, reason: null }
470
+ }
471
+ if (!command) return { entry: null, reason: 'has neither use nor command' }
472
+ const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : ''
473
+ if (!name) return { entry: null, reason: 'has a command but no name' }
474
+ // An unrecognised format is refused rather than defaulted to `rdjsonl`. The
475
+ // scalars above can fall through because their default is what the author
476
+ // most likely meant; here it is not — parsing one tool's output as another's
477
+ // shape yields zero findings and reports the reviewer as clean, which is the
478
+ // false clean this whole feature is built to avoid.
479
+ const fmt = raw.format === undefined || raw.format === null ? 'rdjsonl' : raw.format
480
+ if (!REVIEWER_FORMATS.includes(fmt)) {
481
+ return { entry: null, reason: `format ${JSON.stringify(raw.format)} is not one of ${REVIEWER_FORMATS.join(', ')}` }
482
+ }
483
+ return { entry: { name, use: null, command, format: fmt, timeout }, reason: null }
484
+ }
485
+
486
+ function normalizeReviewers(parsed) {
487
+ const out = []
488
+ for (const raw of parsed) {
489
+ const { entry } = readReviewer(raw)
490
+ if (entry) out.push(entry)
491
+ }
492
+ return out
493
+ }
494
+
495
+ /**
496
+ * List the `review.reviewers` entries the merge dropped, as
497
+ * `{ index, reason }`. Advisory, like `collectUnknownKeys` — nothing refuses.
498
+ *
499
+ * It exists because `collectUnknownKeys` structurally cannot cover this: it
500
+ * descends only where the DEFAULT is a plain object, and this default is an
501
+ * array. Without this, a reviewer entry with a typo in it is dropped in exactly
502
+ * the silence that check was added to end — and worse than an ignored key,
503
+ * because the page would then show no line for it at all.
504
+ */
505
+ function collectBadReviewers(parsed) {
506
+ if (!isObject(parsed) || !isObject(parsed.review) || !Array.isArray(parsed.review.reviewers)) return []
507
+ const out = []
508
+ parsed.review.reviewers.forEach((raw, index) => {
509
+ const { entry, reason } = readReviewer(raw)
510
+ if (!entry) out.push({ index, reason })
511
+ })
512
+ return out
513
+ }
514
+
515
+ /**
516
+ * Normalise a parsed `seedFiles` value into `{ mode, files }`. Accepts two forms:
517
+ * - the array shorthand `[".env", ...]` → { mode: 'symlink', files: [...] }
518
+ * - the object form `{ mode?, files? }` — `mode` is 'copy' or 'symlink'
519
+ * (anything else falls back to 'symlink'); `files` is a list of paths.
520
+ * Malformed entries are dropped (lenient, like `normalizeSetup`) so a stray
521
+ * value can't crash provisioning.
522
+ */
523
+ function normalizeSeedFiles(parsed) {
524
+ if (Array.isArray(parsed)) {
525
+ return { mode: 'symlink', files: normalizeFileList(parsed) }
526
+ }
527
+ const mode = parsed.mode === 'copy' ? 'copy' : 'symlink'
528
+ const files = Array.isArray(parsed.files) ? normalizeFileList(parsed.files) : []
529
+ return { mode, files }
530
+ }
531
+
532
+ /**
533
+ * Normalise a parsed `setup` array into bootstrap commands: keep only trimmed,
534
+ * non-empty strings, drop everything else (lenient, like `normalizeDev`) so a
535
+ * stray entry can't crash provisioning.
536
+ */
537
+ function normalizeSetup(parsed) {
538
+ return normalizeCommands(parsed).map((e) => e.run)
539
+ }
540
+
541
+ /**
542
+ * Normalise a list of command entries into `{ run, when }`. A string is an
543
+ * always-run entry (`when: null`); `{ run, when }` keeps its trigger globs, with
544
+ * a string `when` coerced to a one-item list. An object whose `run` is blank, or
545
+ * whose `when` holds no usable glob, is dropped rather than demoted to
546
+ * always-run — a malformed trigger must not quietly widen into "every time".
547
+ */
548
+ function normalizeCommands(parsed) {
549
+ const out = []
550
+ for (const raw of parsed) {
551
+ if (typeof raw === 'string') {
552
+ const run = raw.trim()
553
+ if (run) out.push({ run, when: null })
554
+ continue
555
+ }
556
+ if (!isObject(raw) || typeof raw.run !== 'string') continue
557
+ const run = raw.run.trim()
558
+ if (!run) continue
559
+ if (raw.when === undefined) {
560
+ out.push({ run, when: null })
561
+ continue
562
+ }
563
+ const globs = typeof raw.when === 'string' ? [raw.when] : Array.isArray(raw.when) ? raw.when : []
564
+ const when = normalizeFileList(globs)
565
+ if (when.length) out.push({ run, when })
566
+ }
567
+ return out
568
+ }
569
+
570
+ // `afterLand` entries: `normalizeCommands`, plus `{ setup: true }` expanded in
571
+ // place to the triggered setup entries. With none declared it expands to
572
+ // nothing — the shorthand names a set, and an empty set is a valid one.
573
+ function normalizeAfterLand(parsed, setupTriggers) {
574
+ const out = []
575
+ for (const raw of parsed) {
576
+ if (isObject(raw) && raw.setup === true) {
577
+ for (const t of setupTriggers) out.push({ run: t.run, when: [...t.when] })
578
+ continue
579
+ }
580
+ out.push(...normalizeCommands([raw]))
581
+ }
582
+ return out
583
+ }
584
+
585
+ /**
586
+ * Merge a parsed config over the defaults. Only known keys are copied (unknown
587
+ * keys ignored for forward-compat). Nested objects are merged field-by-field.
588
+ */
589
+ function mergeConfig(base, parsed) {
590
+ if (!isObject(parsed)) return base
591
+
592
+ if (isObject(parsed.worktree)) {
593
+ assign(base.worktree, parsed.worktree, 'root', 'string')
594
+ assign(base.worktree, parsed.worktree, 'folderPattern', 'string')
595
+ }
596
+
597
+ if (isObject(parsed.docker)) {
598
+ assign(base.docker, parsed.docker, 'enabled', 'boolean')
599
+ assign(base.docker, parsed.docker, 'composeFile', 'string')
600
+ assign(base.docker, parsed.docker, 'projectNamePattern', 'string')
601
+ assign(base.docker, parsed.docker, 'portBase', 'number')
602
+ assign(base.docker, parsed.docker, 'portsPerSpec', 'number')
603
+ assign(base.docker, parsed.docker, 'envFile', 'string')
604
+ assign(base.docker, parsed.docker, 'backupCommand', 'string?')
605
+ }
606
+
607
+ if (Array.isArray(parsed.seedFiles) || isObject(parsed.seedFiles)) {
608
+ base.seedFiles = normalizeSeedFiles(parsed.seedFiles)
609
+ }
610
+
611
+ if (Array.isArray(parsed.setup)) {
612
+ const entries = normalizeCommands(parsed.setup)
613
+ base.setup = entries.map((e) => e.run)
614
+ base.setupTriggers = entries.filter((e) => e.when)
615
+ }
616
+
617
+ // Same rule as `setup`: a stray entry is dropped, never thrown on. Read after
618
+ // `setup`, which `{ setup: true }` expands from.
619
+ if (Array.isArray(parsed.afterLand)) {
620
+ base.afterLand = normalizeAfterLand(parsed.afterLand, base.setupTriggers)
621
+ }
622
+
623
+ if (Array.isArray(parsed.dev)) {
624
+ base.dev = normalizeDev(parsed.dev)
625
+ }
626
+
627
+ if (isObject(parsed.proxy)) {
628
+ assign(base.proxy, parsed.proxy, 'enabled', 'boolean')
629
+ assign(base.proxy, parsed.proxy, 'host', 'string')
630
+ }
631
+
632
+ if (isObject(parsed.branch)) {
633
+ assign(base.branch, parsed.branch, 'pattern', 'string')
634
+ assign(base.branch, parsed.branch, 'identifierField', 'string')
635
+ }
636
+
637
+ assign(base, parsed, 'registry', 'string')
638
+ assign(base, parsed, 'baseBranch', 'string')
639
+
640
+ // Same treatment as `teardown.deleteRemoteBranch` below, for the same reason:
641
+ // an unrecognised value falls through to the default rather than erroring or
642
+ // being taken literally. The fallback direction matters — "worktree" is the
643
+ // conservative one, so a typo ("Checkout", "in-place") costs an extra terminal
644
+ // session, never a spec's work landing somewhere the author did not choose.
645
+ if (parsed.mode === 'worktree' || parsed.mode === 'checkout') {
646
+ base.mode = parsed.mode
647
+ }
648
+
649
+ if (isObject(parsed.guards)) {
650
+ assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
651
+ assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
652
+ assign(base.guards, parsed.guards, 'mainIsLandingZone', 'boolean')
653
+ }
654
+
655
+ // An unrecognised policy falls through to the default rather than erroring or
656
+ // being taken literally — a typo ("Always", "yes") must not silently become a
657
+ // stronger setting than the author typed, and "prompt" is the one value that
658
+ // cannot act without a human first.
659
+ if (isObject(parsed.teardown)) {
660
+ const policy = parsed.teardown.deleteRemoteBranch
661
+ if (policy === 'prompt' || policy === 'never' || policy === 'always') {
662
+ base.teardown.deleteRemoteBranch = policy
663
+ }
664
+ }
665
+
666
+ // An unrecognised reader falls through to `detect`, which is the state that
667
+ // claims least: it can answer "unknown", and unknown is wired to today's
668
+ // behaviour. A typo must never become a confident `local`, because a confident
669
+ // `local` is exactly the dead `file://` link this key exists to prevent.
670
+ if (isObject(parsed.review)) {
671
+ const reader = parsed.review.reader
672
+ if (reader === 'local' || reader === 'remote' || reader === 'detect') {
673
+ base.review.reader = reader
674
+ }
675
+ // Two accepted forms: a finite number pins the port, and the string "auto"
676
+ // derives it from the repo's path. Anything else is refused BY NAME — the
677
+ // default stands — exactly as `reader`, `mode` and `deleteRemoteBranch`
678
+ // refuse a value they do not recognise.
679
+ //
680
+ // Falling through costs nothing here, and that is worth stating rather than
681
+ // assuming: the only value a typo can fall through to is "auto", which is
682
+ // also the only string that would have been accepted. So a misspelt "auto"
683
+ // behaves identically to the spelling that was meant, and a misspelt number
684
+ // was never a number. There is no reading of this key where silence hides a
685
+ // port the author pinned.
686
+ if (typeof parsed.review.servePort === 'number' && Number.isFinite(parsed.review.servePort)) {
687
+ base.review.servePort = parsed.review.servePort
688
+ } else if (parsed.review.servePort === 'auto') {
689
+ base.review.servePort = 'auto'
690
+ }
691
+ // `always` | `never`; anything else leaves the default standing, so a typo
692
+ // cannot quietly restore the dead `file://` link this key exists to end.
693
+ if (parsed.review.serve === 'always' || parsed.review.serve === 'never') {
694
+ base.review.serve = parsed.review.serve
695
+ }
696
+ // TOLERANCE, NOT MIGRATION — the same rule `readVerdict` follows for the
697
+ // old `approve` spelling. These configs are committed, so a rename with no
698
+ // tolerance breaks every other checkout on the next pull. Only `false` is
699
+ // read: `serveOnRemote: true` said "serve where it matters", which is what
700
+ // `always` now does anyway, so it needs no translation.
701
+ //
702
+ // An explicit `serve` wins, so a config carrying both is read the way its
703
+ // author most recently meant.
704
+ if (parsed.review.serve === undefined && parsed.review.serveOnRemote === false) {
705
+ base.review.serve = 'never'
706
+ }
707
+ // An empty string leaves `/commit` standing, like every other string key
708
+ // here. There is nothing it could mean instead: the hand-off has no off
709
+ // switch, so a blank value is a typo rather than an instruction.
710
+ assign(base.review, parsed.review, 'commitWith', 'string')
711
+ // Same shape as `serveOnRemote`, and for a sharper reason: a non-boolean
712
+ // leaves the gate ON. Turning off a check that refuses must be something
713
+ // someone WROTE, never something a typo achieved on their behalf.
714
+ assign(base.review, parsed.review, 'required', 'boolean')
715
+ // Same shape as the two above: a non-boolean leaves the default standing, so
716
+ // a typo cannot quietly widen a bind or permit a publish.
717
+ assign(base.review, parsed.review, 'allowNetwork', 'boolean')
718
+ assign(base.review, parsed.review, 'allowRemote', 'boolean')
719
+ // An entry that is neither shape is DROPPED rather than refused, like every
720
+ // other value here — but unlike the scalars above there is no default for it
721
+ // to fall through to, so a dropped entry is a reviewer that silently never
722
+ // runs. `collectUnknownKeys` cannot see it either: the default is an array,
723
+ // and it deliberately does not walk into one. So the dropping is reported
724
+ // separately, by `collectBadReviewers` below.
725
+ if (Array.isArray(parsed.review.reviewers)) {
726
+ base.review.reviewers = normalizeReviewers(parsed.review.reviewers)
727
+ }
728
+ }
729
+
730
+ if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
731
+ base.spec.companionPaths = normalizeFileList(parsed.spec.companionPaths)
732
+ }
733
+
734
+ if (isObject(parsed.live) && Array.isArray(parsed.live.migrations)) {
735
+ base.live.migrations = normalizeFileList(parsed.live.migrations)
736
+ }
737
+
738
+ if (isObject(parsed.hotfix)) {
739
+ assign(base.hotfix, parsed.hotfix, 'bump', 'string')
740
+ assign(base.hotfix, parsed.hotfix, 'cherryPickMain', 'boolean')
741
+ assign(base.hotfix, parsed.hotfix, 'releaseWith', 'string')
742
+ if (Array.isArray(parsed.hotfix.targets)) {
743
+ base.hotfix.targets = normalizeFileList(parsed.hotfix.targets)
744
+ }
745
+ }
746
+
747
+ return base
748
+ }
749
+
750
+ /**
751
+ * Load and normalise `specs/.core/env.config.json` from `dir` (default cwd).
752
+ * Returns `{ config, present, unknown }`:
753
+ * - missing file → `{ config: defaults, present: false, unknown: [] }`
754
+ * (opt-out; never throws)
755
+ * - present → `{ config: merged, present: true, unknown: [...] }`
756
+ * Malformed JSON → throws a clear Error (callers exit non-zero).
757
+ *
758
+ * `unknown` lists the dotted paths the merge ignored, for a caller to report.
759
+ * It is advisory in the strongest sense: nothing here acts on it, and a config
760
+ * full of strays loads exactly as it always did.
761
+ */
762
+ // The isolation keys a ticketing provider needs a project to declare — set
763
+ // once per process by a provider superset's bin, beneath whatever the
764
+ // project's own env.config.json says. They used to reach only the
765
+ // `env.config.json.example` composed at build time, which left every project
766
+ // that adopted a provider over an EXISTING isolation config (or skipped the
767
+ // example) with a `spec.companionPaths` that never claims the provider's
768
+ // snapshot sidecar — `spec-env stage` then classifies the spec's own
769
+ // `<baseDir>/<identifier>.base.json` as foreign and `/commit` leaves it
770
+ // behind. The base distribution registers nothing and behaves as before.
771
+ let providerEnvDefaults = null
772
+ function setProviderEnvDefaults(overrides) {
773
+ providerEnvDefaults = overrides || null
774
+ }
775
+
776
+ // Fill ONLY the keys the project's file does not mention: a key the file sets —
777
+ // including explicitly to '' or [] — is the project's decision and stands
778
+ // ("the two keys are one fix" in scripts/build-dist.test.js is the contract:
779
+ // blanking identifierField must keep disabling the companion expansion).
780
+ function applyProviderEnvDefaults(config, parsed) {
781
+ if (!providerEnvDefaults) return config
782
+ const d = providerEnvDefaults
783
+ const pBranch = parsed && typeof parsed.branch === 'object' && parsed.branch !== null ? parsed.branch : null
784
+ if (
785
+ d.branch &&
786
+ typeof d.branch.identifierField === 'string' &&
787
+ !(pBranch && 'identifierField' in pBranch) &&
788
+ !config.branch.identifierField
789
+ ) {
790
+ config.branch.identifierField = d.branch.identifierField
791
+ }
792
+ const pSpec = parsed && typeof parsed.spec === 'object' && parsed.spec !== null ? parsed.spec : null
793
+ if (
794
+ d.spec &&
795
+ Array.isArray(d.spec.companionPaths) &&
796
+ !(pSpec && 'companionPaths' in pSpec) &&
797
+ !config.spec.companionPaths.length
798
+ ) {
799
+ config.spec.companionPaths = [...d.spec.companionPaths]
800
+ }
801
+ return config
802
+ }
803
+
804
+ function loadEnvConfig(dir = process.cwd()) {
805
+ const base = defaults()
806
+ const file = join(dir, CONFIG_FILE)
807
+
808
+ let raw
809
+ try {
810
+ raw = readFileSync(file, 'utf-8')
811
+ } catch (error) {
812
+ if (error.code === 'ENOENT') {
813
+ return { config: applyProviderEnvDefaults(base, null), present: false, unknown: [], badReviewers: [] }
814
+ }
815
+ throw error
816
+ }
817
+
818
+ let parsed
819
+ try {
820
+ parsed = JSON.parse(raw)
821
+ } catch (error) {
822
+ throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
823
+ }
824
+
825
+ return {
826
+ config: applyProviderEnvDefaults(mergeConfig(base, parsed), parsed),
827
+ present: true,
828
+ unknown: collectUnknownKeys(parsed),
829
+ badReviewers: collectBadReviewers(parsed),
830
+ }
831
+ }
832
+
833
+ module.exports = {
834
+ loadEnvConfig,
835
+ setProviderEnvDefaults,
836
+ resolveServePort,
837
+ derivedServePort,
838
+ servePortRoot,
839
+ servePortReason,
840
+ PORT_BASE,
841
+ PORT_SPAN,
842
+ collectUnknownKeys,
843
+ collectBadReviewers,
844
+ readReviewer,
845
+ mergeConfig,
846
+ DEFAULT_CONFIG,
847
+ DEFAULT_REVIEWER_TIMEOUT,
848
+ REVIEWER_FORMATS,
849
+ CONFIG_FILE,
850
+ }