@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.
- package/assets/core/SETUP.md +13 -1
- package/assets/core/linear.config.md +27 -1
- package/assets/rules/negative-checks.md +69 -0
- package/assets/skills/spec-linear-setup/SKILL.md +21 -10
- package/assets/skills/spec-status/SKILL.md +18 -0
- package/assets/skills/spec-sync/SKILL.md +25 -35
- package/package.json +1 -1
- package/src/init.js +5 -0
- package/src/vendor/linear/api.js +1 -1
- package/src/vendor/linear/cli-sync.js +359 -104
- package/src/vendor/linear/doctor.js +146 -308
- package/src/vendor/sync-core/index.js +6 -0
- 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
|
@@ -1,341 +1,179 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* `spec-sync doctor` — one readiness report across every layer of a setup.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return '
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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',
|