@skitterbyte/skitterspec 21.0.0 → 22.1.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 (43) hide show
  1. package/MIGRATION.md +218 -0
  2. package/README.md +127 -6
  3. package/assets/claude-md-section.md +42 -18
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-remote-review.md +22 -0
  6. package/assets/commands/spec-skip.md +24 -0
  7. package/assets/core/env.config.json.example +6 -3
  8. package/assets/core/env.config.md +183 -25
  9. package/assets/hooks/main-guard.cjs +145 -0
  10. package/assets/hooks/review-gate.cjs +8 -3
  11. package/assets/review/page.html +1174 -101
  12. package/assets/rules/offered-unblocks.md +100 -0
  13. package/assets/rules/spec-planning.md +195 -14
  14. package/assets/rules/spec-reports.md +181 -29
  15. package/assets/skills/no-spec/SKILL.md +219 -0
  16. package/assets/skills/spec/SKILL.md +248 -21
  17. package/assets/skills/spec-bug/SKILL.md +34 -24
  18. package/assets/skills/spec-cancel/SKILL.md +29 -0
  19. package/assets/skills/spec-complete/SKILL.md +25 -0
  20. package/assets/skills/spec-diff/SKILL.md +164 -37
  21. package/assets/skills/spec-hotfix/SKILL.md +22 -19
  22. package/assets/skills/spec-init/SKILL.md +4 -2
  23. package/assets/skills/spec-next/SKILL.md +166 -58
  24. package/assets/skills/spec-review/SKILL.md +87 -0
  25. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  26. package/assets/skills/spec-start/SKILL.md +33 -0
  27. package/package.json +1 -1
  28. package/src/cli.js +1612 -172
  29. package/src/env/classify.js +87 -2
  30. package/src/env/config.js +333 -19
  31. package/src/env/hooks.js +102 -33
  32. package/src/env/live.js +124 -1
  33. package/src/env/mainguard.js +240 -0
  34. package/src/env/provision.js +106 -10
  35. package/src/env/registry.js +70 -6
  36. package/src/env/resolve.js +128 -5
  37. package/src/env/review.js +854 -29
  38. package/src/env/reviewers/coderabbit.js +228 -0
  39. package/src/env/reviewers.js +517 -0
  40. package/src/env/serve.js +474 -50
  41. package/src/env/supervise.js +8 -1
  42. package/src/env/teardown.js +12 -1
  43. package/src/init.js +185 -24
