@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.
- package/MIGRATION.md +218 -0
- package/README.md +127 -6
- package/assets/claude-md-section.md +42 -18
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-remote-review.md +22 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/env.config.json.example +6 -3
- package/assets/core/env.config.md +183 -25
- package/assets/hooks/main-guard.cjs +145 -0
- package/assets/hooks/review-gate.cjs +8 -3
- package/assets/review/page.html +1174 -101
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +195 -14
- package/assets/rules/spec-reports.md +181 -29
- package/assets/skills/no-spec/SKILL.md +219 -0
- package/assets/skills/spec/SKILL.md +248 -21
- package/assets/skills/spec-bug/SKILL.md +34 -24
- package/assets/skills/spec-cancel/SKILL.md +29 -0
- package/assets/skills/spec-complete/SKILL.md +25 -0
- package/assets/skills/spec-diff/SKILL.md +164 -37
- package/assets/skills/spec-hotfix/SKILL.md +22 -19
- package/assets/skills/spec-init/SKILL.md +4 -2
- package/assets/skills/spec-next/SKILL.md +166 -58
- package/assets/skills/spec-review/SKILL.md +87 -0
- package/assets/skills/spec-reviewed/SKILL.md +31 -5
- package/assets/skills/spec-start/SKILL.md +33 -0
- package/package.json +1 -1
- package/src/cli.js +1612 -172
- package/src/env/classify.js +87 -2
- package/src/env/config.js +333 -19
- package/src/env/hooks.js +102 -33
- package/src/env/live.js +124 -1
- package/src/env/mainguard.js +240 -0
- package/src/env/provision.js +106 -10
- package/src/env/registry.js +70 -6
- package/src/env/resolve.js +128 -5
- package/src/env/review.js +854 -29
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +474 -50
- package/src/env/supervise.js +8 -1
- package/src/env/teardown.js +12 -1
- package/src/init.js +185 -24
package/src/env/classify.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
47
|
-
const {
|
|
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
|
-
|
|
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
|
|
114
|
-
//
|
|
115
|
-
//
|
|
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:
|
|
138
|
-
|
|
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
|
-
|
|
345
|
-
//
|
|
346
|
-
// default
|
|
347
|
-
//
|
|
348
|
-
|
|
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 }`
|
|
382
|
-
*
|
|
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 {
|
|
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
|
}
|