@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.
- package/assets/core/SETUP.md +13 -1
- package/assets/core/linear.config.md +80 -1
- package/assets/rules/negative-checks.md +69 -0
- package/assets/skills/spec-linear-setup/SKILL.md +35 -4
- package/assets/skills/spec-status/SKILL.md +18 -0
- package/assets/skills/spec-sync/SKILL.md +167 -0
- package/package.json +1 -1
- package/src/init.js +20 -5
- package/src/vendor/linear/api.js +62 -8
- package/src/vendor/linear/cli-sync.js +677 -2
- package/src/vendor/linear/credentials.js +299 -0
- package/src/vendor/linear/doctor.js +179 -0
- package/src/vendor/sync-core/index.js +6 -0
- package/src/vendor/sync-core/src/compare.js +4 -2
- package/src/vendor/sync-core/src/normalize.js +17 -2
- package/src/vendor/sync-core/src/retarget.js +274 -0
- package/src/vendor/sync-core/src/verify.js +5 -0
|
@@ -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)
|