@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.
- package/MIGRATION.md +1266 -0
- package/README.md +44 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +606 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +88 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2622 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +338 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +971 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- 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
|
+
}
|