@skitterbyte/skitterspec 12.0.0 → 13.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.
@@ -0,0 +1,119 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure planner for `spec-env prune` — the orphaned-test-DB reaper.
5
+ *
6
+ * Per-spec isolation gives each Docker-escalated spec its own compose stack
7
+ * namespaced by `{repoSlug}_{slug}`; the DB storage is a Docker named volume
8
+ * (`{repoSlug}_{slug}_<volname>`). Those volumes only ever get dropped by an
9
+ * explicit single-spec `spec-env down`, so they leak whenever that path is
10
+ * skipped (declined/guard-aborted teardown, manual `git worktree remove`,
11
+ * `--keep-volumes`). This planner reconciles the live namespace volumes against
12
+ * the specs that are still live and returns the `docker volume rm` commands for
13
+ * the orphans.
14
+ *
15
+ * **Liveness = an existing worktree, NOT the slot registry.** The registry is
16
+ * exactly what goes stale (a declined teardown leaves both the slot and the
17
+ * volume behind), so the caller derives `liveSlugs` from real worktrees and the
18
+ * registry is only reconciled afterwards. See the spec Decisions.
19
+ *
20
+ * We never parse a slug out of an orphan. Instead we build a protected-prefix
21
+ * set from the live slugs (`{repoSlug}_{slug}_`) and keep any volume matching
22
+ * one — everything else in the `{repoSlug}_` namespace is an orphan. The
23
+ * trailing `_` makes the slug match exact: slugs are kebab-case (`[a-z0-9-]`)
24
+ * and `_` is the compose project/volume separator, so `add` never protects
25
+ * `add-widget`.
26
+ *
27
+ * Side-effect free and deterministic: no `Date.now()` — the caller supplies
28
+ * `now` (and per-volume `createdAt`) when age-gating.
29
+ */
30
+
31
+ const { splitPrefix } = require('./resolve.js')
32
+
33
+ /**
34
+ * @param {Array<string|{name:string,createdAt?:number|null}>} volumes
35
+ * live Docker volume names (or objects carrying `createdAt` epoch-ms for age
36
+ * gating). Plain strings are treated as unknown-age.
37
+ * @param {Iterable<string>} liveSlugs slugs of specs that still have a worktree.
38
+ * @param {object} opts { repoSlug, olderThanDays?, now? }
39
+ * `repoSlug` (required) is the namespace prefix. `olderThanDays` (optional)
40
+ * keeps only orphans strictly older than the cutoff; when set, `now`
41
+ * (epoch-ms) is required and volumes of unknown age are conservatively kept.
42
+ * @returns {{ orphans: Array<{name:string,createdAt:number|null}>, commands: string[] }}
43
+ */
44
+ function planPrune(volumes, liveSlugs, opts = {}) {
45
+ const { repoSlug, olderThanDays = null, now = null } = opts
46
+ if (!repoSlug) throw new Error('planPrune: opts.repoSlug is required')
47
+
48
+ const namespace = `${repoSlug}_`
49
+ const live = liveSlugs instanceof Set ? liveSlugs : new Set(liveSlugs || [])
50
+ const protectedPrefixes = [...live].map((slug) => `${repoSlug}_${slug}_`)
51
+
52
+ let orphans = (volumes || [])
53
+ .map((v) => (typeof v === 'string' ? { name: v, createdAt: null } : v))
54
+ .filter((v) => {
55
+ const name = v && v.name
56
+ if (!name || !name.startsWith(namespace)) return false
57
+ return !protectedPrefixes.some((p) => name.startsWith(p))
58
+ })
59
+ .map((v) => ({ name: v.name, createdAt: v.createdAt == null ? null : v.createdAt }))
60
+
61
+ if (olderThanDays != null) {
62
+ if (now == null) throw new Error('planPrune: olderThanDays requires opts.now')
63
+ const cutoff = now - olderThanDays * 24 * 60 * 60 * 1000
64
+ // Unknown age (createdAt == null) is kept — never drop what we can't date.
65
+ orphans = orphans.filter((v) => v.createdAt != null && v.createdAt <= cutoff)
66
+ }
67
+
68
+ const commands = orphans.map((v) => `docker volume rm ${v.name}`)
69
+ return { orphans, commands }
70
+ }
71
+
72
+ /**
73
+ * Derive the set of live spec slugs from the specs that still have a git
74
+ * worktree. The **worktree is the liveness signal** (see module header): the
75
+ * registry is not consulted here. Pure — the caller supplies the spec list and
76
+ * the set of live worktree paths (both gathered via IO).
77
+ *
78
+ * @param {Array<{slug:string, worktreePath:string}>} specs
79
+ * @param {Set<string>|Iterable<string>} liveWorktreePaths absolute paths.
80
+ * @returns {Set<string>} slugs whose worktree currently exists.
81
+ */
82
+ function liveSlugsForSpecs(specs, liveWorktreePaths) {
83
+ const live = liveWorktreePaths instanceof Set ? liveWorktreePaths : new Set(liveWorktreePaths || [])
84
+ const slugs = new Set()
85
+ for (const spec of specs || []) {
86
+ if (spec && spec.worktreePath && live.has(spec.worktreePath)) slugs.add(spec.slug)
87
+ }
88
+ return slugs
89
+ }
90
+
91
+ /**
92
+ * Reconcile the slot registry against the volumes we're about to reap. A stale
93
+ * slot is one whose spec's DB volume is an orphan — freeing it converges the
94
+ * registry back to what actually exists (the registry is keyed by spec *folder*,
95
+ * so we split each folder's prefix to get its slug and match it to a reaped
96
+ * volume by the same `{repoSlug}_{slug}_` prefix used for orphan detection).
97
+ * Pure: returns a new registry object plus the folders freed; never mutates.
98
+ *
99
+ * @param {{slots: Object<string,number>}} registry
100
+ * @param {Array<{name:string}>} orphans the volumes planPrune decided to reap.
101
+ * @param {string} repoSlug
102
+ * @returns {{ registry: {slots: Object<string,number>}, freed: string[] }}
103
+ */
104
+ function reconcileRegistry(registry, orphans, repoSlug) {
105
+ const slots = { ...((registry && registry.slots) || {}) }
106
+ const reaped = (orphans || []).map((o) => o.name)
107
+ const freed = []
108
+ for (const folder of Object.keys(slots)) {
109
+ const { slug } = splitPrefix(folder)
110
+ const prefix = `${repoSlug}_${slug}_`
111
+ if (reaped.some((name) => name.startsWith(prefix))) {
112
+ delete slots[folder]
113
+ freed.push(folder)
114
+ }
115
+ }
116
+ return { registry: { slots }, freed }
117
+ }
118
+
119
+ module.exports = { planPrune, liveSlugsForSpecs, reconcileRegistry }
@@ -36,10 +36,10 @@ function findSpecFolder(specArg, dir, extraDirs = []) {
36
36
  return null
37
37
  }
