@skitterbyte/skitterspec-linear 10.3.0 → 10.5.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,274 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Retarget a mirror after the tracker's team key is renamed.
5
+ *
6
+ * Renaming a Linear team rewrites the key in every issue identifier
7
+ * (`SKI-7` → `SKS-7`). The repo stamps those identifiers in three places — spec
8
+ * frontmatter, the `linear-base` snapshot filenames, and the `subIssues` keys
9
+ * inside those snapshots — and nothing moves them, so afterwards every stamped
10
+ * spec is stale and `/spec-push` fails with `no issue found for SKI-7`.
11
+ *
12
+ * This module is the PURE half: it plans and applies a prefix move over the
13
+ * repo's machine-read fields. It is provider-neutral by design (decision 8) —
14
+ * "old prefix → new prefix over stamps and snapshots" involves no tracker at
15
+ * all, so it belongs here beside `legacy.js`. Detecting the rename and
16
+ * spot-checking an identifier are the only steps that touch a tracker, and they
17
+ * live in the provider's CLI.
18
+ *
19
+ * **Frontmatter only — never prose.** A naive repo-wide `SKI-` → `SKS-`
20
+ * substitution passes a casual eyeball and quietly rewrites the historical
21
+ * record ("Probe SKI-28 falsified the reported hypothesis").
22
+ * `.claude/rules/spec-planning.md` says never delete historical notes, so prose
23
+ * mentions, doc placeholders and test fixtures are out of scope entirely.
24
+ */
25
+
26
+ const fs = require('node:fs')
27
+ const path = require('node:path')
28
+ const { execFileSync } = require('node:child_process')
29
+
30
+ const { parseFrontmatter } = require('./normalize.js')
31
+
32
+ // The lifecycle buckets a spec can live in. Kept local rather than imported so
33
+ // sync-core stays free of the common package.
34
+ const BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
35
+
36
+ // An issue identifier: team key, dash, number. Matched CASE-INSENSITIVELY
37
+ // because `linear_url` carries the identifier lowercased in its path
38
+ // (`…/issue/reu-188/retire-…`). Matching only uppercase left 29 of 33 real URLs
39
+ // in ~/code/ereqs pointing at the old key — the same ones the hand-repair
40
+ // missed. A rewrite only happens when the key matches `oldKey`, so an unrelated
41
+ // token like `utf-8` is never in scope.
42
+ const IDENTIFIER_RE = /\b([A-Za-z][A-Za-z0-9]*)-(\d+)\b/g
43
+
44
+ const STAMP_FIELDS = ['linear_identifier', 'linear_issue_id']
45
+ const SNAPSHOT_SUFFIX = '.base.json'
46
+
47
+ // Every `.md` under each spec folder — overview and phase files alike, plus the
48
+ // legacy bare `<name>.md` shape.
49
+ function specMarkdownFiles(dir) {
50
+ const files = []
51
+ for (const bucket of BUCKETS) {
52
+ const root = path.join(dir, 'specs', bucket)
53
+ let entries
54
+ try {
55
+ entries = fs.readdirSync(root, { withFileTypes: true })
56
+ } catch {
57
+ continue
58
+ }
59
+ for (const entry of entries) {
60
+ const p = path.join(root, entry.name)
61
+ if (entry.isDirectory()) {
62
+ for (const f of fs.readdirSync(p)) if (f.endsWith('.md')) files.push(path.join(p, f))
63
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
64
+ files.push(p)
65
+ }
66
+ }
67
+ }
68
+ return files.sort()
69
+ }
70
+
71
+ const snapshotDirOf = (dir, config) => path.resolve(dir, config.sync.baseDir)
72
+
73
+ function snapshotFiles(dir, config) {
74
+ try {
75
+ return fs.readdirSync(snapshotDirOf(dir, config)).filter((f) => f.endsWith(SNAPSHOT_SUFFIX)).sort()
76
+ } catch {
77
+ return []
78
+ }
79
+ }
80
+
81
+ // Rewrite `<oldKey>-<n>` → `<newKey>-<n>` in `text`, leaving every other
82
+ // identifier alone. The number is preserved: a team rename does not renumber.
83
+ function movePrefix(text, oldKey, newKey) {
84
+ const want = String(oldKey).toLowerCase()
85
+ return String(text).replace(IDENTIFIER_RE, (whole, key, n) => {
86
+ if (key.toLowerCase() !== want) return whole
87
+ // Preserve the case it was written in: a URL path segment stays lowercase,
88
+ // a frontmatter stamp stays uppercase.
89
+ const next = key === key.toLowerCase() ? newKey.toLowerCase() : newKey
90
+ return `${next}-${n}`
91
+ })
92
+ }
93
+
94
+ /**
95
+ * The recorded key this repo believes it is stamped with.
96
+ *
97
+ * `config.linear.teamKey` is authoritative when set — but it defaults to `""`
98
+ * and is written by `init-config` and never read, which is exactly why a stale
99
+ * key never failed loudly. So fall back to the prefix actually observed in the
100
+ * stamps. Disagreeing stamps are reported, never guessed at and never thrown:
101
+ * the caller phrases its own refusal.
102
+ *
103
+ * @returns {{key: string|null, source: 'config'|'stamps'|null, keys?: string[], reason?: string}}
104
+ */
105
+ function deriveRecordedKey(dir, config) {
106
+ const configured = (config.linear && config.linear.teamKey) || ''
107
+ if (configured) return { key: configured, source: 'config' }
108
+
109
+ const keys = new Set()
110
+ for (const file of specMarkdownFiles(dir)) {
111
+ let raw
112
+ try {
113
+ raw = fs.readFileSync(file, 'utf-8')
114
+ } catch {
115
+ continue
116
+ }
117
+ const { data } = parseFrontmatter(raw)
118
+ for (const field of STAMP_FIELDS) {
119
+ if (!data[field]) continue
120
+ const m = /^([A-Z][A-Z0-9]*)-\d+$/.exec(String(data[field]).trim())
121
+ if (m) keys.add(m[1])
122
+ }
123
+ }
124
+ for (const name of snapshotFiles(dir, config)) {
125
+ const m = /^([A-Z][A-Z0-9]*)-\d+$/.exec(name.slice(0, -SNAPSHOT_SUFFIX.length))
126
+ if (m) keys.add(m[1])
127
+ }
128
+
129
+ // BLIND SPOT: the scan sees `.md` frontmatter under the four lifecycle buckets
130
+ // and the snapshot filenames — nothing else. A stamp anywhere else (a spec
131
+ // parked outside a bucket, a non-markdown file) is invisible, so `no stamped
132
+ // identifiers` means "none where we looked". That is why it refuses instead of
133
+ // concluding the repo is unstamped: the caller stops, and a human looks.
134
+ const found = [...keys].sort()
135
+ if (found.length === 1) return { key: found[0], source: 'stamps' }
136
+ if (!found.length) return { key: null, source: null, keys: [], reason: 'no stamped identifiers found under specs/' }
137
+ return {
138
+ key: null,
139
+ source: null,
140
+ keys: found,
141
+ reason: `stamps disagree — found ${found.join(', ')}; set linear.teamKey to the recorded one`,
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Plan the prefix move. Pure: reads the repo, writes nothing.
147
+ *
148
+ * @returns {{stamps: Array<{file, from, to}>, snapshots: Array<{file, from, to, keys}>, configKey: {from,to}|null}}
149
+ * `stamps[].from`/`to` are whole file contents; the caller writes them as-is.
150
+ */
151
+ function planRetarget({ dir, oldKey, newKey, config }) {
152
+ const stamps = []
153
+ for (const file of specMarkdownFiles(dir)) {
154
+ let raw
155
+ try {
156
+ raw = fs.readFileSync(file, 'utf-8')
157
+ } catch {
158
+ continue
159
+ }
160
+ // Scoped to the leading `---` block. Everything after it is prose and is
161
+ // returned byte-identical.
162
+ const m = /^(---\n[\s\S]*?\n---)([\s\S]*)$/.exec(raw)
163
+ if (!m) continue
164
+ const head = movePrefix(m[1], oldKey, newKey)
165
+ if (head === m[1]) continue
166
+ stamps.push({ file: path.relative(dir, file), from: raw, to: head + m[2] })
167
+ }
168
+
169
+ const snapshots = []
170
+ const prefix = `${oldKey}-`
171
+ for (const name of snapshotFiles(dir, config)) {
172
+ const ident = name.slice(0, -SNAPSHOT_SUFFIX.length)
173
+ let body
174
+ try {
175
+ body = JSON.parse(fs.readFileSync(path.join(snapshotDirOf(dir, config), name), 'utf-8'))
176
+ } catch {
177
+ continue
178
+ }
179
+ // The `subIssues` map is keyed BY IDENTIFIER, so a rename strands every key
180
+ // inside the file as well as the filename. The hashes are content-derived
181
+ // and stay valid — only the keys move.
182
+ const keys = {}
183
+ let rekeyed = false
184
+ for (const [k, hash] of Object.entries(body.subIssues || {})) {
185
+ const next = k.startsWith(prefix) ? `${newKey}-${k.slice(prefix.length)}` : k
186
+ if (next !== k) rekeyed = true
187
+ keys[next] = hash
188
+ }
189
+ const renamed = ident.startsWith(prefix) ? `${newKey}-${ident.slice(prefix.length)}${SNAPSHOT_SUFFIX}` : name
190
+ if (renamed === name && !rekeyed) continue
191
+ snapshots.push({ file: name, from: name, to: renamed, keys, body })
192
+ }
193
+
194
+ const recorded = (config.linear && config.linear.teamKey) || ''
195
+ const configKey = recorded && recorded !== newKey ? { from: recorded, to: newKey } : null
196
+
197
+ return { stamps, snapshots, configKey }
198
+ }
199
+
200
+ // True when a plan would change nothing.
201
+ const isEmptyRetarget = (plan) => !plan.stamps.length && !plan.snapshots.length && !plan.configKey
202
+
203
+ // `git status --porcelain` over `dir`: [] clean, the lines when dirty, null when
204
+ // this is not a git repo.
205
+ function dirtyPaths(dir) {
206
+ let out
207
+ try {
208
+ out = execFileSync('git', ['-C', dir, 'status', '--porcelain'], { stdio: ['ignore', 'pipe', 'ignore'] })
209
+ .toString()
210
+ .trim()
211
+ } catch {
212
+ return null
213
+ }
214
+ return out ? out.split('\n') : []
215
+ }
216
+
217
+ // Move a file, preferring `git mv` so history follows it. Falls back to a plain
218
+ // rename when the file is untracked (git mv refuses those) or git is absent.
219
+ function moveFile(dir, from, to) {
220
+ try {
221
+ execFileSync('git', ['-C', dir, 'mv', from, to], { stdio: ['ignore', 'ignore', 'ignore'] })
222
+ return 'git mv'
223
+ } catch {
224
+ fs.renameSync(path.join(dir, from), path.join(dir, to))
225
+ return 'rename'
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Apply a plan. Everything moves together — stamps, snapshot names, the keys
231
+ * inside them, and the config key — because a half-retargeted repo is harder to
232
+ * reason about than an un-retargeted one.
233
+ */
234
+ function applyRetarget(plan, { dir, config }) {
235
+ const changed = { files: [], snapshots: [], configKey: false }
236
+
237
+ for (const s of plan.stamps) {
238
+ fs.writeFileSync(path.join(dir, s.file), s.to, 'utf-8')
239
+ changed.files.push(s.file)
240
+ }
241
+
242
+ const baseRel = config.sync.baseDir
243
+ for (const snap of plan.snapshots) {
244
+ // Re-key BEFORE the rename, so the path being written is the one the plan
245
+ // recorded.
246
+ const src = path.join(dir, baseRel, snap.from)
247
+ fs.writeFileSync(src, JSON.stringify({ ...snap.body, subIssues: snap.keys }, null, 2) + '\n', 'utf-8')
248
+ if (snap.to !== snap.from) {
249
+ const how = moveFile(dir, path.join(baseRel, snap.from), path.join(baseRel, snap.to))
250
+ changed.snapshots.push({ ...snap, how })
251
+ }
252
+ }
253
+
254
+ if (plan.configKey) {
255
+ // Textual, not parse-and-restringify: the config is hand-edited and carries
256
+ // ordering (and possibly comments) a JSON round-trip would discard.
257
+ const file = path.join(dir, 'specs', '.core', 'linear.config.json')
258
+ const raw = fs.readFileSync(file, 'utf-8')
259
+ fs.writeFileSync(file, raw.replace(/("teamKey"\s*:\s*")([^"]*)(")/, `$1${plan.configKey.to}$3`), 'utf-8')
260
+ changed.configKey = true
261
+ }
262
+
263
+ return changed
264
+ }
265
+
266
+ module.exports = {
267
+ planRetarget,
268
+ applyRetarget,
269
+ deriveRecordedKey,
270
+ isEmptyRetarget,
271
+ dirtyPaths,
272
+ movePrefix,
273
+ specMarkdownFiles,
274
+ }
@@ -63,6 +63,11 @@ function stream(text) {
63
63
  * `at` is the index in the reduced stream where they first diverge, with ~40
64
64
  * characters of each side around it so the warning names the damage.
65
65
  */
66
+ // BLIND SPOT: a `stored` the caller never fetched is indistinguishable here
67
+ // from a description the tracker really did store empty — both reduce to `''`
68
+ // and read as total loss. The evidence that the read happened lives with the
69
+ // caller, so it must pass only a description it actually read back (`verifyLines`
70
+ // gates on `typeof stored.issue === 'string'` for exactly this reason).
66
71
  function compareStored(sent, stored) {
67
72
  const a = stream(sent)
68
73
  const b = stream(stored)