@skitterbyte/skitterspec-linear 10.4.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.
@@ -1,341 +1,179 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * Identifier driftthe offline half of `spec-sync doctor`.
4
+ * `spec-sync doctor`one readiness report across every layer of a setup.
5
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.
6
+ * Setting skitterspec up spans four layers the `specs/` scaffold and skills,
7
+ * per-spec isolation, the tracker config, and the API key — and each was checked
8
+ * by a different command, or by none. `init` reports the scaffold and isolation;
9
+ * `credentials status` reports the key; the tracker config had no readiness check
10
+ * at all, only commands that write it. So "is this project set up?" had no single
11
+ * answer, and a skill needing to know its own prerequisites had nothing to call.
11
12
  *
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`.
13
+ * This module is the PURE half. It takes the project's state as an argument
14
+ * gathered by the caller — and returns rows. No `fs`, no network, no output, so
15
+ * every branch is exercised from a literal rather than a scaffolded temp project.
16
+ *
17
+ * Two distinctions carry the design:
18
+ *
19
+ * 1. **`missing` is not `broken`.** `missing` is an opt-in not taken, which is
20
+ * fine; `broken` is configured-but-wrong, which is not. `ok` is false only for
21
+ * `broken`, so declining isolation or a tracker never reads as a failure. The
22
+ * existing commands blur exactly this.
23
+ * 2. **Every non-`ok` row names the command that fixes it**, so the output is
24
+ * actionable without reading docs — the shape `credentials status` already
25
+ * uses.
26
+ *
27
+ * It never prints a secret: the key row carries a masked fingerprint and its
28
+ * source, never the value. This is the command a skill runs, so that has to hold
29
+ * by construction rather than by convention.
16
30
  */
17
31
 
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
32
+ const STATES = ['ok', 'missing', 'broken', 'skipped']
30
33
 
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]}`
34
+ // A row is a check. `fix` is the exact command to run, or null when there is
35
+ // nothing to fix.
36
+ const row = (id, label, state, detail, fix = null) => {
37
+ if (!STATES.includes(state)) throw new Error(`doctor: unknown check state "${state}" for ${id}`)
38
+ return { id, label, state, detail, fix }
63
39
  }
64
40
 
65
41
  /**
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.
42
+ * @param {object} state gathered by the caller:
43
+ * {
44
+ * scaffold: { specsDir: bool, buckets: string[], skills: number },
45
+ * isolation: { present: bool, parsed: bool, error?: string },
46
+ * tracker: { present: bool, parsed: bool, teamId?: string, teamKey?: string, error?: string },
47
+ * key: { ok: bool, source?: string, fingerprint?: string, error?: string },
48
+ * remote: { checked: bool, ok?: bool, teamKey?: string, error?: string },
49
+ * }
50
+ * @returns {{ok: boolean, checks: Array}}
78
51
  */
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
- }
52
+ function runChecks(state = {}) {
53
+ const checks = [
54
+ scaffoldCheck(state.scaffold),
55
+ isolationCheck(state.isolation),
56
+ trackerCheck(state.tracker),
57
+ keyCheck(state.key, state.tracker),
58
+ remoteCheck(state.remote),
59
+ ]
60
+ // `missing` is a declined opt-in, so it must not fail the run. Only a
61
+ // configured-but-wrong layer does.
62
+ return { ok: !checks.some((c) => c.state === 'broken'), checks }
63
+ }
112
64
 
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 */
65
+ function scaffoldCheck(s = {}) {
66
+ if (!s.specsDir) {
67
+ return row('scaffold', 'scaffold', 'missing', 'no specs/ folder', 'skitterspec init')
121
68
  }
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.
69
+ // A LIFECYCLE BUCKET IS NOT CHECKED, deliberately. git does not track empty
70
+ // directories, so `specs/in-progress/` disappears whenever no spec is in
71
+ // progress and returns the moment one starts — every lifecycle skill runs
72
+ // `mkdir -p` before it moves a spec. Checking for it reported a healthy repo
73
+ // as broken, and exited 1 under any skill branching on the code.
153
74
  //
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
- }
75
+ // `.core` is the signal that survives: `init` always writes the config
76
+ // templates and the manifest into it, so it is never an empty directory.
77
+ if (!s.core) {
78
+ return row(
79
+ 'scaffold',
80
+ 'scaffold',
81
+ 'broken',
82
+ 'specs/ exists but specs/.core/ is missing — a half-installed scaffold',
83
+ 'skitterspec init --resync',
84
+ )
172
85
  }
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)),
86
+ if (!s.skills) {
87
+ return row('scaffold', 'scaffold', 'broken', 'specs/ exists but no skills are installed', 'skitterspec init --resync')
185
88
  }
