@skitterbyte/skitterspec-linear 9.2.0 → 10.0.1
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/MIGRATION.md +292 -0
- package/README.md +19 -0
- package/assets/core/SETUP.md +29 -4
- package/assets/core/linear.config.md +13 -1
- package/assets/skills/spec-push/SKILL.md +36 -14
- package/package.json +3 -2
- package/src/cli.js +6 -2
- package/src/init.js +45 -11
- package/src/lines-diff.js +114 -0
- package/src/vendor/linear/cli-sync.js +93 -6
- package/src/vendor/sync-core/index.js +4 -1
- package/src/vendor/sync-core/src/legacy.js +90 -0
- package/src/vendor/sync-core/src/normalize.js +100 -7
- package/src/vendor/sync-core/src/push.js +7 -0
- package/src/vendor/sync-core/src/task-block.js +87 -33
- package/src/vendor/sync-core/src/write.js +1 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A minimal line diff — just enough for `update` to say what it skipped.
|
|
5
|
+
*
|
|
6
|
+
* `update` reports a file it kept as `customized (kept)` and nothing else, so
|
|
7
|
+
* there is no way to learn WHICH upstream changes you declined without diffing
|
|
8
|
+
* against `node_modules` by hand. That is how a real behavioural change (the
|
|
9
|
+
* lifecycle skills learning to commit their own edits) went unnoticed through an
|
|
10
|
+
* upgrade in the field.
|
|
11
|
+
*
|
|
12
|
+
* Zero dependencies on purpose: this package ships with none, and `diff(1)` is
|
|
13
|
+
* not a portable guarantee. An LCS over lines is a few dozen lines of code and
|
|
14
|
+
* the inputs are markdown files of a few hundred lines.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Longest-common-subsequence walk over two line arrays, as a flat op list.
|
|
18
|
+
// `t` is ' ' (context), '-' (only in `a`) or '+' (only in `b`).
|
|
19
|
+
function diffOps(a, b) {
|
|
20
|
+
const n = a.length
|
|
21
|
+
const m = b.length
|
|
22
|
+
// dp[i][j] = LCS length of a[i..] and b[j..], flattened.
|
|
23
|
+
const dp = new Int32Array((n + 1) * (m + 1))
|
|
24
|
+
const at = (i, j) => i * (m + 1) + j
|
|
25
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
26
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
27
|
+
dp[at(i, j)] = a[i] === b[j] ? dp[at(i + 1, j + 1)] + 1 : Math.max(dp[at(i + 1, j)], dp[at(i, j + 1)])
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ops = []
|
|
32
|
+
let i = 0
|
|
33
|
+
let j = 0
|
|
34
|
+
while (i < n && j < m) {
|
|
35
|
+
if (a[i] === b[j]) {
|
|
36
|
+
ops.push({ t: ' ', line: a[i], a: i, b: j })
|
|
37
|
+
i++
|
|
38
|
+
j++
|
|
39
|
+
} else if (dp[at(i + 1, j)] >= dp[at(i, j + 1)]) {
|
|
40
|
+
ops.push({ t: '-', line: a[i], a: i, b: j })
|
|
41
|
+
i++
|
|
42
|
+
} else {
|
|
43
|
+
ops.push({ t: '+', line: b[j], a: i, b: j })
|
|
44
|
+
j++
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
while (i < n) {
|
|
48
|
+
ops.push({ t: '-', line: a[i], a: i, b: j })
|
|
49
|
+
i++
|
|
50
|
+
}
|
|
51
|
+
while (j < m) {
|
|
52
|
+
ops.push({ t: '+', line: b[j], a: i, b: j })
|
|
53
|
+
j++
|
|
54
|
+
}
|
|
55
|
+
return ops
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Group the ops into unified-diff hunks, each carrying `context` unchanged lines
|
|
59
|
+
// either side of a run of changes. Runs closer together than 2×context merge, as
|
|
60
|
+
// `diff -u` does, so a cluster of edits reads as one hunk.
|
|
61
|
+
function toHunks(ops, context) {
|
|
62
|
+
const changed = ops.map((o) => o.t !== ' ')
|
|
63
|
+
const hunks = []
|
|
64
|
+
let k = 0
|
|
65
|
+
while (k < ops.length) {
|
|
66
|
+
if (!changed[k]) {
|
|
67
|
+
k++
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
let start = Math.max(0, k - context)
|
|
71
|
+
let end = k
|
|
72
|
+
// Extend while the next change is near enough to keep in the same hunk.
|
|
73
|
+
for (let p = k; p < ops.length; p++) {
|
|
74
|
+
if (changed[p]) end = p
|
|
75
|
+
else if (p - end > context * 2) break
|
|
76
|
+
}
|
|
77
|
+
end = Math.min(ops.length - 1, end + context)
|
|
78
|
+
|
|
79
|
+
const body = ops.slice(start, end + 1)
|
|
80
|
+
const aStart = body[0].a + 1
|
|
81
|
+
const bStart = body[0].b + 1
|
|
82
|
+
const aLen = body.filter((o) => o.t !== '+').length
|
|
83
|
+
const bLen = body.filter((o) => o.t !== '-').length
|
|
84
|
+
hunks.push(
|
|
85
|
+
[`@@ -${aStart},${aLen} +${bStart},${bLen} @@`, ...body.map((o) => `${o.t}${o.line}`)].join('\n'),
|
|
86
|
+
)
|
|
87
|
+
k = end + 1
|
|
88
|
+
}
|
|
89
|
+
return hunks
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Diff `a` (what is on disk) against `b` (what the package ships).
|
|
94
|
+
*
|
|
95
|
+
* `added`/`removed` count the lines an update WOULD add and remove — the summary
|
|
96
|
+
* printed beside a kept file. `hunks` are unified-diff blocks for `--diff`.
|
|
97
|
+
*
|
|
98
|
+
* @param {string|string[]} a
|
|
99
|
+
* @param {string|string[]} b
|
|
100
|
+
* @param {{context?:number}} [opts]
|
|
101
|
+
* @returns {{added:number, removed:number, hunks:string[]}}
|
|
102
|
+
*/
|
|
103
|
+
function linesDiff(a, b, { context = 3 } = {}) {
|
|
104
|
+
const A = Array.isArray(a) ? a : String(a).split('\n')
|
|
105
|
+
const B = Array.isArray(b) ? b : String(b).split('\n')
|
|
106
|
+
const ops = diffOps(A, B)
|
|
107
|
+
return {
|
|
108
|
+
added: ops.filter((o) => o.t === '+').length,
|
|
109
|
+
removed: ops.filter((o) => o.t === '-').length,
|
|
110
|
+
hunks: toHunks(ops, context),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { linesDiff }
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* spec-sync normalize <spec> print the local projection (JSON)
|
|
12
12
|
* spec-sync push <spec> print the create/update PLAN the skill applies
|
|
13
|
+
* (requires --workspace-states; see stateCheckFailure)
|
|
13
14
|
* spec-sync stamp <spec> write returned ids back into the spec files
|
|
14
15
|
* spec-sync record <spec> write the last-pushed snapshot (after apply)
|
|
15
16
|
* spec-sync status <spec> read-only drift report (never writes)
|
|
@@ -35,6 +36,7 @@ const {
|
|
|
35
36
|
isEmptyPlan,
|
|
36
37
|
remoteWorkflowState,
|
|
37
38
|
validateStates,
|
|
39
|
+
stateSuggestions,
|
|
38
40
|
lintPhases,
|
|
39
41
|
writeFrontmatter,
|
|
40
42
|
stampSubIssueId,
|
|
@@ -178,16 +180,22 @@ function specSyncNormalize(dir, config, specArg, out, err) {
|
|
|
178
180
|
// applies it over MCP then calls `record`.
|
|
179
181
|
function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
180
182
|
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
181
|
-
if (!snapshotDir) return
|
|
183
|
+
if (!snapshotDir) return 1
|
|
184
|
+
const failure = stateCheckFailure(config, flags)
|
|
185
|
+
if (failure) {
|
|
186
|
+
out.write(failure.join('\n') + '\n')
|
|
187
|
+
return 1
|
|
188
|
+
}
|
|
182
189
|
const identifier = specIdentifier(snapshotDir, config)
|
|
183
190
|
const r = push({ dir, snapshotDir, identifier, config })
|
|
184
191
|
if (flags.json || !out.isTTY) {
|
|
185
192
|
warnToErr(snapshotDir, config, err)
|
|
186
193
|
out.write(JSON.stringify(r.plan, null, 2) + '\n')
|
|
187
|
-
return
|
|
194
|
+
return 0
|
|
188
195
|
}
|
|
189
196
|
const p = r.plan
|
|
190
197
|
const lines = [`spec-sync push: ${identifier}`, ...warningLines(snapshotDir, config)]
|
|
198
|
+
if (p.legacy) lines.push(...legacyLines(p.legacy))
|
|
191
199
|
if (r.empty) lines.push(' nothing to push — mirror matches the last push')
|
|
192
200
|
else {
|
|
193
201
|
if (p.issue) lines.push(' issue: description/state')
|
|
@@ -196,6 +204,84 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
|
196
204
|
lines.push(' (run with --json for the full plan the skill applies)')
|
|
197
205
|
}
|
|
198
206
|
out.write(lines.join('\n') + '\n')
|
|
207
|
+
return 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The pre-9.0 mirror block. Loud on purpose: the plan below it looks entirely
|
|
211
|
+
// ordinary — an all-creates plan for a spec that reads as unlinked — and
|
|
212
|
+
// applying it mints a second mirror and abandons the first.
|
|
213
|
+
function legacyLines(legacy) {
|
|
214
|
+
const found = legacy.keys.length ? legacy.keys.join(', ') : 'a pre-9.0 last-pushed snapshot'
|
|
215
|
+
const where = legacy.files.length ? ` in ${legacy.files.join(', ')}` : ''
|
|
216
|
+
const out = [
|
|
217
|
+
' !! PRE-9.0 MIRROR — do not apply this plan as-is',
|
|
218
|
+
` found ${found}${where}`,
|
|
219
|
+
]
|
|
220
|
+
if (legacy.orphans) {
|
|
221
|
+
const o = legacy.orphans
|
|
222
|
+
out.push(
|
|
223
|
+
` applying it would orphan ${o.total} live object(s): ` +
|
|
224
|
+
`${o.projects} project(s), ${o.milestones} milestone(s), ${o.issues} task issue(s)`,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
out.push(' migrate first — see MIGRATION.md ("v8 → v9")')
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Validate the configured `states` names against the workspace, for a command
|
|
233
|
+
* that REFUSES without them.
|
|
234
|
+
*
|
|
235
|
+
* The engine is offline — `mcp.js` is the skill's adapter, not ours — so the
|
|
236
|
+
* names have to be fetched over MCP and handed in via `--workspace-states`. That
|
|
237
|
+
* handoff used to be advisory: `/spec-push` told the agent to run it against
|
|
238
|
+
* `status`, and skipping it sent a state name Linear **silently ignores** (the
|
|
239
|
+
* description lands, the issue never moves, nothing errors). Requiring the file
|
|
240
|
+
* turns the one check that catches it from a convention into a precondition.
|
|
241
|
+
*
|
|
242
|
+
* Returns null when the caller may proceed, or the lines to print before exiting
|
|
243
|
+
* non-zero.
|
|
244
|
+
*/
|
|
245
|
+
function stateCheckFailure(config, flags) {
|
|
246
|
+
if (flags.skipStateCheck) return null
|
|
247
|
+
if (!flags.workspaceStates) {
|
|
248
|
+
return [
|
|
249
|
+
'spec-sync push: refusing — the configured issue states have not been validated',
|
|
250
|
+
' pass --workspace-states <file> (a JSON array of the workspace\'s issue',
|
|
251
|
+
' workflow-state names, which /spec-push fetches over MCP), or',
|
|
252
|
+
' --skip-state-check to push anyway.',
|
|
253
|
+
' Linear silently ignores an unknown issue state: the push would look',
|
|
254
|
+
' clean and the issue would never move.',
|
|
255
|
+
]
|
|
256
|
+
}
|
|
257
|
+
if (!fs.existsSync(flags.workspaceStates)) {
|
|
258
|
+
return [`spec-sync push: refusing — no such --workspace-states file: ${flags.workspaceStates}`]
|
|
259
|
+
}
|
|
260
|
+
let names
|
|
261
|
+
try {
|
|
262
|
+
names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return [`spec-sync push: refusing — --workspace-states is not valid JSON: ${error.message}`]
|
|
265
|
+
}
|
|
266
|
+
const list = Array.isArray(names) ? names : []
|
|
267
|
+
const missing = validateStates(config, list)
|
|
268
|
+
if (missing.length) {
|
|
269
|
+
// Say what IS available, and what to use instead. "Done is not a state" sends
|
|
270
|
+
// you to the Linear UI to go and look; naming the replacement does not.
|
|
271
|
+
const lines = ['spec-sync push: refusing — configured state name(s) not in the workspace', '']
|
|
272
|
+
for (const { bucket, configured, suggestion } of stateSuggestions(config, list)) {
|
|
273
|
+
lines.push(` states.${bucket}: "${configured}" is not an issue state in this workspace`)
|
|
274
|
+
if (suggestion) lines.push(` use "${suggestion}" instead`)
|
|
275
|
+
}
|
|
276
|
+
lines.push(
|
|
277
|
+
'',
|
|
278
|
+
` available: ${list.join(', ') || '(the workspace reported none)'}`,
|
|
279
|
+
' Fix specs/.core/linear.config.json → states. Linear silently ignores an',
|
|
280
|
+
' unknown issue state, so this would have pushed clean and moved nothing.',
|
|
281
|
+
)
|
|
282
|
+
return lines
|
|
283
|
+
}
|
|
284
|
+
return null
|
|
199
285
|
}
|
|
200
286
|
|
|
201
287
|
// A tracker id as it appears in a spec: `SKI-42`. Deliberately strict — the
|
|
@@ -347,12 +433,13 @@ async function specSync(rest, io = {}) {
|
|
|
347
433
|
const [sub, ...args] = rest
|
|
348
434
|
let dir = io.cwd || process.cwd()
|
|
349
435
|
const positional = []
|
|
350
|
-
const flags = { json: false, remote: null, workspaceStates: null, issue: null, url: null, subs: [] }
|
|
436
|
+
const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [] }
|
|
351
437
|
for (let i = 0; i < args.length; i++) {
|
|
352
438
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
353
439
|
else if (args[i] === '--json') flags.json = true
|
|
354
440
|
else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
|
|
355
441
|
else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
|
|
442
|
+
else if (args[i] === '--skip-state-check') flags.skipStateCheck = true
|
|
356
443
|
else if (args[i] === '--issue') flags.issue = args[++i]
|
|
357
444
|
else if (args[i] === '--url') flags.url = args[++i]
|
|
358
445
|
else if (args[i] === '--sub') flags.subs.push(args[++i])
|
|
@@ -374,8 +461,7 @@ async function specSync(rest, io = {}) {
|
|
|
374
461
|
specSyncNormalize(dir, config, positional[0], out, err)
|
|
375
462
|
return 0
|
|
376
463
|
case 'push':
|
|
377
|
-
specSyncPush(dir, config, positional[0], flags, out, err)
|
|
378
|
-
return 0
|
|
464
|
+
return specSyncPush(dir, config, positional[0], flags, out, err) || 0
|
|
379
465
|
case 'stamp':
|
|
380
466
|
return specSyncStamp(dir, config, positional[0], flags, out)
|
|
381
467
|
case 'record':
|
|
@@ -387,7 +473,8 @@ async function specSync(rest, io = {}) {
|
|
|
387
473
|
specSyncLinked(dir, config, flags, out)
|
|
388
474
|
return 0
|
|
389
475
|
default:
|
|
390
|
-
out.write('Usage: skitterspec spec-sync <normalize|
|
|
476
|
+
out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
|
|
477
|
+
' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
|
|
391
478
|
' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
|
|
392
479
|
' skitterspec spec-sync linked [--json]\n')
|
|
393
480
|
return 0
|
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
* over its API. No remote content is read or merged.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates } = require('./src/normalize.js')
|
|
13
|
+
const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions } = require('./src/normalize.js')
|
|
14
14
|
const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify } = require('./src/compare.js')
|
|
15
15
|
const { readBase, writeBase } = require('./src/base.js')
|
|
16
16
|
const { push, recordPush, projectionOf } = require('./src/push.js')
|
|
17
17
|
const { writeFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
|
|
18
18
|
const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
|
|
19
|
+
const { detectLegacyMirror } = require('./src/legacy.js')
|
|
19
20
|
|
|
20
21
|
module.exports = {
|
|
21
22
|
normalizeLocal,
|
|
@@ -31,6 +32,7 @@ module.exports = {
|
|
|
31
32
|
remoteWorkflowState,
|
|
32
33
|
titleFromText,
|
|
33
34
|
validateStates,
|
|
35
|
+
stateSuggestions,
|
|
34
36
|
hashField,
|
|
35
37
|
stableStringify,
|
|
36
38
|
readBase,
|
|
@@ -41,4 +43,5 @@ module.exports = {
|
|
|
41
43
|
findPhaseFileByTitle,
|
|
42
44
|
listPhaseFiles,
|
|
43
45
|
sanitizeSpecMarkdown,
|
|
46
|
+
detectLegacyMirror,
|
|
44
47
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect a spec whose mirror was created under the **pre-9.0 model**.
|
|
5
|
+
*
|
|
6
|
+
* v9 remapped the mirror: a spec is an issue (was a Project), a phase is a
|
|
7
|
+
* sub-issue (was a Milestone), and tasks are no longer objects at all. The
|
|
8
|
+
* frontmatter keys moved with it — `linear_project_id` → `linear_identifier`,
|
|
9
|
+
* `linear_milestone_id` → `linear_issue_id`.
|
|
10
|
+
*
|
|
11
|
+
* The failure this guards is silent and destructive: v9 looks for the new keys,
|
|
12
|
+
* finds nothing, and produces a perfectly ordinary **all-creates** plan. Applying
|
|
13
|
+
* it mints a fresh mirror and abandons the old one — in the field that would have
|
|
14
|
+
* been 17 new objects against 2 projects, 15 milestones and 145 task issues left
|
|
15
|
+
* orphaned, with nothing on screen suggesting a prior mirror existed. It was
|
|
16
|
+
* caught only because an all-creates plan looked wrong for specs synced an hour
|
|
17
|
+
* earlier.
|
|
18
|
+
*
|
|
19
|
+
* Pure reads; returns `null` for anything that is not demonstrably pre-9.0, so a
|
|
20
|
+
* never-pushed spec is never mistaken for a stranded one.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs')
|
|
24
|
+
const path = require('node:path')
|
|
25
|
+
|
|
26
|
+
const { parseFrontmatter } = require('./normalize.js')
|
|
27
|
+
const { readBase } = require('./base.js')
|
|
28
|
+
const { listPhaseFiles } = require('./write.js')
|
|
29
|
+
|
|
30
|
+
// Frontmatter keys only the pre-9.0 model ever wrote.
|
|
31
|
+
const LEGACY_OVERVIEW_KEY = 'linear_project_id'
|
|
32
|
+
const LEGACY_PHASE_KEY = 'linear_milestone_id'
|
|
33
|
+
|
|
34
|
+
function frontmatterOf(file) {
|
|
35
|
+
try {
|
|
36
|
+
return parseFrontmatter(fs.readFileSync(file, 'utf-8')).data || {}
|
|
37
|
+
} catch {
|
|
38
|
+
return {}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A pre-9.0 snapshot recorded `{project, milestones, issues}`; v9 records
|
|
43
|
+
// `{issue, subIssues}`. Counting what it holds turns the warning from "this
|
|
44
|
+
// looks old" into "this many live objects would be abandoned".
|
|
45
|
+
function countOrphans(snapshot) {
|
|
46
|
+
if (!snapshot || typeof snapshot !== 'object') return null
|
|
47
|
+
const size = (v) => (Array.isArray(v) ? v.length : v && typeof v === 'object' ? Object.keys(v).length : 0)
|
|
48
|
+
const projects = snapshot.project ? 1 : 0
|
|
49
|
+
const milestones = size(snapshot.milestones)
|
|
50
|
+
const issues = size(snapshot.issues)
|
|
51
|
+
if (!projects && !milestones && !issues) return null
|
|
52
|
+
return { projects, milestones, issues, total: projects + milestones + issues }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @returns {null|{keys:string[], files:string[], orphans:object|null, orphanCount:number}}
|
|
57
|
+
*/
|
|
58
|
+
function detectLegacyMirror({ dir, snapshotDir, identifier, config }) {
|
|
59
|
+
const keys = []
|
|
60
|
+
const files = []
|
|
61
|
+
|
|
62
|
+
const overviewFile = (config && config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
63
|
+
const overview = frontmatterOf(path.join(snapshotDir, overviewFile))
|
|
64
|
+
if (overview[LEGACY_OVERVIEW_KEY] != null) {
|
|
65
|
+
keys.push(LEGACY_OVERVIEW_KEY)
|
|
66
|
+
files.push(overviewFile)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const file of listPhaseFiles(snapshotDir)) {
|
|
70
|
+
if (frontmatterOf(path.join(snapshotDir, file))[LEGACY_PHASE_KEY] != null) {
|
|
71
|
+
if (!keys.includes(LEGACY_PHASE_KEY)) keys.push(LEGACY_PHASE_KEY)
|
|
72
|
+
files.push(file)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let orphans = null
|
|
77
|
+
try {
|
|
78
|
+
orphans = countOrphans(readBase(dir, identifier, config))
|
|
79
|
+
} catch {
|
|
80
|
+
/* an unreadable snapshot is not evidence either way */
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A legacy snapshot alone is enough: the spec may have had its frontmatter
|
|
84
|
+
// hand-cleaned while the mirror it names is still live.
|
|
85
|
+
if (!keys.length && !orphans) return null
|
|
86
|
+
|
|
87
|
+
return { keys, files, orphans, orphanCount: orphans ? orphans.total : 0 }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { detectLegacyMirror }
|
|
@@ -319,6 +319,46 @@ function parseTaskLine(line) {
|
|
|
319
319
|
return { id, text, done }
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Split a phase's task blocks into the `##` sections they were written under.
|
|
324
|
+
*
|
|
325
|
+
* The checklist used to be one flat list under a hardcoded `## Tasks`, so a
|
|
326
|
+
* criterion written under `## Acceptance` arrived in the mirror as an ordinary
|
|
327
|
+
* open task. Nothing was lost — it was just unreadable.
|
|
328
|
+
*
|
|
329
|
+
* Grouping is done by MAPPING blocks onto headings, not by re-parsing the body
|
|
330
|
+
* section by section: `findTaskBlocks` tracks open task subtrees across a
|
|
331
|
+
* continuous body, and slicing that body at every heading would change what a
|
|
332
|
+
* block claims at a section boundary. A heading inside a fence is not a heading
|
|
333
|
+
* (`fenceMask`), and the phase file's own `#` H1 is not a section.
|
|
334
|
+
*
|
|
335
|
+
* @returns {Array<{heading:string|null, tasks:string[]}>} in source order;
|
|
336
|
+
* `heading` is null for blocks that precede every heading, and a heading with
|
|
337
|
+
* no blocks under it never appears.
|
|
338
|
+
*/
|
|
339
|
+
function groupTasksByHeading(lines, blocks, renderTask) {
|
|
340
|
+
const inFence = fenceMask(lines)
|
|
341
|
+
const headings = []
|
|
342
|
+
for (let i = 0; i < lines.length; i++) {
|
|
343
|
+
if (inFence[i]) continue
|
|
344
|
+
const m = /^(#{2,6})\s+(.*\S)\s*$/.exec(lines[i])
|
|
345
|
+
if (m) headings.push({ line: i, heading: `${m[1]} ${m[2]}` })
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const groups = []
|
|
349
|
+
for (const b of blocks) {
|
|
350
|
+
let heading = null
|
|
351
|
+
for (const h of headings) {
|
|
352
|
+
if (h.line >= b.start) break
|
|
353
|
+
heading = h.heading
|
|
354
|
+
}
|
|
355
|
+
const last = groups[groups.length - 1]
|
|
356
|
+
if (last && last.heading === heading) last.tasks.push(renderTask(b))
|
|
357
|
+
else groups.push({ heading, tasks: [renderTask(b)] })
|
|
358
|
+
}
|
|
359
|
+
return groups
|
|
360
|
+
}
|
|
361
|
+
|
|
322
362
|
// Read the phase files (01-*.md, 02-*.md …) in execution order. Each yields its
|
|
323
363
|
// linked milestone id (from optional frontmatter), title, goal and tasks.
|
|
324
364
|
function readPhaseFiles(snapshotDir) {
|
|
@@ -343,14 +383,23 @@ function readPhaseFiles(snapshotDir) {
|
|
|
343
383
|
// both sides keeps a wrapped goal from diffing forever.
|
|
344
384
|
const goal = collapseHyphenAware((/\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body) || [])[1] || '')
|
|
345
385
|
// Rendered as markdown checklist lines, ready to drop into a sub-issue
|
|
346
|
-
// description: indentation kept so nesting survives,
|
|
347
|
-
//
|
|
386
|
+
// description: indentation kept so nesting survives, each bullet keeping
|
|
387
|
+
// the marker its author wrote, and any inline `(KEY-123)` stamped on a legacy task
|
|
348
388
|
// line stripped — those ids were per-task issues we no longer create, and
|
|
349
389
|
// they read as noise in the mirror.
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
390
|
+
const lines = body.split('\n')
|
|
391
|
+
const renderTask = (b) => {
|
|
392
|
+
// `findTaskBlocks` also returns the plain sub-bullets written underneath
|
|
393
|
+
// a task. They carry no checkbox, so they render as the bullet their
|
|
394
|
+
// author used — emitting `- [ ]` here would invent a task that does not
|
|
395
|
+
// exist in the repo.
|
|
396
|
+
const parsed = parseTaskLine(`[${b.checkbox ? b.mark : ' '}] ${b.text}`)
|
|
397
|
+
const text = parsed ? parsed.text : b.text
|
|
398
|
+
return b.checkbox ? `${b.indent}- [${b.mark}] ${text}` : `${b.indent}${b.marker} ${text}`
|
|
399
|
+
}
|
|
400
|
+
const blocks = findTaskBlocks(lines)
|
|
401
|
+
const tasks = blocks.map(renderTask)
|
|
402
|
+
const taskGroups = groupTasksByHeading(lines, blocks, renderTask)
|
|
354
403
|
return {
|
|
355
404
|
phase: file.replace(/\.md$/, ''),
|
|
356
405
|
file,
|
|
@@ -365,6 +414,7 @@ function readPhaseFiles(snapshotDir) {
|
|
|
365
414
|
emoji: headingEmoji(body),
|
|
366
415
|
statusLine: phaseStatusLine(body),
|
|
367
416
|
tasks,
|
|
417
|
+
taskGroups,
|
|
368
418
|
}
|
|
369
419
|
})
|
|
370
420
|
}
|
|
@@ -492,7 +542,14 @@ function subIssueBody(phase, tasksMode) {
|
|
|
492
542
|
if (tasksMode !== 'checklist' || !phase.tasks.length) return phase.goal
|
|
493
543
|
const parts = []
|
|
494
544
|
if (phase.goal) parts.push(phase.goal, '')
|
|
495
|
-
|
|
545
|
+
// One section per source heading, in source order. Checkboxes written before
|
|
546
|
+
// any heading keep the `## Tasks` default, so a phase file with a single task
|
|
547
|
+
// section — every one in this repo's corpus but two — projects unchanged.
|
|
548
|
+
const groups = phase.taskGroups && phase.taskGroups.length ? phase.taskGroups : [{ heading: null, tasks: phase.tasks }]
|
|
549
|
+
groups.forEach((group, i) => {
|
|
550
|
+
if (i) parts.push('')
|
|
551
|
+
parts.push(group.heading || '## Tasks', '', ...group.tasks)
|
|
552
|
+
})
|
|
496
553
|
return parts.join('\n')
|
|
497
554
|
}
|
|
498
555
|
|
|
@@ -663,7 +720,43 @@ function validateStates(config, workspaceStates) {
|
|
|
663
720
|
return configured.filter((name) => !have.has(name.toLowerCase().trim()))
|
|
664
721
|
}
|
|
665
722
|
|
|
723
|
+
// Words that identify a workspace state as belonging to a lifecycle bucket.
|
|
724
|
+
// Used only to SUGGEST a replacement for a configured name the workspace does
|
|
725
|
+
// not have — never to pick one silently. The 8→9 case this exists for is
|
|
726
|
+
// `complete`, where the correct value inverts: the project status `Completed`
|
|
727
|
+
// became the issue state `Done`, and no string-distance measure gets you from
|
|
728
|
+
// one to the other.
|
|
729
|
+
const BUCKET_WORDS = {
|
|
730
|
+
backlog: ['backlog', 'triage', 'todo', 'to do'],
|
|
731
|
+
'in-progress': ['in progress', 'in-progress', 'doing', 'started', 'in review'],
|
|
732
|
+
complete: ['done', 'complete', 'completed', 'shipped', 'merged', 'released'],
|
|
733
|
+
cancelled: ['canceled', 'cancelled', 'abandoned', "won't do", 'wont do', 'duplicate'],
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* For each configured state name the workspace lacks, what to use instead.
|
|
738
|
+
*
|
|
739
|
+
* `validateStates` says a name is wrong; this says what is right, which is the
|
|
740
|
+
* difference between an error you can act on and one you have to go look up.
|
|
741
|
+
*
|
|
742
|
+
* @returns {Array<{bucket:string, configured:string, suggestion:string|null}>}
|
|
743
|
+
*/
|
|
744
|
+
function stateSuggestions(config, workspaceStates) {
|
|
745
|
+
const names = (workspaceStates || []).map((s) => String(s)).filter(Boolean)
|
|
746
|
+
const have = new Set(names.map((n) => n.toLowerCase().trim()))
|
|
747
|
+
const out = []
|
|
748
|
+
for (const [bucket, configured] of Object.entries((config && config.states) || {})) {
|
|
749
|
+
if (typeof configured !== 'string') continue
|
|
750
|
+
if (have.has(configured.toLowerCase().trim())) continue
|
|
751
|
+
const words = BUCKET_WORDS[bucket] || []
|
|
752
|
+
const suggestion = names.find((n) => words.includes(n.toLowerCase().trim())) || null
|
|
753
|
+
out.push({ bucket, configured, suggestion })
|
|
754
|
+
}
|
|
755
|
+
return out
|
|
756
|
+
}
|
|
757
|
+
|
|
666
758
|
module.exports = {
|
|
759
|
+
stateSuggestions,
|
|
667
760
|
normalizeLocal,
|
|
668
761
|
lintPhases,
|
|
669
762
|
readSnapshot,
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
const { normalizeLocal } = require('./normalize.js')
|
|
19
19
|
const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
|
|
20
20
|
const { readBase, writeBase } = require('./base.js')
|
|
21
|
+
const { detectLegacyMirror } = require('./legacy.js')
|
|
21
22
|
|
|
22
23
|
// Build the one-way projection from a local snapshot: the spec issue's prose +
|
|
23
24
|
// status, and its phase sub-issues. `status` is the local lifecycle bucket; the
|
|
@@ -36,6 +37,12 @@ function push({ dir, snapshotDir, identifier, config }) {
|
|
|
36
37
|
const projection = projectionOf(snapshotDir, config)
|
|
37
38
|
const snapshot = readBase(dir, identifier, config)
|
|
38
39
|
const plan = planChanges(projection, snapshot)
|
|
40
|
+
// A spec still linked under the pre-9.0 model reads as unlinked here, so the
|
|
41
|
+
// plan above is all-creates and would abandon a live mirror. Carry the finding
|
|
42
|
+
// ON THE PLAN, not as a warning: `--json` routes warnings to stderr, and the
|
|
43
|
+
// skill that applies this plan is exactly the consumer that would miss them.
|
|
44
|
+
const legacy = detectLegacyMirror({ dir, snapshotDir, identifier, config })
|
|
45
|
+
if (legacy) plan.legacy = legacy
|
|
39
46
|
return { ok: true, empty: isEmptyPlan(plan), plan, projection }
|
|
40
47
|
}
|
|
41
48
|
|