@@ -45,7 +45,18 @@ function expandCompanion(pattern, spec, config) {
45
45
  const tokens = { slug: spec.slug }
46
46
  if (/\{identifier\}/.test(pattern)) {
47
47
  const field = config && config.branch && config.branch.identifierField
48
- const identifier = readFrontmatterField(spec.path, field)
48
+ // NO PATH IS CANNOT-TELL, not an error. Callers hand in spec objects of two
49
+ // shapes: `resolveSpec` returns a full one, and `allSpecs` returns
50
+ // `{folder, slug, worktreePath}` with no `path` at all. Reading the
51
+ // frontmatter of `undefined` threw, and the throw surfaced as
52
+ // `render failed: The "path" argument must be of type string` on the review
53
+ // server's index — every spec, not just one.
54
+ //
55
+ // It routes to the same answer a missing identifier already routes to:
56
+ // expand nothing, so the pattern matches nothing and the path is not
57
+ // claimed as this spec's. Being wrong here costs a companion file left
58
+ // unstaged; throwing cost the whole page.
59
+ const identifier = spec.path ? readFrontmatterField(spec.path, field) : null
49
60
  if (!identifier) return null
50
61
  tokens.identifier = identifier
51
62
  }
@@ -88,4 +99,78 @@ function classifyDirtyTree(spec, dirtyPaths, config) {
88
99
  return { owned, foreign }
89
100
  }
90
101
 
91
- module.exports = { classifyDirtyTree, expandCompanion }
102
+ // git quotes a path containing unusual bytes and C-escapes it. Unquote what we
103
+ // can; anything we cannot parse confidently is returned as-is, which makes it
104
+ // fail the spec-folder comparison and land in `foreign` — a refusal, which is the
105
+ // safe direction to be wrong in.
106
+ function unquotePath(p) {
107
+ if (!p.startsWith('"') || !p.endsWith('"')) return p
108
+ try {
109
+ return JSON.parse(p)
110
+ } catch {
111
+ return p
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Repo-relative paths of everything uncommitted. Returns null when git could not
117
+ * be read at all — the caller must treat that as "nobody looked", never "clean".
118
+ *
119
+ * Two prefix-free listings rather than `git status --porcelain`, deliberately.
120
+ * Porcelain prefixes every path with a two-character status field, and the shared
121
+ * git reader TRIMS its output — which eats the leading space of the first line
122
+ * only, so a fixed-offset parse silently returned `EADME.md` for `README.md`.
123
+ * These emit bare paths, so there is no offset to get wrong. `--others` also
124
+ * lists untracked files INDIVIDUALLY, where porcelain collapses them into their
125
+ * topmost untracked directory — reporting a brand-new spec as `specs/backlog/`,
126
+ * an ancestor attributable to no single spec, and so refusing the very tree this
127
+ * gate exists to accept. Both were found by running it, not by reading it.
128
+ */
129
+ function dirtyPaths(git) {
130
+ const lists = [
131
+ git(['diff', '--name-only', 'HEAD']),
132
+ git(['ls-files', '--others', '--exclude-standard']),
133
+ ]
134
+ if (lists.every((l) => l === null)) return null
135
+ const out = []
136
+ for (const list of lists) {
137
+ if (!list) continue
138
+ for (const line of list.split('\n')) {
139
+ const q = line.trim()
140
+ if (q) out.push(unquotePath(q))
141
+ }
142
+ }
143
+ return out
144
+ }
145
+
146
+ /**
147
+ * A spec's own uncommitted documents in one tree: `{tree, owned}`, or a reason.
148
+ *
149
+ * SHARED ON PURPOSE. Two surfaces render a spec's documents — the CLI's
150
+ * `review --docs` and the review server's route for a spec with no worktree —
151
+ * and if they classified separately they could disagree about what the page
152
+ * shows. A reader's verdict is about the page they read, so the served page and
153
+ * the written page have to be the same page.
154
+ *
155
+ * Three states, never two (`.claude/rules/negative-checks.md` rule 4). An
156
+ * unreadable git is `cannot tell`, and must not collapse into an empty file
157
+ * set: an empty set renders a page saying nothing changed, which is the one
158
+ * reading that is certainly wrong.
159
+ *
160
+ * WHAT WOULD FOOL THIS: a document of this spec's that is already committed.
161
+ * `dirtyPaths` answers about the uncommitted tree only, so a spec whose files
162
+ * are all committed reports `empty` rather than showing its own text. That is
163
+ * the intended reading — there is no change to review — and it is why `empty`
164
+ * is a distinct answer rather than an error.
165
+ *
166
+ * @returns {{tree: string, owned: string[]}|{error: string}|{empty: true}}
167
+ */
168
+ function specDocsIn(tree, spec, config, git) {
169
+ const paths = dirtyPaths(git)
170
+ if (paths === null) return { error: 'git could not be read' }
171
+ const { owned } = classifyDirtyTree(spec, paths, config)
172
+ if (!owned.length) return { empty: true }
173
+ return { tree, owned }
174
+ }
175
+
176
+ module.exports = { classifyDirtyTree, expandCompanion, unquotePath, dirtyPaths, specDocsIn }
package/src/env/config.js CHANGED
@@ -43,11 +43,98 @@
43
43
  * }
44
44
  */
45
45
 
46
- const { readFileSync } = require('node:fs')
47
- const { join } = require('node:path')
46
+ const { createHash } = require('node:crypto')
47
+ const { readFileSync, realpathSync } = require('node:fs')
48
+ const { join, resolve } = require('node:path')
48
49
 
49
50
  const CONFIG_FILE = join('specs', '.core', 'env.config.json')
50
51
 
52
+ // The window `servePort: "auto"` derives a port from. 7700-7799 keeps the
53
+ // familiar neighbourhood — 7777, the old shared default, is inside it — while
54
+ // giving every repo on a machine its own slot without anyone configuring one.
55
+ //
56
+ // A hundred slots is a SMALL chance of two repos landing together, not no
57
+ // chance. That case is not papered over: the server still refuses the busy
58
+ // port and names `servePort` as the durable fix. Walking up to the next free
59
+ // port would remove the refusal and keep the staleness, because the port would
60
+ // then depend on which repo started first.
61
+ const PORT_BASE = 7700
62
+ const PORT_SPAN = 100
63
+
64
+ /**
65
+ * The repo path the derivation hashes — `realpath`ed ONCE, here.
66
+ *
67
+ * A symlinked spelling of one tree (`/tmp` → `/private/tmp` on macOS, a
68
+ * convenience symlink into a worktree root) is the same repo, and hashing the
69
+ * two spellings separately would hand out two different ports for it — two
70
+ * different URLs, one of them dead. Resolving once is what makes the port a
71
+ * property of the tree rather than of how you typed it.
72
+ *
73
+ * WHAT WOULD FOOL THIS: `realpathSync` throws on a path that does not exist, so
74
+ * it falls back to `resolve`. That is the cannot-tell branch and it is routed
75
+ * to inaction (.claude/rules/negative-checks.md rule 4) — an absolute path is
76
+ * still deterministic, so the worst case is a stable port for a directory that
77
+ * is not there, never a crash on an unrelated command.
78
+ */
79
+ function servePortRoot(dir) {
80
+ try {
81
+ return realpathSync(resolve(dir))
82
+ } catch {
83
+ return resolve(dir)
84
+ }
85
+ }
86
+
87
+ /**
88
+ * `PORT_BASE + hash(path) % PORT_SPAN` — a PURE function of the path.
89
+ *
90
+ * Purity is the whole point, not an implementation detail. A port that is
91
+ * merely *free* is not a port a link handed out yesterday can still resolve, so
92
+ * nothing here may read the registry, `.spec-env/`, or any other state on disk:
93
+ * a port that depends on a file changes when the file is deleted, and this
94
+ * exists precisely so it does not change. The same tree gets the same port
95
+ * across a restart, a reboot, and a `--stop`.
96
+ *
97
+ * sha256 rather than a hand-rolled hash because its distribution is not
98
+ * something this file has to argue for — with a hundred slots, clustering is
99
+ * the only way the derivation could fail at its job.
100
+ */
101
+ function derivedServePort(root) {
102
+ const digest = createHash('sha256').update(root).digest()
103
+ return PORT_BASE + (digest.readUInt32BE(0) % PORT_SPAN)
104
+ }
105
+
106
+ /**
107
+ * The port `spec-env review serve` should use, and HOW it was chosen.
108
+ *
109
+ * Three sources, in precedence order: `--port` on this run, an explicit number
110
+ * in `review.servePort`, else the derivation. The `source` rides along so
111
+ * `--status` can answer "why is this on 7742?" from the tool rather than from
112
+ * reading this file.
113
+ *
114
+ * Returns `{ port, source, root? }`; `root` is the resolved path that was
115
+ * hashed, present only on a derived port.
116
+ */
117
+ function resolveServePort(config, dir, override) {
118
+ const flag = Number(override)
119
+ if (override != null && override !== '' && Number.isFinite(flag)) {
120
+ return { port: flag, source: 'flag' }
121
+ }
122
+ const configured = config && config.review ? config.review.servePort : undefined
123
+ if (typeof configured === 'number' && Number.isFinite(configured)) {
124
+ return { port: configured, source: 'configured' }
125
+ }
126
+ const root = servePortRoot(dir)
127
+ return { port: derivedServePort(root), source: 'derived', root }
128
+ }
129
+
130
+ /** One sentence naming how a resolved port was chosen, for `--status`. */
131
+ function servePortReason(source) {
132
+ if (source === 'flag') return 'this run only, from --port'
133
+ if (source === 'configured') return 'pinned by review.servePort'
134
+ if (source === 'derived') return "derived from this repo's path"
135
+ return ''
136
+ }
137
+
51
138
  const DEFAULT_CONFIG = Object.freeze({
52
139
  // Where a spec's branch is built. "worktree" gives every spec its own checkout
53
140
  // — parallel specs, `main` left free — at the cost of a terminal session per
@@ -100,7 +187,16 @@ const DEFAULT_CONFIG = Object.freeze({
100
187
  spec: Object.freeze({ companionPaths: Object.freeze([]) }),
101
188
  // Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
102
189
  baseBranch: '',
103
- guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
190
+ // `mainIsLandingZone` defaults ON wherever isolation is configured, matching
191
+ // the review gate's precedent: the push toward not working on the base branch
192
+ // is the normal path, and stepping off it is the deliberate act. That does
193
+ // change behaviour for a project on upgrade, which is the honest cost — the
194
+ // refusal names both exits (`/no-spec` and `/allow-main`) and this key.
195
+ guards: Object.freeze({
196
+ refuseTeardownIfDirty: true,
197
+ refuseTeardownIfUnpushed: true,
198
+ mainIsLandingZone: true,
199
+ }),
104
200
  // Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
105
201
  // `spec-env down` does about a branch the USER published by hand — nothing
106
202
  // publishes one at provisioning, so there is often no remote ref at all and
@@ -110,9 +206,11 @@ const DEFAULT_CONFIG = Object.freeze({
110
206
  // planned for a LANDED branch — see teardown.js.
111
207
  teardown: Object.freeze({ deleteRemoteBranch: 'prompt' }),
112
208
 
113
- // `reader` decides how a diff's location is worded, and — via
114
- // `serveOnRemote` — whether the engine stands the local server up so a remote
115
- // reader gets a link that opens. It never decides to PUBLISH: publishing
209
+ // `reader` decides how a diff's location is WORDED, and nothing else. It
210
+ // once also decided whether the engine served — a gate that produced the
211
+ // `file://` link on a local machine, where a `file://` page cannot POST and
212
+ // so the verdict buttons had nowhere to go. `serve` owns that now.
213
+ // It never decides to PUBLISH: publishing
116
214
  // leaves a page this tooling cannot remove, so it stays an explicit ask.
117
215
  // `detect` sniffs; `local`/`remote` are the operator's own answer and are
118
216
  // believed without sniffing, because they know where they are reading and no
@@ -132,12 +230,42 @@ const DEFAULT_CONFIG = Object.freeze({
132
230
  // toward reading the diff is the point, and a project that would rather not
133
231
  // be pushed says so once. It is the only key anything reads to decide
134
232
  // whether the gate refuses, so turning it off turns off the hook with it.
233
+ // `serve` decides whether a render stands the local server up: `always`
234
+ // (the default) or `never`. It replaced `serveOnRemote`, whose name would now
235
+ // claim to govern remote renders while governing every one of them — and a
236
+ // config key that lies is what made a wrong fix look right. A legacy
237
+ // `serveOnRemote: false` is still read as `serve: "never"`.
238
+ //
239
+ // THE BIND IS NOT THIS KEY'S BUSINESS, and that separation is what keeps
240
+ // serving-everywhere free of new exposure: `reader` still decides it, so a
241
+ // remote reader binds every interface exactly as before and a local or
242
+ // unknown one binds loopback. Serving more never means listening wider.
243
+ // `allowNetwork` and `allowRemote` are the two tiers a project permits. They
244
+ // exist because the engine CANNOT KNOW where the reader is sitting and kept
245
+ // being asked to guess: detection reported `unknown` on a local session and
246
+ // produced a page whose buttons cannot POST, reported `remote` and produced a
247
+ // LAN URL a phone off the network could not reach, and flipped mid-session
248
+ // and changed the address underneath a reader. Each was fixed on its own; the
249
+ // next reader position would have produced a fourth.
250
+ //
251
+ // So the reader picks instead. `allowNetwork` decides THE BIND — on binds
252
+ // every interface, off binds loopback — which is the last decision detection
253
+ // had. `allowRemote` PERMITS publishing; it never publishes, because each
254
+ // publish leaves a page skitterspec cannot delete.
135
255
  review: Object.freeze({
136
256
  reader: 'detect',
137
- servePort: 7777,
138
- serveOnRemote: true,
257
+ servePort: 'auto',
258
+ serve: 'always',
259
+ allowNetwork: true,
260
+ allowRemote: false,
139
261
  commitWith: '/commit',
140
262
  required: true,
263
+ // External code reviewers whose findings render as CHECKS on the page.
264
+ // Empty by default and never written by `init`: configuring one sends the
265
+ // worktree's diff to whatever the command talks to, which is a decision
266
+ // only the project can make. Two entry shapes — `{use}` names a bundled
267
+ // adapter, `{name, command, format}` is bring-your-own.
268
+ reviewers: Object.freeze([]),
141
269
  }),
142
270
  // Live overlay (`spec-env live`). `migrations` is a list of globs marking
143
271
  // migration files; a branch that changes any of them is treated as stateful and
@@ -155,6 +283,47 @@ function isObject(value) {
155
283
  return value !== null && typeof value === 'object' && !Array.isArray(value)
156
284
  }
157
285
 
286
+ /**
287
+ * List the keys in a parsed config that `mergeConfig` will not read, as dotted
288
+ * paths (`open`, `review.readr`). Advisory only — nothing here refuses, and the
289
+ * merge below is unchanged: unknown keys are still dropped, they are just no
290
+ * longer dropped in silence.
291
+ *
292
+ * The known set is DEFAULT_CONFIG itself rather than a list written out beside
293
+ * it. That is not a shortcut: every key `mergeConfig` reads is necessarily a key
294
+ * of the defaults, because a key with no default has nothing to merge onto — so
295
+ * a hand-written list would be a second copy of the same contract, and a second
296
+ * copy is how the two come to disagree. Adding a key to `mergeConfig` without
297
+ * adding its default is already impossible; this check inherits that.
298
+ *
299
+ * It descends only where the DEFAULT is a plain object. An array's elements are
300
+ * data, not keys — `dev` entries, `setup` commands, `spec.companionPaths`,
301
+ * `live.migrations`, `hotfix.targets` — and walking into them would report every
302
+ * path in the list as an unknown key.
303
+ *
304
+ * WHAT IT DELIBERATELY DOES NOT REPORT: a KNOWN key whose value was rejected for
305
+ * its type or for not matching an enum (`mode: "Checkout"`,
306
+ * `teardown.deleteRemoteBranch: "yes"`). Those already fall through to a
307
+ * documented conservative default, each with a comment saying why, and folding
308
+ * them in here would change what this line means from "I ignored a key you wrote"
309
+ * to "I disagreed with a value you wrote".
310
+ */
311
+ function collectUnknownKeys(parsed, known = DEFAULT_CONFIG, prefix = '') {
312
+ if (!isObject(parsed)) return []
313
+ const out = []
314
+ for (const key of Object.keys(parsed)) {
315
+ const dotted = prefix ? `${prefix}.${key}` : key
316
+ if (!Object.prototype.hasOwnProperty.call(known, key)) {
317
+ out.push(dotted)
318
+ continue
319
+ }
320
+ if (isObject(known[key]) && isObject(parsed[key])) {
321
+ out.push(...collectUnknownKeys(parsed[key], known[key], dotted))
322
+ }
323
+ }
324
+ return out
325
+ }
326
+
158
327
  // A fresh, deeply-mutable copy of the defaults to merge onto.
159
328
  function defaults() {
160
329
  return {
@@ -171,7 +340,7 @@ function defaults() {
171
340
  baseBranch: DEFAULT_CONFIG.baseBranch,
172
341
  guards: { ...DEFAULT_CONFIG.guards },
173
342
  teardown: { ...DEFAULT_CONFIG.teardown },
174
- review: { ...DEFAULT_CONFIG.review },
343
+ review: { ...DEFAULT_CONFIG.review, reviewers: [] },
175
344
  live: { migrations: [] },
176
345
  hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
177
346
  }
@@ -228,6 +397,89 @@ function normalizeFileList(parsed) {
228
397
  return out
229
398
  }
230
399
 
400
+ // How long a reviewer gets before it is killed, in seconds. A generous default:
401
+ // these tools take 30s-3min, and the render they precede is about to wait on a
402
+ // human for far longer.
403
+ const DEFAULT_REVIEWER_TIMEOUT = 180
404
+
405
+ // Output formats the runner can parse. `rdjsonl` is the native contract
406
+ // (reviewdog's interchange shape); a bundled adapter brings its own parser and
407
+ // declares no format at all.
408
+ const REVIEWER_FORMATS = ['rdjsonl']
409
+
410
+ /**
411
+ * Read one `review.reviewers` entry. Returns the normalised entry, or `null`
412
+ * with a reason — the caller decides whether to keep it or report it.
413
+ *
414
+ * Two shapes, and they are told apart by which key is present rather than by
415
+ * guessing: `{use}` names a bundled adapter (the engine owns its command line
416
+ * and its parser), `{name, command}` is bring-your-own. An entry carrying both
417
+ * is refused BY NAME rather than resolved in some order — it is two
418
+ * instructions, and picking one would run a reviewer the author did not ask for.
419
+ *
420
+ * WHETHER A NAMED ADAPTER EXISTS IS NOT CHECKED HERE, deliberately: the adapter
421
+ * registry lives in the runner, and this module must not depend on it to answer
422
+ * a question about shape. The runner reports an unknown `use` as its own
423
+ * outcome.
424
+ */
425
+ function readReviewer(raw) {
426
+ if (!isObject(raw)) return { entry: null, reason: 'not an object' }
427
+ const use = typeof raw.use === 'string' ? raw.use.trim() : ''
428
+ const command = typeof raw.command === 'string' ? raw.command.trim() : ''
429
+ if (use && command) return { entry: null, reason: 'carries both use and command — write one' }
430
+ // A number that is not finite and positive is not a timeout. Falling through
431
+ // to the default is safe in a way `0` would not be: a zero timeout kills every
432
+ // reviewer instantly and would read, on the page, as one that never ran.
433
+ const t = raw.timeout
434
+ const timeout = typeof t === 'number' && Number.isFinite(t) && t > 0 ? t : DEFAULT_REVIEWER_TIMEOUT
435
+ if (use) {
436
+ const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : use
437
+ return { entry: { name, use, command: null, format: null, timeout }, reason: null }
438
+ }
439
+ if (!command) return { entry: null, reason: 'has neither use nor command' }
440
+ const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : ''
441
+ if (!name) return { entry: null, reason: 'has a command but no name' }
442
+ // An unrecognised format is refused rather than defaulted to `rdjsonl`. The
443
+ // scalars above can fall through because their default is what the author
444
+ // most likely meant; here it is not — parsing one tool's output as another's
445
+ // shape yields zero findings and reports the reviewer as clean, which is the
446
+ // false clean this whole feature is built to avoid.
447
+ const fmt = raw.format === undefined || raw.format === null ? 'rdjsonl' : raw.format
448
+ if (!REVIEWER_FORMATS.includes(fmt)) {
449
+ return { entry: null, reason: `format ${JSON.stringify(raw.format)} is not one of ${REVIEWER_FORMATS.join(', ')}` }
450
+ }
451
+ return { entry: { name, use: null, command, format: fmt, timeout }, reason: null }
452
+ }
453
+
454
+ function normalizeReviewers(parsed) {
455
+ const out = []
456
+ for (const raw of parsed) {
457
+ const { entry } = readReviewer(raw)
458
+ if (entry) out.push(entry)
459
+ }
460
+ return out
461
+ }
462
+
463
+ /**
464
+ * List the `review.reviewers` entries the merge dropped, as
465
+ * `{ index, reason }`. Advisory, like `collectUnknownKeys` — nothing refuses.
466
+ *
467
+ * It exists because `collectUnknownKeys` structurally cannot cover this: it
468
+ * descends only where the DEFAULT is a plain object, and this default is an
469
+ * array. Without this, a reviewer entry with a typo in it is dropped in exactly
470
+ * the silence that check was added to end — and worse than an ignored key,
471
+ * because the page would then show no line for it at all.
472
+ */
473
+ function collectBadReviewers(parsed) {
474
+ if (!isObject(parsed) || !isObject(parsed.review) || !Array.isArray(parsed.review.reviewers)) return []
475
+ const out = []
476
+ parsed.review.reviewers.forEach((raw, index) => {
477
+ const { entry, reason } = readReviewer(raw)
478
+ if (!entry) out.push({ index, reason })
479
+ })
480
+ return out
481
+ }
482
+
231
483
  /**
232
484
  * Normalise a parsed `seedFiles` value into `{ mode, files }`. Accepts two forms:
233
485
  * - the array shorthand `[".env", ...]` → { mode: 'symlink', files: [...] }
@@ -319,6 +571,7 @@ function mergeConfig(base, parsed) {
319
571
  if (isObject(parsed.guards)) {
320
572
  assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
321
573
  assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
574
+ assign(base.guards, parsed.guards, 'mainIsLandingZone', 'boolean')
322
575
  }
323
576
 
324
577
  // An unrecognised policy falls through to the default rather than erroring or
@@ -341,11 +594,38 @@ function mergeConfig(base, parsed) {
341
594
  if (reader === 'local' || reader === 'remote' || reader === 'detect') {
342
595
  base.review.reader = reader
343
596
  }
344
- assign(base.review, parsed.review, 'servePort', 'number')
345
- // Opting OUT is the only thing this key can do — a non-boolean leaves the
346
- // default in place rather than being read as a refusal, so a typo cannot
347
- // quietly restore the dead `file://` link on a remote reader.
348
- assign(base.review, parsed.review, 'serveOnRemote', 'boolean')
597
+ // Two accepted forms: a finite number pins the port, and the string "auto"
598
+ // derives it from the repo's path. Anything else is refused BY NAME — the
599
+ // default stands — exactly as `reader`, `mode` and `deleteRemoteBranch`
600
+ // refuse a value they do not recognise.
601
+ //
602
+ // Falling through costs nothing here, and that is worth stating rather than
603
+ // assuming: the only value a typo can fall through to is "auto", which is
604
+ // also the only string that would have been accepted. So a misspelt "auto"
605
+ // behaves identically to the spelling that was meant, and a misspelt number
606
+ // was never a number. There is no reading of this key where silence hides a
607
+ // port the author pinned.
608
+ if (typeof parsed.review.servePort === 'number' && Number.isFinite(parsed.review.servePort)) {
609
+ base.review.servePort = parsed.review.servePort
610
+ } else if (parsed.review.servePort === 'auto') {
611
+ base.review.servePort = 'auto'
612
+ }
613
+ // `always` | `never`; anything else leaves the default standing, so a typo
614
+ // cannot quietly restore the dead `file://` link this key exists to end.
615
+ if (parsed.review.serve === 'always' || parsed.review.serve === 'never') {
616
+ base.review.serve = parsed.review.serve
617
+ }
618
+ // TOLERANCE, NOT MIGRATION — the same rule `readVerdict` follows for the
619
+ // old `approve` spelling. These configs are committed, so a rename with no
620
+ // tolerance breaks every other checkout on the next pull. Only `false` is
621
+ // read: `serveOnRemote: true` said "serve where it matters", which is what
622
+ // `always` now does anyway, so it needs no translation.
623
+ //
624
+ // An explicit `serve` wins, so a config carrying both is read the way its
625
+ // author most recently meant.
626
+ if (parsed.review.serve === undefined && parsed.review.serveOnRemote === false) {
627
+ base.review.serve = 'never'
628
+ }
349
629
  // An empty string leaves `/commit` standing, like every other string key
350
630
  // here. There is nothing it could mean instead: the hand-off has no off
351
631
  // switch, so a blank value is a typo rather than an instruction.
@@ -354,6 +634,19 @@ function mergeConfig(base, parsed) {
354
634
  // leaves the gate ON. Turning off a check that refuses must be something
355
635
  // someone WROTE, never something a typo achieved on their behalf.
356
636
  assign(base.review, parsed.review, 'required', 'boolean')
637
+ // Same shape as the two above: a non-boolean leaves the default standing, so
638
+ // a typo cannot quietly widen a bind or permit a publish.
639
+ assign(base.review, parsed.review, 'allowNetwork', 'boolean')
640
+ assign(base.review, parsed.review, 'allowRemote', 'boolean')
641
+ // An entry that is neither shape is DROPPED rather than refused, like every
642
+ // other value here — but unlike the scalars above there is no default for it
643
+ // to fall through to, so a dropped entry is a reviewer that silently never
644
+ // runs. `collectUnknownKeys` cannot see it either: the default is an array,
645
+ // and it deliberately does not walk into one. So the dropping is reported
646
+ // separately, by `collectBadReviewers` below.
647
+ if (Array.isArray(parsed.review.reviewers)) {
648
+ base.review.reviewers = normalizeReviewers(parsed.review.reviewers)
649
+ }
357
650
  }
358
651
 
359
652
  if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
@@ -377,10 +670,15 @@ function mergeConfig(base, parsed) {
377
670
 
378
671
  /**
379
672
  * Load and normalise `specs/.core/env.config.json` from `dir` (default cwd).
380
- * Returns `{ config, present }`:
381
- * - missing file → `{ config: defaults, present: false }` (opt-out; never throws)
382
- * - present → `{ config: merged, present: true }`
673
+ * Returns `{ config, present, unknown }`:
674
+ * - missing file → `{ config: defaults, present: false, unknown: [] }`
675
+ * (opt-out; never throws)
676
+ * - present → `{ config: merged, present: true, unknown: [...] }`
383
677
  * Malformed JSON → throws a clear Error (callers exit non-zero).
678
+ *
679
+ * `unknown` lists the dotted paths the merge ignored, for a caller to report.
680
+ * It is advisory in the strongest sense: nothing here acts on it, and a config
681
+ * full of strays loads exactly as it always did.
384
682
  */
385
683
  function loadEnvConfig(dir = process.cwd()) {
386
684
  const base = defaults()
@@ -390,7 +688,7 @@ function loadEnvConfig(dir = process.cwd()) {
390
688
  try {
391
689
  raw = readFileSync(file, 'utf-8')
392
690
  } catch (error) {
393
- if (error.code === 'ENOENT') return { config: base, present: false }
691
+ if (error.code === 'ENOENT') return { config: base, present: false, unknown: [], badReviewers: [] }
394
692
  throw error
395
693
  }
396
694
 
@@ -401,12 +699,28 @@ function loadEnvConfig(dir = process.cwd()) {
401
699
  throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
402
700
  }
403
701
 
404
- return { config: mergeConfig(base, parsed), present: true }
702
+ return {
703
+ config: mergeConfig(base, parsed),
704
+ present: true,
705
+ unknown: collectUnknownKeys(parsed),
706
+ badReviewers: collectBadReviewers(parsed),
707
+ }
405
708
  }
406
709
 
407
710
  module.exports = {
408
711
  loadEnvConfig,
712
+ resolveServePort,
713
+ derivedServePort,
714
+ servePortRoot,
715
+ servePortReason,
716
+ PORT_BASE,
717
+ PORT_SPAN,
718
+ collectUnknownKeys,
719
+ collectBadReviewers,
720
+ readReviewer,
409
721
  mergeConfig,
410
722
  DEFAULT_CONFIG,
723
+ DEFAULT_REVIEWER_TIMEOUT,
724
+ REVIEWER_FORMATS,
411
725
  CONFIG_FILE,
412
726
  }