89
+ return row('scaffold', 'scaffold', 'ok', `specs/ + ${s.skills} skills installed`)
186
90
  }
187
91
 
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
92
+ // Isolation and tracker have NO false-positive mode, and no test is added for
93
+ // one: each only says `broken` on positive evidence — a file that is present and
94
+ // does not parse, or a config that parses and holds no teamId. Absence is
95
+ // reported as `missing`, an opt-in not taken, which never fails the run.
96
+ function isolationCheck(s = {}) {
97
+ if (!s.present) {
98
+ return row('isolation', 'isolation', 'missing', 'not enabled — every spec builds in place', 'skitterspec init --isolation')
218
99
  }
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] || '')
100
+ if (!s.parsed) {
101
+ return row('isolation', 'isolation', 'broken', s.error || 'env.config.json does not parse', 'fix specs/.core/env.config.json')
102
+ }
103
+ return row('isolation', 'isolation', 'ok', 'env.config.json worktree per spec')
233
104
  }
234
105
 
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'
106
+ function trackerCheck(s = {}) {
107
+ if (!s.present) {
108
+ return row('tracker', 'tracker', 'missing', 'no linear.config.json — sync is opt-in', '/spec-linear-setup')
109
+ }
110
+ if (!s.parsed) {
111
+ return row('tracker', 'tracker', 'broken', s.error || 'linear.config.json does not parse', '/spec-linear-setup')
112
+ }
113
+ if (!s.teamId) {
114
+ // Configured but unusable: every Linear call needs the team id.
115
+ return row('tracker', 'tracker', 'broken', 'linear.config.json has no linear.teamId', '/spec-linear-setup')
244
116
  }
117
+ const team = s.teamKey ? `${s.teamId} (${s.teamKey})` : s.teamId
118
+ return row('tracker', 'tracker', 'ok', `linear.config.json — team ${team}`)
245
119
  }
246
120
 
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
- }
121
+ // BLIND SPOT: `s.ok` collapses three sources — the env var, the store, and a
122
+ // `keyCommand` the store runs. An absent env var is not an absent key, and the
123
+ // caller resolves all three before this sees it. `s.error` carries WHY when one
124
+ // of them failed; passing it through is what keeps a broken keyCommand from
125
+ // being reported as a key the user never set.
126
+ function keyCheck(s = {}, tracker = {}) {
127
+ // Without a tracker there is nothing for a key to authenticate, so asking for
128
+ // one would be noise.
129
+ if (!tracker.present) return row('key', 'key', 'skipped', 'no tracker configured')
130
+ if (!s.ok) {
131
+ return row(
132
+ 'key',
133
+ 'key',
134
+ 'missing',
135
+ s.error || `no key for ${tracker.teamKey || tracker.teamId || 'this team'}`,
136
+ 'skitterspec spec-sync credentials set',
137
+ )
280
138
  }
139
+ // Masked fingerprint and source only — never the value.
140
+ return row('key', 'key', 'ok', `${s.fingerprint || 'set'} from ${s.source || 'unknown'}`)
141
+ }
281
142
 
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)
143
+ function remoteCheck(s = {}) {
144
+ if (!s.checked) {
145
+ return row('remote', 'remote', 'skipped', 'pass --check-remote to verify against Linear')
292
146
  }
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')
147
+ // Asked for, but the check did not run — either there was nothing to ask WITH
148
+ // (no key, no team id; the row that owns that already reported it), or the
149
+ // request never got an answer (unreachable, rate-limited). Neither says this
150
+ // project is misconfigured, and `broken` here exits 1 for every skill
151
+ // branching on the code. The caller decides which failures land here.
152
+ if (s.skipped) {
153
+ return row('remote', 'remote', 'skipped', s.reason || 'nothing to check against')
304
154
  }
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 })
155
+ if (!s.ok) {
156
+ // Well-formed config is not working config: the id may not resolve, or the
157
+ // key may be revoked. Either way this is configured-but-wrong.
158
+ //
159
+ // `reason` is composed by the caller from a CLASSIFIED failure, never from a
160
+ // raw API message — an error body can echo the request back, and this is the
161
+ // command a skill prints.
162
+ return row('remote', 'remote', 'broken', s.reason || 'Linear did not accept the request', s.fix || 'skitterspec spec-sync credentials set')
316
163
  }
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
164
+ if (s.teamKey && s.recordedKey && s.teamKey !== s.recordedKey) {
165
+ // The team resolved and the key worked — but it is not the team this repo
166
+ // thinks it files into. That is a rename, and every stamped identifier in
167
+ // the repo is now stale.
168
+ return row(
169
+ 'remote',
170
+ 'remote',
171
+ 'broken',
172
+ `team resolves as ${s.teamKey}, but the config records ${s.recordedKey} — the team was renamed`,
173
+ 'skitterspec spec-sync retarget',
174
+ )
327
175
  }
