@skitterbyte/skitterspec-linear 10.3.0 → 10.4.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,299 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The user-level credentials store — where a Linear API key lives when it is
5
+ * not in the environment.
6
+ *
7
+ * The repo's `specs/.core/linear.config.json` is COMMITTED and deliberately
8
+ * holds only the NAME of an env var, never a key. That is what makes it safe to
9
+ * share, so a key can never go there. This store is the alternative: one file
10
+ * per machine, outside every repo, at
11
+ * `$XDG_CONFIG_HOME/skitterspec/credentials.json` (else `~/.config/…`), keyed by
12
+ * Linear team id so one file serves every checkout.
13
+ *
14
+ * Reads never throw. `resolveApiKey` already treats "no key" as a NORMAL state
15
+ * meaning "fall back to MCP" rather than an error, so every failure here returns
16
+ * a structured reason the caller can report or ignore — a missing store is not a
17
+ * problem, it is the default.
18
+ *
19
+ * Nothing in this module ever returns a key inside an error, and callers must
20
+ * keep it out of logs, plans, snapshots and stamped frontmatter.
21
+ */
22
+
23
+ const { spawnSync } = require('node:child_process')
24
+ const fs = require('node:fs')
25
+ const os = require('node:os')
26
+ const path = require('node:path')
27
+
28
+ const DIR_NAME = 'skitterspec'
29
+ const FILE_NAME = 'credentials.json'
30
+
31
+ // Modes wider than owner-only. Checked ssh-style: a store other users can read
32
+ // is refused rather than read, because silently using it would hide the leak,
33
+ // and silently chmod'ing someone's file is not ours to do.
34
+ const GROUP_OR_WORLD = 0o077
35
+
36
+ /**
37
+ * Absolute path of the store. Honours `$XDG_CONFIG_HOME`, else `~/.config`.
38
+ * `env` and `homedir` are injected so tests never touch a real home directory.
39
+ */
40
+ function storePath(env = process.env, homedir = os.homedir) {
41
+ const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.trim()
42
+ const base = xdg || path.join(homedir(), '.config')
43
+ return path.join(base, DIR_NAME, FILE_NAME)
44
+ }
45
+
46
+ /**
47
+ * Read the store.
48
+ *
49
+ * @returns {object} `{ ok: true, store, path }` — parsed and owner-only;
50
+ * `{ ok: false, reason, code, path }` otherwise. `code` is one of:
51
+ * `absent` (no file — the normal default, not a problem), `permissions`
52
+ * (group/world readable), `unreadable`, `malformed`.
53
+ */
54
+ function readStore(file, { stat = fs.statSync, read = fs.readFileSync } = {}) {
55
+ let info
56
+ try {
57
+ info = stat(file)
58
+ } catch (err) {
59
+ if (err.code === 'ENOENT') {
60
+ return { ok: false, code: 'absent', path: file, reason: `no credentials store at ${file}` }
61
+ }
62
+ return { ok: false, code: 'unreadable', path: file, reason: `cannot read ${file}: ${err.message}` }
63
+ }
64
+
65
+ if (info.mode & GROUP_OR_WORLD) {
66
+ const mode = (info.mode & 0o777).toString(8)
67
+ return {
68
+ ok: false,
69
+ code: 'permissions',
70
+ path: file,
71
+ reason:
72
+ `credentials store ${file} is mode ${mode} — readable by other users.\n` +
73
+ ` Run: chmod 600 ${file}`,
74
+ }
75
+ }
76
+
77
+ let raw
78
+ try {
79
+ raw = read(file, 'utf-8')
80
+ } catch (err) {
81
+ return { ok: false, code: 'unreadable', path: file, reason: `cannot read ${file}: ${err.message}` }
82
+ }
83
+
84
+ let store
85
+ try {
86
+ store = JSON.parse(raw)
87
+ } catch (err) {
88
+ return { ok: false, code: 'malformed', path: file, reason: `invalid JSON in ${file}: ${err.message}` }
89
+ }
90
+ if (!store || typeof store !== 'object' || Array.isArray(store)) {
91
+ return { ok: false, code: 'malformed', path: file, reason: `invalid credentials store in ${file}` }
92
+ }
93
+ return { ok: true, store, path: file }
94
+ }
95
+
96
+ /**
97
+ * The key recorded for one team, or null. Never throws, and never reports the
98
+ * value it did or didn't find.
99
+ */
100
+ function keyForTeam(store, teamId) {
101
+ if (!teamId) return null
102
+ const teams = store && store.teams
103
+ const entry = teams && typeof teams === 'object' ? teams[teamId] : null
104
+ if (!entry || typeof entry !== 'object') return null
105
+ const key = entry.key
106
+ return typeof key === 'string' && key.trim() ? key.trim() : null
107
+ }
108
+
109
+ // Last 4 characters, for reporting that a key exists without revealing it.
110
+ function fingerprint(key) {
111
+ if (typeof key !== 'string' || !key) return null
112
+ return `…${key.slice(-4)}`
113
+ }
114
+
115
+
116
+ /**
117
+ * Record a key for one team, creating the store at `0600` and its directory at
118
+ * `0700`. Other teams' entries are preserved.
119
+ *
120
+ * Refuses rather than writing when the existing store is unreadable or
121
+ * over-permissive — the same guard as reading, because silently rewriting a
122
+ * world-readable file would leave the leak in place.
123
+ *
124
+ * Returns `{ ok: true, path, created }` or `{ ok: false, reason }`. The key is
125
+ * never echoed back in either.
126
+ */
127
+ function writeKey(file, teamId, key, deps = {}) {
128
+ const mkdir = deps.mkdir || fs.mkdirSync
129
+ const write = deps.write || fs.writeFileSync
130
+ const chmod = deps.chmod || fs.chmodSync
131
+ const exists = deps.exists || fs.existsSync
132
+
133
+ if (!teamId) return { ok: false, reason: 'no team id — nothing to key the entry by' }
134
+ if (typeof key !== 'string' || !key.trim()) return { ok: false, reason: 'empty key — nothing stored' }
135
+
136
+ const created = !exists(file)
137
+ let store = { version: 1, teams: {} }
138
+ if (!created) {
139
+ const current = readStore(file, deps)
140
+ if (!current.ok) return { ok: false, reason: current.reason, code: current.code }
141
+ store = current.store
142
+ if (!store.teams || typeof store.teams !== 'object') store.teams = {}
143
+ if (!store.version) store.version = 1
144
+ }
145
+
146
+ store.teams[teamId] = { ...(store.teams[teamId] || {}), key: key.trim() }
147
+
148
+ mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
149
+ write(file, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 })
150
+ // `mode` on writeFileSync only applies when the file is CREATED, so an
151
+ // existing file keeps its mode — narrow it explicitly.
152
+ chmod(file, 0o600)
153
+ return { ok: true, path: file, created }
154
+ }
155
+
156
+ /**
157
+ * Remove one team's entry, leaving every other team intact. A store or entry
158
+ * that isn't there is a clean no-op, not an error.
159
+ */
160
+ function removeKey(file, teamId, deps = {}) {
161
+ const write = deps.write || fs.writeFileSync
162
+ const current = readStore(file, deps)
163
+ if (!current.ok) {
164
+ if (current.code === 'absent') return { ok: true, path: file, removed: false }
165
+ return { ok: false, reason: current.reason, code: current.code }
166
+ }
167
+ const teams = current.store.teams
168
+ if (!teams || !teams[teamId]) return { ok: true, path: file, removed: false }
169
+ delete teams[teamId]
170
+ write(file, JSON.stringify(current.store, null, 2) + '\n', { mode: 0o600 })
171
+ return { ok: true, path: file, removed: true }
172
+ }
173
+
174
+ /**
175
+ * Resolve one team's key from the store: a stored `key` first, else running its
176
+ * `keyCommand` and taking stdout.
177
+ *
178
+ * `keyCommand` is honoured ONLY from this user-level store — never from the
179
+ * repo's committed `linear.config.json`. That file travels with the repo, so a
180
+ * command named there would let a cloned repo run arbitrary code on the machine
181
+ * of anyone who ran `spec-sync`. The repo config keeps naming only an env var.
182
+ *
183
+ * A command that fails, times out or prints nothing resolves to no key, which is
184
+ * the ordinary "fall back to MCP" state — but its stderr is carried back so a
185
+ * broken command is diagnosable rather than mysteriously inert. Its **stdout is
186
+ * never** put in an error: that is the key.
187
+ *
188
+ * @returns {object} `{ key, source }` with source `'store'` | `'command'`, or
189
+ * `{ key: null, source: null, reason }`.
190
+ */
191
+ function resolveTeamKey(store, teamId, deps = {}) {
192
+ const direct = keyForTeam(store, teamId)
193
+ if (direct) return { key: direct, source: 'store' }
194
+
195
+ const entry = store && store.teams && typeof store.teams === 'object' ? store.teams[teamId] : null
196
+ const command = entry && typeof entry.keyCommand === 'string' ? entry.keyCommand.trim() : ''
197
+ if (!command) return { key: null, source: null }
198
+
199
+ const run = deps.run || defaultRunCommand
200
+ const result = run(command, deps.timeoutMs || COMMAND_TIMEOUT_MS)
201
+ if (!result.ok) {
202
+ return { key: null, source: null, command, reason: `keyCommand failed: ${result.error}` }
203
+ }
204
+ const key = (result.stdout || '').trim()
205
+ if (!key) {
206
+ return { key: null, source: null, command, reason: 'keyCommand produced no output' }
207
+ }
208
+ return { key, source: 'command', command }
209
+ }
210
+
211
+ // 60s: a password manager may prompt for biometric or a master password, and a
212
+ // hang here would look like the CLI itself wedging.
213
+ const COMMAND_TIMEOUT_MS = 60_000
214
+
215
+ // Linear personal API keys are `lin_api_` + a long token.
216
+ const LITERAL_KEY_RE = /lin_api_[A-Za-z0-9]{8,}/
217
+
218
+ function defaultRunCommand(command, timeout) {
219
+ const r = spawnSync('sh', ['-c', command], { encoding: 'utf-8', timeout })
220
+ if (r.error) return { ok: false, error: r.error.message }
221
+ if (r.status !== 0) {
222
+ // stderr only — stdout is the secret, and must not reach an error message.
223
+ const detail = (r.stderr || '').trim().split('\n')[0] || `exit ${r.status}`
224
+ return { ok: false, error: detail }
225
+ }
226
+ return { ok: true, stdout: r.stdout }
227
+ }
228
+
229
+ /**
230
+ * Record a command for one team instead of a key. Unlike a key, a command is not
231
+ * a secret, so this one IS safe to pass as an argument.
232
+ */
233
+ function writeKeyCommand(file, teamId, command, deps = {}) {
234
+ const mkdir = deps.mkdir || fs.mkdirSync
235
+ const write = deps.write || fs.writeFileSync
236
+ const chmod = deps.chmod || fs.chmodSync
237
+ const exists = deps.exists || fs.existsSync
238
+
239
+ if (!teamId) return { ok: false, reason: 'no team id — nothing to key the entry by' }
240
+ if (typeof command !== 'string' || !command.trim()) {
241
+ return { ok: false, reason: 'empty command — nothing stored' }
242
+ }
243
+ // Refusing `--key` makes `--command "echo lin_api_…"` the obvious workaround,
244
+ // and it is worse: a command is NOT treated as a secret — `status` prints it
245
+ // back, and it sits in the store in clear. Catch the shortcut at the door.
246
+ if (LITERAL_KEY_RE.test(command)) {
247
+ return {
248
+ ok: false,
249
+ reason:
250
+ 'that command has a Linear key written into it.\n' +
251
+ ' A command is displayed by `credentials status` and is not treated as a\n' +
252
+ ' secret — embedding a key there exposes it. To store a key, run\n' +
253
+ ' `credentials set` with no arguments and paste at the hidden prompt.',
254
+ }
255
+ }
256
+
257
+ const created = !exists(file)
258
+ let store = { version: 1, teams: {} }
259
+ if (!created) {
260
+ const current = readStore(file, deps)
261
+ if (!current.ok) return { ok: false, reason: current.reason, code: current.code }
262
+ store = current.store
263
+ if (!store.teams || typeof store.teams !== 'object') store.teams = {}
264
+ if (!store.version) store.version = 1
265
+ }
266
+
267
+ // A command REPLACES a stored key for that team — keeping both would mean the
268
+ // key silently wins and the command never runs.
269
+ store.teams[teamId] = { keyCommand: command.trim() }
270
+
271
+ mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
272
+ write(file, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 })
273
+ chmod(file, 0o600)
274
+ return { ok: true, path: file, created }
275
+ }
276
+
277
+ /** The store's permission bits as an octal string, or null when absent. */
278
+ function storeMode(file, deps = {}) {
279
+ const stat = deps.stat || fs.statSync
280
+ try {
281
+ return (stat(file).mode & 0o777).toString(8)
282
+ } catch {
283
+ return null
284
+ }
285
+ }
286
+
287
+ module.exports = {
288
+ storePath,
289
+ readStore,
290
+ keyForTeam,
291
+ fingerprint,
292
+ writeKey,
293
+ writeKeyCommand,
294
+ resolveTeamKey,
295
+ removeKey,
296
+ storeMode,
297
+ DIR_NAME,
298
+ FILE_NAME,
299
+ }
@@ -0,0 +1,341 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Identifier drift — the offline half of `spec-sync doctor`.
5
+ *
6
+ * When a Linear team is renamed (`REU` → `ERQ`), nothing in the repo moves: every
7
+ * stamped `linear_identifier` / `linear_issue_id` / `linear_url`, the config's
8
+ * `teamKey`, and every `linear-base/<ID>.base.json` filename keeps the old
9
+ * prefix. Nothing detected it and nothing repaired it; the first occurrence was
10
+ * fixed by hand across 221 refs in 54 files.
11
+ *
12
+ * This module only SCANS — it reads the repo and reports what disagrees with a
13
+ * team key it is handed. It performs no network calls and writes nothing, so the
14
+ * detection logic is testable without an adapter. Deciding what the current key
15
+ * IS (a Linear read) and repairing (phase 3) live in `cli-sync.js`.
16
+ */
17
+
18
+ const fs = require('node:fs')
19
+ const path = require('node:path')
20
+ const { execFileSync } = require('node:child_process')
21
+
22
+ const { BUCKETS } = require('../../env/resolve.js')
23
+ const { parseFrontmatter } = require('../sync-core')
24
+
25
+ // A Linear issue identifier: an uppercase team key, a dash, a number. The key is
26
+ // captured so drift is a prefix comparison rather than a guess.
27
+ const IDENTIFIER_RE = /^([A-Z][A-Z0-9]*)-(\d+)$/
28
+ // The same, embedded in a URL path (`…/issue/REU-151/slug`).
29
+ const URL_IDENTIFIER_RE = /\b([A-Z][A-Z0-9]*)-(\d+)\b/g
30
+
31
+ const STAMP_FIELDS = ['linear_identifier', 'linear_issue_id']
32
+
33
+ // Every `.md` under each spec folder — overview and phase files alike, plus the
34
+ // legacy bare `<name>.md` shape. Anything stamped lives in one of these.
35
+ function specMarkdownFiles(dir) {
36
+ const files = []
37
+ for (const bucket of BUCKETS) {
38
+ const root = path.join(dir, 'specs', bucket)
39
+ let entries
40
+ try {
41
+ entries = fs.readdirSync(root, { withFileTypes: true })
42
+ } catch {
43
+ continue
44
+ }
45
+ for (const entry of entries) {
46
+ const p = path.join(root, entry.name)
47
+ if (entry.isDirectory()) {
48
+ for (const f of fs.readdirSync(p)) if (f.endsWith('.md')) files.push(path.join(p, f))
49
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
50
+ files.push(p)
51
+ }
52
+ }
53
+ }
54
+ return files.sort()
55
+ }
56
+
57
+ // Retarget one identifier onto `currentKey`, preserving the number. Returns null
58
+ // when it is already current or is not an identifier at all.
59
+ function retarget(value, currentKey) {
60
+ const m = IDENTIFIER_RE.exec(String(value).trim())
61
+ if (!m || m[1] === currentKey) return null
62
+ return `${currentKey}-${m[2]}`
63
+ }
64
+
65
+ /**
66
+ * Scan the repo for identifiers that disagree with `currentKey`.
67
+ *
68
+ * @returns {{
69
+ * stamps: Array<{file, field, from, to}>,
70
+ * urls: Array<{file, from, to}>,
71
+ * snapshots: Array<{from, to}>,
72
+ * snapshotKeys: Array<{file, from, to}>,
73
+ * mentions: Array<{file, from, to}>, // prose refs — reported, never repaired
74
+ * config: {from, to}|null,
75
+ * refs: Array<{from, to}>,
76
+ * }} `refs` is the DISTINCT set of drifted identifiers — what the caller checks
77
+ * against Linear, so 221 stamps of 198 identifiers cost 198 reads, not 221.
78
+ */
79
+ function scanDrift(dir, config, currentKey) {
80
+ const stamps = []
81
+ const urls = []
82
+ const seen = new Map()
83
+ const note = (from, to) => seen.set(from, to)
84
+
85
+ for (const file of specMarkdownFiles(dir)) {
86
+ let raw
87
+ try {
88
+ raw = fs.readFileSync(file, 'utf-8')
89
+ } catch {
90
+ continue
91
+ }
92
+ const { data } = parseFrontmatter(raw)
93
+ for (const field of STAMP_FIELDS) {
94
+ if (!data[field]) continue
95
+ const to = retarget(data[field], currentKey)
96
+ if (!to) continue
97
+ stamps.push({ file: path.relative(dir, file), field, from: String(data[field]).trim(), to })
98
+ note(String(data[field]).trim(), to)
99
+ }
100
+ if (data.linear_url) {
101
+ const url = String(data.linear_url)
102
+ let changed = url
103
+ for (const m of url.matchAll(URL_IDENTIFIER_RE)) {
104
+ const to = retarget(m[0], currentKey)
105
+ if (!to) continue
106
+ changed = changed.split(m[0]).join(to)
107
+ note(m[0], to)
108
+ }
109
+ if (changed !== url) urls.push({ file: path.relative(dir, file), from: url, to: changed })
110
+ }
111
+ }
112
+
113
+ const snapshots = []
114
+ const snapshotKeys = []
115
+ const baseDir = path.resolve(dir, config.sync.baseDir)
116
+ let snapshotNames = []
117
+ try {
118
+ snapshotNames = fs.readdirSync(baseDir).filter((f) => f.endsWith('.base.json'))
119
+ } catch {
120
+ /* no snapshots yet — nothing to retarget */
121
+ }
122
+ for (const name of snapshotNames.sort()) {
123
+ const to = retarget(name.slice(0, -'.base.json'.length), currentKey)
124
+ if (to) {
125
+ snapshots.push({ from: name, to: `${to}.base.json` })
126
+ note(name.slice(0, -'.base.json'.length), to)
127
+ }
128
+ // A snapshot's `subIssues` map is KEYED BY IDENTIFIER, so a rename strands
129
+ // every key inside the file as well as the filename. Missing these made the
130
+ // first scan report 59 refs where the repo really carries ~198: the bulk of
131
+ // a linked repo's identifiers live in here, not in frontmatter. The hashes
132
+ // are content-derived and stay valid — only their keys move.
133
+ let body
134
+ try {
135
+ body = JSON.parse(fs.readFileSync(path.join(baseDir, name), 'utf-8'))
136
+ } catch {
137
+ continue
138
+ }
139
+ for (const ident of Object.keys((body && body.subIssues) || {})) {
140
+ const keyTo = retarget(ident, currentKey)
141
+ if (!keyTo) continue
142
+ snapshotKeys.push({ file: path.join(config.sync.baseDir, name), from: ident, to: keyTo })
143
+ note(ident, keyTo)
144
+ }
145
+ }
146
+
147
+ // Prose MENTIONS of a stale identifier — `(REU-61)` beside a task, "the REU-196
148
+ // spec claimed …". These are human-written references, not functional stamps,
149
+ // and repair deliberately leaves them alone: rewriting narrative text is a
150
+ // different risk class, and an identifier-shaped token in prose need not be a
151
+ // Linear ref at all. They are counted anyway so the report cannot imply a
152
+ // `--write` left the repo fully retargeted when ~145 mentions still say REU.
153
+ //
154
+ // Only prefixes that actually appear in the repo's STAMPS are counted, so an
155
+ // unrelated `ABC-123` in prose is never mistaken for a drifted ref.
156
+ const staleKeys = new Set([...seen.keys()].map((k) => k.split('-')[0]))
157
+ const mentions = []
158
+ if (staleKeys.size) {
159
+ for (const file of specMarkdownFiles(dir)) {
160
+ let raw
161
+ try {
162
+ raw = fs.readFileSync(file, 'utf-8')
163
+ } catch {
164
+ continue
165
+ }
166
+ const { body } = parseFrontmatter(raw)
167
+ for (const m of String(body).matchAll(URL_IDENTIFIER_RE)) {
168
+ if (!staleKeys.has(m[1]) || m[1] === currentKey) continue
169
+ mentions.push({ file: path.relative(dir, file), from: m[0], to: `${currentKey}-${m[2]}` })
170
+ }
171
+ }
172
+ }
173
+
174
+ const configured = (config.linear && config.linear.teamKey) || ''
175
+ const configDrift = configured && configured !== currentKey ? { from: configured, to: currentKey } : null
176
+
177
+ return {
178
+ stamps,
179
+ urls,
180
+ snapshots,
181
+ snapshotKeys,
182
+ mentions,
183
+ config: configDrift,
184
+ refs: [...seen.entries()].map(([from, to]) => ({ from, to })).sort((a, b) => a.from.localeCompare(b.from)),
185
+ }
186
+ }
187
+
188
+ // True when a scan found nothing to repair.
189
+ function isClean(drift) {
190
+ return (
191
+ !drift.stamps.length &&
192
+ !drift.urls.length &&
193
+ !drift.snapshots.length &&
194
+ !drift.snapshotKeys.length &&
195
+ !drift.config
196
+ )
197
+ }
198
+
199
+ // How many distinct files a repair would touch.
200
+ function fileCount(drift) {
201
+ return new Set([...drift.stamps.map((s) => s.file), ...drift.urls.map((u) => u.file)]).size
202
+ }
203
+
204
+ // --- repair ------------------------------------------------------------------
205
+
206
+ // `git status --porcelain` over `dir`: [] when clean, the offending lines when
207
+ // dirty, null when this is not a git repo at all.
208
+ function dirtyPaths(dir) {
209
+ let out
210
+ try {
211
+ out = execFileSync('git', ['-C', dir, 'status', '--porcelain'], {
212
+ stdio: ['ignore', 'pipe', 'ignore'],
213
+ })
214
+ .toString()
215
+ .trim()
216
+ } catch {
217
+ return null
218
+ }
219
+ return out ? out.split('\n') : []
220
+ }
221
+
222
+ // Rewrite identifier tokens INSIDE a file's frontmatter block only.
223
+ //
224
+ // Scoped to the frontmatter on purpose: the same token appears in spec prose,
225
+ // which repair deliberately leaves alone. A blind whole-file replace would
226
+ // rewrite narrative text as a side effect of fixing a stamp.
227
+ function rewriteFrontmatter(raw, replacements) {
228
+ const m = /^(---\n[\s\S]*?\n---)(\n[\s\S]*)?$/.exec(raw)
229
+ if (!m) return raw
230
+ let head = m[1]
231
+ for (const [from, to] of replacements) head = head.split(from).join(to)
232
+ return head + (m[2] || '')
233
+ }
234
+
235
+ // Move a file, preferring `git mv` so history survives. Falls back to a plain
236
+ // rename when the file is untracked (git mv refuses those) or git is absent.
237
+ function moveFile(dir, from, to) {
238
+ try {
239
+ execFileSync('git', ['-C', dir, 'mv', from, to], { stdio: ['ignore', 'ignore', 'ignore'] })
240
+ return 'git mv'
241
+ } catch {
242
+ fs.renameSync(path.join(dir, from), path.join(dir, to))
243
+ return 'rename'
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Apply a scan's repairs. Everything moves together — config, stamps, snapshot
249
+ * filenames and the identifier keys inside them — because a half-repaired repo
250
+ * is harder to reason about than an un-repaired one.
251
+ *
252
+ * `skip` is the set of `from` identifiers that resolve to NO issue under the new
253
+ * key. Those are left exactly as they are: repair fixes what is provably
254
+ * repairable, and reports the rest rather than inventing a target.
255
+ *
256
+ * Prose mentions are never touched — see `scanDrift`.
257
+ */
258
+ function repairDrift(dir, config, drift, { skip = new Set() } = {}) {
259
+ const keep = (r) => !skip.has(r.from)
260
+ const changed = { files: [], snapshots: [], config: false, skipped: 0 }
261
+
262
+ // 1. Frontmatter stamps and urls, one pass per file.
263
+ const byFile = new Map()
264
+ for (const r of [...drift.stamps, ...drift.urls]) {
265
+ if (!keep(r)) {
266
+ changed.skipped++
267
+ continue
268
+ }
269
+ if (!byFile.has(r.file)) byFile.set(r.file, [])
270
+ byFile.get(r.file).push([r.from, r.to])
271
+ }
272
+ for (const [rel, replacements] of byFile) {
273
+ const abs = path.join(dir, rel)
274
+ const raw = fs.readFileSync(abs, 'utf-8')
275
+ const next = rewriteFrontmatter(raw, replacements)
276
+ if (next !== raw) {
277
+ fs.writeFileSync(abs, next, 'utf-8')
278
+ changed.files.push(rel)
279
+ }
280
+ }
281
+
282
+ // 2. Snapshot sub-issue keys, rewritten BEFORE the filename moves so the path
283
+ // being read is still the one the scan recorded.
284
+ const keysByFile = new Map()
285
+ for (const k of drift.snapshotKeys) {
286
+ if (!keep(k)) {
287
+ changed.skipped++
288
+ continue
289
+ }
290
+ if (!keysByFile.has(k.file)) keysByFile.set(k.file, [])
291
+ keysByFile.get(k.file).push(k)
292
+ }
293
+ for (const [rel, keys] of keysByFile) {
294
+ const abs = path.join(dir, rel)
295
+ const body = JSON.parse(fs.readFileSync(abs, 'utf-8'))
296
+ const subIssues = {}
297
+ // The hashes are CONTENT-derived, so they survive a rename untouched — only
298
+ // the keys move. Rebuilt rather than mutated so key order stays stable.
299
+ for (const [ident, hash] of Object.entries(body.subIssues || {})) {
300
+ const hit = keys.find((k) => k.from === ident)
301
+ subIssues[hit ? hit.to : ident] = hash
302
+ }
303
+ fs.writeFileSync(abs, JSON.stringify({ ...body, subIssues }, null, 2) + '\n', 'utf-8')
304
+ }
305
+
306
+ // 3. Snapshot filenames.
307
+ const baseRel = config.sync.baseDir
308
+ for (const snap of drift.snapshots) {
309
+ const ident = snap.from.slice(0, -'.base.json'.length)
310
+ if (skip.has(ident)) {
311
+ changed.skipped++
312
+ continue
313
+ }
314
+ const how = moveFile(dir, path.join(baseRel, snap.from), path.join(baseRel, snap.to))
315
+ changed.snapshots.push({ ...snap, how })
316
+ }
317
+
318
+ // 4. The config key, last: it is the thing that makes the next scan read
319
+ // clean, so it should not flip before the files it describes have moved.
320
+ if (drift.config) {
321
+ const file = path.join(dir, 'specs', '.core', 'linear.config.json')
322
+ const raw = fs.readFileSync(file, 'utf-8')
323
+ // Textual, not parse-and-restringify: the config is hand-edited and carries
324
+ // comments and ordering a JSON round-trip would silently discard.
325
+ fs.writeFileSync(file, raw.replace(/("teamKey"\s*:\s*")([^"]*)(")/, `$1${drift.config.to}$3`), 'utf-8')
326
+ changed.config = true
327
+ }
328
+
329
+ return changed
330
+ }
331
+
332
+ module.exports = {
333
+ scanDrift,
334
+ isClean,
335
+ fileCount,
336
+ retarget,
337
+ specMarkdownFiles,
338
+ dirtyPaths,
339
+ repairDrift,
340
+ rewriteFrontmatter,
341
+ }
@@ -71,7 +71,9 @@ function snapshotOf(projection) {
71
71
  /**
72
72
  * Diff the local projection against the last-pushed snapshot.
73
73
  * @returns {{ issue?: object, subIssues: {create,update} }}
74
- * create items carry a `ref` (local handle) and no id; update items carry `id`.
74
+ * create items carry a `ref` (local handle) and no id; update items carry both
75
+ * — the `ref` because the read-back check matches sub-issues to phases BY ref,
76
+ * and an update with only an id makes every one of them look unmatched.
75
77
  * `plan.issue` (when present) is the spec issue's description + state; the push
76
78
  * skill applies `config.linear.projectId` grouping on top of it.
77
79
  */
@@ -85,7 +87,7 @@ function planChanges(projection, snapshot) {
85
87
  if (s.id == null) {
86
88
  subIssues.create.push({ ref: s.ref, name: s.name, goal: s.goal, state: s.state })
87
89
  } else if (snapS[String(s.id)] !== subIssueHash(s)) {
88
- subIssues.update.push({ id: s.id, name: s.name, goal: s.goal, state: s.state })
90
+ subIssues.update.push({ ref: s.ref, id: s.id, name: s.name, goal: s.goal, state: s.state })
89
91
  }
90
92
  }
91
93