@skitterbyte/skitterspec 22.0.0 → 22.2.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/README.md +14 -0
- package/assets/claude-md-section.md +13 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/env.config.json.example +2 -1
- package/assets/core/env.config.md +83 -2
- package/assets/hooks/main-guard.cjs +145 -0
- package/assets/hooks/review-gate.cjs +8 -3
- package/assets/review/page.html +132 -2
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +160 -11
- package/assets/rules/spec-reports.md +66 -9
- package/assets/skills/no-spec/SKILL.md +221 -0
- package/assets/skills/spec/SKILL.md +138 -37
- package/assets/skills/spec-bug/SKILL.md +8 -2
- 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 +36 -2
- package/assets/skills/spec-init/SKILL.md +4 -2
- package/assets/skills/spec-next/SKILL.md +50 -3
- package/assets/skills/spec-reviewed/SKILL.md +3 -3
- package/assets/skills/spec-start/SKILL.md +14 -0
- package/package.json +1 -1
- package/src/cli.js +749 -62
- package/src/env/config.js +121 -4
- package/src/env/hooks.js +102 -33
- package/src/env/live.js +30 -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 +113 -4
- package/src/env/review.js +324 -20
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +189 -44
- package/src/env/teardown.js +12 -1
- package/src/init.js +125 -15
package/src/env/provision.js
CHANGED
|
@@ -230,6 +230,60 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
|
230
230
|
return ok
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/**
|
|
234
|
+
* The `-f <composeFile>` argument for a `docker compose` invocation.
|
|
235
|
+
*
|
|
236
|
+
* ONLY when the configured file is known to be present, and that condition is
|
|
237
|
+
* the whole point. `docker.composeFile` was read, normalised and validated by
|
|
238
|
+
* the config loader and then never passed to docker at all, so a project naming
|
|
239
|
+
* a non-default file was silently relying on docker's own discovery instead.
|
|
240
|
+
* Passing it unconditionally would fix that project and BREAK the mirror image
|
|
241
|
+
* of it — one whose compose file is `compose.yaml` (a name docker finds by
|
|
242
|
+
* itself) while this key sits at its untouched default of `docker-compose.yml`.
|
|
243
|
+
*
|
|
244
|
+
* So the flag is added on the positive signal and omitted otherwise, which
|
|
245
|
+
* leaves docker's discovery exactly as it is for everyone it was already
|
|
246
|
+
* working for.
|
|
247
|
+
*/
|
|
248
|
+
function composeFileArg(config, present) {
|
|
249
|
+
const file = config && config.docker && config.docker.composeFile
|
|
250
|
+
return present === true && file ? ` -f ${file}` : ''
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Does this spec want a Docker stack?
|
|
255
|
+
*
|
|
256
|
+
* ONE function, called by `planUp` and by the `up` command in `cli.js`. They
|
|
257
|
+
* used to each carry their own copy and the copies disagreed: the CLI read
|
|
258
|
+
* `spec.stack === 'docker'` with no fallback, so a header-less spec allocated no
|
|
259
|
+
* registry slot, while the planner fell back to the master switch and emitted
|
|
260
|
+
* `docker compose up -d` anyway — a stack brought up on a port offset derived
|
|
261
|
+
* from a slot that was never allocated.
|
|
262
|
+
*
|
|
263
|
+
* A SPEC WITH NO `Stack:` HEADER NEEDS THE COMPOSE FILE TO BE PRESENT. The
|
|
264
|
+
* master switch alone is not evidence that there is a stack to bring up: it is
|
|
265
|
+
* a project-wide default, and `/spec` has written the header on every spec for
|
|
266
|
+
* some time, so the specs reaching this fallback are legacy or hand-written. The
|
|
267
|
+
* compose file is the positive signal that the escalation would land on
|
|
268
|
+
* something real (`.claude/rules/negative-checks.md` rule 1).
|
|
269
|
+
*
|
|
270
|
+
* WHAT WOULD FOOL THIS, deliberately unhandled: a project whose compose file is
|
|
271
|
+
* generated during `setup` rather than committed. It does not exist when the
|
|
272
|
+
* plan is built, so a header-less spec there resolves to `worktree`. The answer
|
|
273
|
+
* is an explicit `> **Stack:** worktree + docker` header, which is checked first
|
|
274
|
+
* and never consults this at all — so being wrong costs a header, not a stack.
|
|
275
|
+
*
|
|
276
|
+
* `composeFilePresent` undefined means the caller did not look, which is
|
|
277
|
+
* cannot-tell rather than false. It routes to no escalation, the harmless
|
|
278
|
+
* branch: a missing stack surfaces as a header to add, where a spurious one
|
|
279
|
+
* surfaces as `docker compose` failing mid-provision (rule 4).
|
|
280
|
+
*/
|
|
281
|
+
function resolveStack(spec, config, { docs = false, composeFilePresent } = {}) {
|
|
282
|
+
const stack =
|
|
283
|
+
spec.stack || (config.docker.enabled && composeFilePresent === true ? 'docker' : 'worktree')
|
|
284
|
+
return { stack, wantsDocker: stack === 'docker' && config.docker.enabled && !docs }
|
|
285
|
+
}
|
|
286
|
+
|
|
233
287
|
/**
|
|
234
288
|
* Plan a provisioning run.
|
|
235
289
|
*
|
|
@@ -238,18 +292,34 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
|
238
292
|
* @param {object} alloc { slot, attached } — attached:true when the slot already
|
|
239
293
|
* existed in the registry (re-run → attach, don't clobber).
|
|
240
294
|
* @param {object} config normalised env config.
|
|
295
|
+
* @param {object} opts { docs } — documents mode (see below). Optional.
|
|
241
296
|
* @returns {object} { worktreePath, branch, projectName, slot, portOffset,
|
|
242
297
|
* envContents, commands, seedCommands,
|
|
243
|
-
* setupCommands, attached }
|
|
298
|
+
* setupCommands, docs, attached }
|
|
244
299
|
*/
|
|
245
|
-
function planUp(spec, alloc, config, ctx) {
|
|
300
|
+
function planUp(spec, alloc, config, ctx, opts = {}) {
|
|
246
301
|
const { slot, attached } = alloc
|
|
247
302
|
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
303
|
+
// DOCUMENTS MODE — a worktree to write a spec in, not to run code in.
|
|
304
|
+
//
|
|
305
|
+
// `/spec` provisions before it writes the spec folder, so that authoring never
|
|
306
|
+
// touches the base branch. What it needs is a checkout on a branch; what it
|
|
307
|
+
// does not need is `pnpm install --frozen-lockfile` and a database, to write
|
|
308
|
+
// markdown. So this mode keeps the fork and drops everything whose only
|
|
309
|
+
// purpose is making the tree runnable.
|
|
310
|
+
//
|
|
311
|
+
// It is not a lesser provision that has to be undone: `/spec-start` re-runs
|
|
312
|
+
// `up` over the same worktree WITHOUT this flag, and the setup commands run
|
|
313
|
+
// then — at the first moment the tree is used for code.
|
|
314
|
+
const docs = opts.docs === true
|
|
315
|
+
|
|
316
|
+
// Per-spec escalation, decided in ONE place (`resolveStack`) so the planner and
|
|
317
|
+
// the CLI cannot answer it differently — which they did, for exactly the
|
|
318
|
+
// header-less spec this gate is about.
|
|
319
|
+
const { wantsDocker } = resolveStack(spec, config, {
|
|
320
|
+
docs,
|
|
321
|
+
composeFilePresent: ctx ? ctx.composeFilePresent : undefined,
|
|
322
|
+
})
|
|
253
323
|
|
|
254
324
|
// Slot, port block and `.env` are Docker-only. A worktree-only spec takes none
|
|
255
325
|
// of them: no registry slot, no PORT_OFFSET, no `.env`.
|
|
@@ -298,7 +368,10 @@ function planUp(spec, alloc, config, ctx) {
|
|
|
298
368
|
: `git worktree add ${spec.worktreePath} -b ${spec.branch}${forkPoint}`,
|
|
299
369
|
)
|
|
300
370
|
if (wantsDocker) {
|
|
301
|
-
commands.push(
|
|
371
|
+
commands.push(
|
|
372
|
+
`docker compose${composeFileArg(config, ctx ? ctx.composeFilePresent : undefined)}` +
|
|
373
|
+
` --project-name ${spec.projectName} up -d`,
|
|
374
|
+
)
|
|
302
375
|
}
|
|
303
376
|
|
|
304
377
|
// The tree gate. A worktree forks from the base branch's tree, so an
|
|
@@ -325,7 +398,16 @@ function planUp(spec, alloc, config, ctx) {
|
|
|
325
398
|
envContents,
|
|
326
399
|
commands: gate.blocked ? [] : [...gate.commands, ...commands],
|
|
327
400
|
seedCommands: gate.blocked ? [] : seedCommands,
|
|
328
|
-
|
|
401
|
+
// Documents mode drops these, and keeps the seed commands above. The two are
|
|
402
|
+
// not the same kind of thing: seeding links the gitignored files a worktree
|
|
403
|
+
// has none of — cheap, and a spec author may well want `.env` present —
|
|
404
|
+
// while `setup` is the expensive half that exists to make the tree run.
|
|
405
|
+
setupCommands: gate.blocked || docs ? [] : setupCommands,
|
|
406
|
+
// Reported so a caller reading `--json` can tell a documents worktree from a
|
|
407
|
+
// full one POSITIVELY, rather than inferring it from what is missing. An
|
|
408
|
+
// empty `setupCommands` also describes a project that configured no setup
|
|
409
|
+
// at all, and those two must not read alike.
|
|
410
|
+
docs,
|
|
329
411
|
attached,
|
|
330
412
|
}
|
|
331
413
|
}
|
|
@@ -347,6 +429,12 @@ function planUp(spec, alloc, config, ctx) {
|
|
|
347
429
|
* refusal — it is the re-run, and the answer is "already attached".
|
|
348
430
|
*
|
|
349
431
|
* There is no bootstrap: the primary checkout already has its dependencies.
|
|
432
|
+
*
|
|
433
|
+
* AND THAT IS WHY `--docs` IS INERT HERE, rather than unimplemented. Documents
|
|
434
|
+
* mode exists to skip making a fresh tree runnable; this mode makes no fresh
|
|
435
|
+
* tree, so there is nothing for it to skip. The flag is accepted and changes
|
|
436
|
+
* nothing, because a caller passing it is asking for a cheap checkout and gets
|
|
437
|
+
* one — refusing would make `/spec` mode-aware for no behavioural difference.
|
|
350
438
|
*/
|
|
351
439
|
function planCheckoutUp(spec, ctx, config) {
|
|
352
440
|
const base = ctx.base || (config && config.baseBranch) || 'main'
|
|
@@ -388,4 +476,12 @@ function planCheckoutUp(spec, ctx, config) {
|
|
|
388
476
|
return result
|
|
389
477
|
}
|
|
390
478
|
|
|
391
|
-
module.exports = {
|
|
479
|
+
module.exports = {
|
|
480
|
+
planUp,
|
|
481
|
+
planCheckoutUp,
|
|
482
|
+
planSpecCommit,
|
|
483
|
+
seedCommandFor,
|
|
484
|
+
worktreeCd,
|
|
485
|
+
resolveStack,
|
|
486
|
+
composeFileArg,
|
|
487
|
+
}
|
package/src/env/registry.js
CHANGED
|
@@ -34,7 +34,7 @@ function readRegistry(rootDir, config) {
|
|
|
34
34
|
try {
|
|
35
35
|
raw = fs.readFileSync(file, 'utf-8')
|
|
36
36
|
} catch (error) {
|
|
37
|
-
if (error.code === 'ENOENT') return { slots: {} }
|
|
37
|
+
if (error.code === 'ENOENT') return { slots: {}, specless: {} }
|
|
38
38
|
throw error
|
|
39
39
|
}
|
|
40
40
|
let parsed
|
|
@@ -43,14 +43,24 @@ function readRegistry(rootDir, config) {
|
|
|
43
43
|
} catch (error) {
|
|
44
44
|
throw new Error(`Invalid registry ${config.registry}: ${error.message}`)
|
|
45
45
|
}
|
|
46
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
slots: isObject(parsed.slots) ? { ...parsed.slots } : {},
|
|
48
|
+
specless: isObject(parsed.specless) ? { ...parsed.specless } : {},
|
|
49
|
+
}
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
// Persist the registry, creating its parent dir as needed.
|
|
50
53
|
function writeRegistry(rootDir, config, registry) {
|
|
51
54
|
const file = registryPath(rootDir, config)
|
|
52
55
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
53
|
-
|
|
56
|
+
// `specless` is written only when it holds something, so a project that never
|
|
57
|
+
// ran `/no-spec` keeps a registry file byte-identical to the one it had
|
|
58
|
+
// before this key existed.
|
|
59
|
+
const payload = { slots: registry.slots }
|
|
60
|
+
if (registry.specless && Object.keys(registry.specless).length) {
|
|
61
|
+
payload.specless = registry.specless
|
|
62
|
+
}
|
|
63
|
+
fs.writeFileSync(file, JSON.stringify(payload, null, 2) + '\n')
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
/**
|
|
@@ -60,14 +70,19 @@ function writeRegistry(rootDir, config, registry) {
|
|
|
60
70
|
*/
|
|
61
71
|
function allocateSlot(registry, name) {
|
|
62
72
|
const slots = { ...registry.slots }
|
|
73
|
+
// CARRIED THROUGH, NOT REBUILT. Every one of these helpers returns a whole
|
|
74
|
+
// registry, so a helper that forgets a key silently deletes it on the next
|
|
75
|
+
// write — which is how a `/no-spec` branch would have become unresolvable the
|
|
76
|
+
// moment any Docker spec was provisioned beside it.
|
|
77
|
+
const specless = { ...(registry.specless || {}) }
|
|
63
78
|
if (Object.prototype.hasOwnProperty.call(slots, name)) {
|
|
64
|
-
return { registry: { slots }, slot: slots[name] }
|
|
79
|
+
return { registry: { slots, specless }, slot: slots[name] }
|
|
65
80
|
}
|
|
66
81
|
const used = new Set(Object.values(slots))
|
|
67
82
|
let slot = 0
|
|
68
83
|
while (used.has(slot)) slot++
|
|
69
84
|
slots[name] = slot
|
|
70
|
-
return { registry: { slots }, slot }
|
|
85
|
+
return { registry: { slots, specless }, slot }
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
/**
|
|
@@ -77,7 +92,7 @@ function allocateSlot(registry, name) {
|
|
|
77
92
|
function freeSlot(registry, name) {
|
|
78
93
|
const slots = { ...registry.slots }
|
|
79
94
|
delete slots[name]
|
|
80
|
-
return { slots }
|
|
95
|
+
return { slots, specless: { ...(registry.specless || {}) } }
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
// Port block base for a slot.
|
|
@@ -85,6 +100,51 @@ function portOffset(slot, config) {
|
|
|
85
100
|
return config.docker.portBase + slot * config.docker.portsPerSpec
|
|
86
101
|
}
|
|
87
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Record a SPECLESS branch — `/no-spec` work, which has a worktree and a branch
|
|
105
|
+
* and no spec document anywhere.
|
|
106
|
+
*
|
|
107
|
+
* A SEPARATE KEY RATHER THAN A SLOT, and that is the whole design of it. A slot
|
|
108
|
+
* means a reserved port block, which mechanical work does not want; and every
|
|
109
|
+
* consumer of `slots` reads an entry there as "this spec runs a Docker stack".
|
|
110
|
+
* Sharing the map would have made `/no-spec` allocate ports it never binds and
|
|
111
|
+
* appear as a stateful spec in `status`.
|
|
112
|
+
*
|
|
113
|
+
* It is what makes a specless name RESOLVABLE. Without a record, "no spec folder
|
|
114
|
+
* for this name" is indistinguishable from a typo — and `resolve.js` would have
|
|
115
|
+
* to treat every unknown name as possibly-specless, which is exactly the
|
|
116
|
+
* absence-as-evidence this codebase refuses (`.claude/rules/negative-checks.md`
|
|
117
|
+
* rule 1). This is the positive signal: the engine wrote this name down.
|
|
118
|
+
*
|
|
119
|
+
* Idempotent, and pure — returns a new registry object.
|
|
120
|
+
*/
|
|
121
|
+
function recordSpecless(registry, name, entry = {}) {
|
|
122
|
+
const specless = { ...(registry.specless || {}) }
|
|
123
|
+
specless[name] = { ...(specless[name] || {}), ...entry }
|
|
124
|
+
return { slots: { ...registry.slots }, specless }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Forget a specless branch. Idempotent: forgetting an absent one is a clean
|
|
129
|
+
* no-op, which is what lets teardown call it without looking first.
|
|
130
|
+
*/
|
|
131
|
+
function forgetSpecless(registry, name) {
|
|
132
|
+
const specless = { ...(registry.specless || {}) }
|
|
133
|
+
delete specless[name]
|
|
134
|
+
return { slots: { ...registry.slots }, specless }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Is this name a recorded specless branch? Pure, and the positive signal
|
|
138
|
+
// `resolve.js` asks for before it will resolve a name with no spec folder.
|
|
139
|
+
function isSpecless(registry, name) {
|
|
140
|
+
return Object.prototype.hasOwnProperty.call((registry && registry.specless) || {}, name)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Every recorded specless name, sorted so output is stable. Pure.
|
|
144
|
+
function speclessNames(registry) {
|
|
145
|
+
return Object.keys((registry && registry.specless) || {}).sort()
|
|
146
|
+
}
|
|
147
|
+
|
|
88
148
|
module.exports = {
|
|
89
149
|
registryPath,
|
|
90
150
|
readRegistry,
|
|
@@ -92,4 +152,8 @@ module.exports = {
|
|
|
92
152
|
allocateSlot,
|
|
93
153
|
freeSlot,
|
|
94
154
|
portOffset,
|
|
155
|
+
recordSpecless,
|
|
156
|
+
forgetSpecless,
|
|
157
|
+
isSpecless,
|
|
158
|
+
speclessNames,
|
|
95
159
|
}
|
package/src/env/resolve.js
CHANGED
|
@@ -468,17 +468,87 @@ function assertPrimaryOnMain(config, git) {
|
|
|
468
468
|
* That is `bug-spec-env-cwd-anchor`'s fix and it is deliberately untouched here;
|
|
469
469
|
* only which *file* is read moves.
|
|
470
470
|
*/
|
|
471
|
+
/**
|
|
472
|
+
* Resolve a SPECLESS branch — `/no-spec` work — to the same shape `resolveSpec`
|
|
473
|
+
* returns, minus every field that comes from a spec document.
|
|
474
|
+
*
|
|
475
|
+
* `bucket`, `path`, `stack` and `baseRef` are null rather than invented: there
|
|
476
|
+
* is no document to read them from, and a plausible default would make a
|
|
477
|
+
* consumer believe it had read one. `specless: true` is the flag every caller
|
|
478
|
+
* branches on, so "is there a spec here?" is a field rather than an inference
|
|
479
|
+
* from a missing `path`.
|
|
480
|
+
*
|
|
481
|
+
* The branch is `chore/{slug}` by default — `branch.pattern` already carries a
|
|
482
|
+
* `{type}`, so nothing about branch naming is special-cased here. A recorded
|
|
483
|
+
* entry may name its own `branch`, which wins: the record is what the worktree
|
|
484
|
+
* was actually created against, and re-deriving it would go wrong the moment a
|
|
485
|
+
* project changed its pattern under a standing branch.
|
|
486
|
+
*
|
|
487
|
+
* Pure but for `repoInfo`, like `resolveSpec`.
|
|
488
|
+
*/
|
|
489
|
+
function resolveSpecless(name, dir, config, entry = {}) {
|
|
490
|
+
const { repo, repoSlug } = repoInfo(dir)
|
|
491
|
+
// AN AUTHORING RECORD RESOLVES AS THE SPEC IT WILL BE. `spec: true` marks a
|
|
492
|
+
// record written by `up --docs` for a spec not written yet (the `/spec`
|
|
493
|
+
// authoring lane), so type and slug come from the name's own prefix — the
|
|
494
|
+
// branch and worktree it answers with MUST equal what the written spec will
|
|
495
|
+
// later resolve to, or the confirm step lands in one tree and the documents
|
|
496
|
+
// in another. A plain `/no-spec` record keeps the whole name as its slug,
|
|
497
|
+
// byte-identical to before.
|
|
498
|
+
const authored = entry && entry.spec === true
|
|
499
|
+
const slug = authored ? splitPrefix(name).slug : name
|
|
500
|
+
const tokens = { repo, repoSlug, slug }
|
|
501
|
+
const spec = {
|
|
502
|
+
folder: name,
|
|
503
|
+
bucket: null,
|
|
504
|
+
path: null,
|
|
505
|
+
type: authored ? splitPrefix(name).type : (entry && entry.type) || 'chore',
|
|
506
|
+
slug,
|
|
507
|
+
stack: 'worktree',
|
|
508
|
+
baseRef: null,
|
|
509
|
+
specless: true,
|
|
510
|
+
unwritten: authored,
|
|
511
|
+
}
|
|
512
|
+
const branch = (entry && entry.branch) || branchFor(spec, config)
|
|
513
|
+
const worktreeRoot = expandTokens(config.worktree.root, tokens)
|
|
514
|
+
const worktreeFolder = expandTokens(config.worktree.folderPattern, tokens)
|
|
515
|
+
return {
|
|
516
|
+
...spec,
|
|
517
|
+
repo,
|
|
518
|
+
repoSlug,
|
|
519
|
+
branch,
|
|
520
|
+
worktreeRoot,
|
|
521
|
+
worktreeFolder,
|
|
522
|
+
worktreePath: path.resolve(dir, worktreeRoot, worktreeFolder),
|
|
523
|
+
projectName: expandTokens(config.docker.projectNamePattern, tokens),
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
471
527
|
function resolveSpec(specArg, dir, config, opts = {}) {
|
|
472
528
|
const searchDirs = opts.searchDirs || []
|
|
473
529
|
const preferDirs = opts.preferDirs || []
|
|
474
530
|
const found = findSpecFolder(specArg, dir, searchDirs, preferDirs)
|
|
475
531
|
if (!found) {
|
|
532
|
+
// A SPECLESS BRANCH, but only on the registry's word. `/no-spec` work has a
|
|
533
|
+
// worktree and a branch and no spec document anywhere, so it can never be
|
|
534
|
+
// found by looking under `specs/**` — and "not found there" is not evidence
|
|
535
|
+
// of it, because a typo looks identical (`.claude/rules/negative-checks.md`
|
|
536
|
+
// rule 1). The caller passes the registry's specless map, and being named in
|
|
537
|
+
// it is the positive signal; without one, nothing changes and the error
|
|
538
|
+
// below is thrown exactly as before.
|
|
539
|
+
if (opts.specless && Object.prototype.hasOwnProperty.call(opts.specless, specArg)) {
|
|
540
|
+
return resolveSpecless(specArg, dir, config, opts.specless[specArg])
|
|
541
|
+
}
|
|
476
542
|
// Name the roots we looked under: the usual cause is a spec that only exists
|
|
477
543
|
// on its own branch, and the message should say where we didn't find it.
|
|
478
|
-
|
|
544
|
+
// The code is what lets `up --docs` catch exactly this refusal and open the
|
|
545
|
+
// authoring lane — matching on the message would make its wording API.
|
|
546
|
+
const err = new Error(
|
|
479
547
|
`spec not found under specs/**: ${specArg} ` +
|
|
480
548
|
`(searched: ${[...preferDirs, dir, ...searchDirs].join(', ')})`,
|
|
481
549
|
)
|
|
550
|
+
err.code = 'SPEC_NOT_FOUND'
|
|
551
|
+
throw err
|
|
482
552
|
}
|
|
483
553
|
|
|
484
554
|
const { type, slug } = splitPrefix(found.folder)
|
|
@@ -489,7 +559,19 @@ function resolveSpec(specArg, dir, config, opts = {}) {
|
|
|
489
559
|
// A hotfix forks its worktree from a release tag (its `Base version`) instead
|
|
490
560
|
// of base HEAD; every other type resolves to baseRef:null (fork from HEAD).
|
|
491
561
|
const baseRef = type === 'hotfix' ? readBaseVersionField(found.path) : null
|
|
492
|
-
|
|
562
|
+
// `specless: false` is written out rather than left off, so every consumer
|
|
563
|
+
// reads the same field on every resolution instead of treating `undefined` as
|
|
564
|
+
// an answer.
|
|
565
|
+
const spec = {
|
|
566
|
+
folder: found.folder,
|
|
567
|
+
bucket: found.bucket,
|
|
568
|
+
path: found.path,
|
|
569
|
+
type,
|
|
570
|
+
slug,
|
|
571
|
+
stack,
|
|
572
|
+
baseRef,
|
|
573
|
+
specless: false,
|
|
574
|
+
}
|
|
493
575
|
const branch = branchFor(spec, config)
|
|
494
576
|
|
|
495
577
|
const worktreeRoot = expandTokens(config.worktree.root, tokens)
|
|
@@ -553,23 +635,50 @@ function collectSpecFolders(roots) {
|
|
|
553
635
|
* spec that was authored on its branch and never committed to the primary
|
|
554
636
|
* checkout.
|
|
555
637
|
*/
|
|
556
|
-
function allSpecs(dir, config, worktreePaths) {
|
|
638
|
+
function allSpecs(dir, config, worktreePaths, specless = {}) {
|
|
557
639
|
const searchDirs = [...worktreePaths]
|
|
558
640
|
const specs = []
|
|
559
641
|
for (const folder of collectSpecFolders([dir, ...searchDirs])) {
|
|
560
642
|
try {
|
|
561
643
|
const spec = resolveSpec(folder, dir, config, { searchDirs })
|
|
562
|
-
specs.push({
|
|
644
|
+
specs.push({
|
|
645
|
+
folder: spec.folder,
|
|
646
|
+
slug: spec.slug,
|
|
647
|
+
worktreePath: spec.worktreePath,
|
|
648
|
+
specless: false,
|
|
649
|
+
})
|
|
563
650
|
} catch {
|
|
564
651
|
// Unresolvable folder (not a real spec) — skip.
|
|
565
652
|
}
|
|
566
653
|
}
|
|
654
|
+
// SPECLESS BRANCHES ARE LISTED TOO, and they have to be added here rather than
|
|
655
|
+
// found: `collectSpecFolders` scans `specs/**`, where by definition they are
|
|
656
|
+
// not. Leaving them out is what would make a `/no-spec` worktree invisible to
|
|
657
|
+
// `status` and to every bare resolution — the outcome decision 7 rejected for
|
|
658
|
+
// a plain git branch, arrived at by omission instead.
|
|
659
|
+
//
|
|
660
|
+
// A FOLDER SHADOWS ITS OWN RECORD. An authoring record (`up --docs` on an
|
|
661
|
+
// unwritten spec) shares its name with the folder `/spec` then writes, and
|
|
662
|
+
// once that folder exists it is the answer — listing the record beside it
|
|
663
|
+
// would show one spec twice.
|
|
664
|
+
const collected = new Set(specs.map((s) => s.folder))
|
|
665
|
+
for (const name of Object.keys(specless).sort()) {
|
|
666
|
+
if (collected.has(name)) continue
|
|
667
|
+
const spec = resolveSpecless(name, dir, config, specless[name])
|
|
668
|
+
specs.push({
|
|
669
|
+
folder: spec.folder,
|
|
670
|
+
slug: spec.slug,
|
|
671
|
+
worktreePath: spec.worktreePath,
|
|
672
|
+
specless: true,
|
|
673
|
+
})
|
|
674
|
+
}
|
|
567
675
|
return specs
|
|
568
676
|
}
|
|
569
677
|
|
|
570
678
|
module.exports = {
|
|
571
679
|
BUCKETS,
|
|
572
680
|
resolveSpec,
|
|
681
|
+
resolveSpecless,
|
|
573
682
|
liveWorktreePaths,
|
|
574
683
|
collectSpecFolders,
|
|
575
684
|
allSpecs,
|