328
-
329
- return changed
176
+ return row('remote', 'remote', 'ok', `team ${s.teamKey} resolves, key accepted`)
330
177
  }
331
178
 
332
- module.exports = {
333
- scanDrift,
334
- isClean,
335
- fileCount,
336
- retarget,
337
- specMarkdownFiles,
338
- dirtyPaths,
339
- repairDrift,
340
- rewriteFrontmatter,
341
- }
179
+ module.exports = { runChecks, STATES }
@@ -22,6 +22,7 @@ const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
22
22
  const { detectLegacyMirror } = require('./src/legacy.js')
23
23
  const { compareStored } = require('./src/verify.js')
24
24
  const { flattenNestedTables } = require('./src/tables.js')
25
+ const { planRetarget, applyRetarget, deriveRecordedKey, isEmptyRetarget, dirtyPaths } = require('./src/retarget.js')
25
26
 
26
27
  module.exports = {
27
28
  normalizeLocal,
@@ -51,4 +52,9 @@ module.exports = {
51
52
  detectLegacyMirror,
52
53
  compareStored,
53
54
  flattenNestedTables,
55
+ planRetarget,
56
+ applyRetarget,
57
+ deriveRecordedKey,
58
+ isEmptyRetarget,
59
+ dirtyPaths,
54
60
  }
@@ -237,7 +237,12 @@ function parsePhaseIndex(phasesSection) {
237
237
  if (!/^\d+$/.test(n)) continue // skip header + separator rows
238
238
  const name = cells[2]
239
239
  const emoji = (cells[3].match(/[⬜🔄✅]/u) || [])[0]
240
- rows.push({ name, status: EMOJI_STATUS[emoji] || 'not-started' })
240
+ // `stated` separates "the row says not-started" from "the row said nothing
241
+ // we recognise". A cell holding the word `Done`, an em dash, or a legacy
242
+ // spec's freeform text expresses no status in this vocabulary, and reading
243
+ // its absence as `not-started` let lintPhases quote the overview as saying
244
+ // something it never said.
245
+ rows.push({ name, status: EMOJI_STATUS[emoji] || 'not-started', stated: Boolean(emoji) })
241
246
  }
242
247
  return rows
243
248
  }
@@ -419,6 +424,10 @@ function readPhaseFiles(snapshotDir) {
419
424
  */
420
425
  function lintPhases(snapshotDir, config) {
421
426
  const phases = readPhaseFiles(snapshotDir)
427
+ // BLIND SPOT: `readPhaseFiles` only sees `NN-*.md`, so a legacy bare
428
+ // `<name>.md` spec yields no phases at all. Silence is the right answer —
429
+ // there is no phase file to carry an emoji — but it is silence from having
430
+ // looked nowhere, not from having looked and found everything in order.
422
431
  if (!phases.length) return []
423
432
 
424
433
  // The overview may be absent (a legacy bare `<name>.md` spec) — that is not
@@ -459,8 +468,14 @@ function lintPhases(snapshotDir, config) {
459
468
 
460
469
  // Match the index row by phase title, falling back to position — a renamed
461
470
  // phase shouldn't silently drop the check.
471
+ //
472
+ // BLIND SPOT: the index is only evidence where it used the emoji vocabulary.
473
+ // A row whose Status cell holds prose (or nothing) parses as `not-started`,
474
+ // which is a default, not a statement — cross-checking against it accused a
475
+ // healthy spec of a disagreement with a value nobody wrote. `stated` is the
476
+ // positive signal: compare only against a status the row actually expressed.
462
477
  const row = indexRows.find((r) => r.name === phase.name) || indexRows[i]
463
- if (row && row.status !== heading) {
478
+ if (row && row.stated && row.status !== heading) {
464
479
  warnings.push({
465
480
  file: phase.file,
466
481
  code: 'status-disagreement',