38
38
 
39
- // Split a `feat-`/`bug-` prefix. Unknown prefix → type defaults to `feat` and
40
- // the whole folder name is the slug.
39
+ // Split a `feat-`/`bug-`/`hotfix-` prefix. Unknown prefix → type defaults to
40
+ // `feat` and the whole folder name is the slug.
41
41
  function splitPrefix(folder) {
42
- const m = /^(feat|bug)-(.+)$/.exec(folder)
42
+ const m = /^(feat|bug|hotfix)-(.+)$/.exec(folder)
43
43
  if (m) return { type: m[1], slug: m[2] }
44
44
  return { type: 'feat', slug: folder }
45
45
  }
@@ -104,6 +104,26 @@ function readStackField(specPath, config) {
104
104
  return config.docker && config.docker.enabled ? 'docker' : 'worktree'
105
105
  }
106
106
 
107
+ /**
108
+ * Read a spec's `> **Base version:** <tag>` blockquote field from 00-overview.md.
109
+ * This is the release tag a hotfix forks its worktree from (e.g. `v33.16.4`),
110
+ * authored by the `/spec-hotfix` skill. Returns the trimmed tag, or null when the
111
+ * field / file is absent — so a non-hotfix spec resolves to `baseRef: null` and
112
+ * provisioning forks from base HEAD as before.
113
+ */
114
+ function readBaseVersionField(specPath) {
115
+ const overview = path.join(specPath, '00-overview.md')
116
+ let raw
117
+ try {
118
+ raw = fs.readFileSync(overview, 'utf-8')
119
+ } catch {
120
+ return null
121
+ }
122
+ const m = /^>\s*\*\*Base version:\*\*\s*(.+)$/m.exec(raw)
123
+ if (!m) return null
124
+ return m[1].trim().replace(/^["'`]|["'`]$/g, '') || null
125
+ }
126
+
107
127
  /**
108
128
  * Derive the git branch for a spec from the provider-neutral `branch.pattern`
109
129
  * (`{type}`, `{slug}`, and optionally `{identifier}`). When the pattern uses
@@ -207,7 +227,10 @@ function resolveSpec(specArg, dir, config, opts = {}) {
207
227
  const tokens = { repo, repoSlug, slug }
208
228
 
209
229
  const stack = readStackField(found.path, config)
210
- const spec = { folder: found.folder, bucket: found.bucket, path: found.path, type, slug, stack }
230
+ // A hotfix forks its worktree from a release tag (its `Base version`) instead
231
+ // of base HEAD; every other type resolves to baseRef:null (fork from HEAD).
232
+ const baseRef = type === 'hotfix' ? readBaseVersionField(found.path) : null
233
+ const spec = { folder: found.folder, bucket: found.bucket, path: found.path, type, slug, stack, baseRef }
211
234
  const branch = branchFor(spec, config)
212
235
 
213
236
  const worktreeRoot = expandTokens(config.worktree.root, tokens)
@@ -239,4 +262,5 @@ module.exports = {
239
262
  expandTokens,
240
263
  findSpecFolder,
241
264
  readStackField,
265
+ readBaseVersionField,
242
266
  }
@@ -21,7 +21,7 @@ const { expandTokens } = require('./resolve.js')
21
21
  * @param {object} spec resolved spec: { slug, branch, worktreePath, projectName, ... }
22
22
  * @param {object} config normalised env config.
23
23
  * @param {object} flags { keepVolumes, force }
24
- * @param {object} ctx { worktreeState: { dirty, unpushed, merged }, timestamp }
24
+ * @param {object} ctx { worktreeState: { dirty, unpushed, merged, reachableFromTag }, timestamp }
25
25
  * @returns {object} { blocked, reason, commands, backupCommand, backupPath,
26
26
  * volumesDropped }
27
27
  */
@@ -30,20 +30,22 @@ function planDown(spec, config, flags, ctx) {
30
30
  const force = Boolean(flags && flags.force)
31
31
  const keepVolumes = Boolean(flags && flags.keepVolumes)
32
32
 
33
+ // A hotfix lands by tag + cherry-pick, so its branch is never an ancestor of
34
+ // base — but once its head is captured by a tag (the deploy tag from
35
+ // `hotfix land`), the commits are recoverable and the branch is safe to drop.
36
+ // Treat "reachable from a tag" as landed, alongside merged.
37
+ const landed = Boolean(worktreeState.merged || worktreeState.reachableFromTag)
38
+
33
39
  // --- guards (overridable with --force) ---
34
40
  if (!force) {
35
41
  if (config.guards.refuseTeardownIfDirty && worktreeState.dirty) {
36
42
  return blocked('worktree has uncommitted changes')
37
43
  }
38
44
  // Unpushed commits are only unsafe when they aren't already integrated into
39
- // the base branch. A branch merged into base carries nothing to lose even
40
- // with no remote — so /spec-complete's local land-then-teardown needs no
41
- // --force. Block only when the commits are both unpushed AND unmerged.
42
- if (
43
- config.guards.refuseTeardownIfUnpushed &&
44
- worktreeState.unpushed &&
45
- !worktreeState.merged
46
- ) {
45
+ // the base branch (or captured by a tag). A landed branch carries nothing to
46
+ // lose even with no remote — so /spec-complete's local land-then-teardown
47
+ // needs no --force. Block only when unpushed AND not landed.
48
+ if (config.guards.refuseTeardownIfUnpushed && worktreeState.unpushed && !landed) {
47
49
  return blocked('worktree has unpushed commits not yet merged into the base branch')
48
50
  }
49
51
  }
@@ -85,11 +87,16 @@ function planDown(spec, config, flags, ctx) {
85
87
  : `git worktree remove ${spec.worktreePath}`,
86
88
  )
87
89
 
88
- // --- delete the branch (safe: -d refuses an unmerged branch, never -D) ---
89
- // Runs after the worktree remove frees the branch. On a forced teardown of an
90
- // unmerged branch this fails loudly; the skill relays it rather than -D-ing.
90
+ // --- delete the branch ---
91
+ // Runs after the worktree remove frees the branch. Normally `-d` (safe: refuses
92
+ // an unmerged branch, never -D). A tag-landed hotfix branch is intentionally NOT
93
+ // an ancestor of base, so `-d` would refuse it — but its commits are captured by
94
+ // the deploy tag, so `-D` is safe *only* in that case. Everything else stays `-d`;
95
+ // on a forced teardown of a genuinely unmerged branch it fails loudly and the
96
+ // skill relays it rather than -D-ing.
91
97
  if (spec.branch) {
92
- commands.push(`git branch -d ${spec.branch}`)
98
+ const tagLanded = Boolean(worktreeState.reachableFromTag && !worktreeState.merged)
99
+ commands.push(`git branch ${tagLanded ? '-D' : '-d'} ${spec.branch}`)
93
100
  }
94
101
 
95
102
  return { blocked: false, reason: null, commands, backupCommand, backupPath, volumesDropped }