@skitterbyte/skitterspec-linear 9.1.0 → 10.0.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/MIGRATION.md +156 -0
- package/README.md +34 -4
- package/assets/core/SETUP.md +23 -6
- package/assets/core/linear.config.json.example +1 -1
- package/assets/core/linear.config.md +25 -6
- package/assets/skills/spec/SKILL.md +14 -12
- package/assets/skills/spec-push/SKILL.md +63 -25
- package/assets/skills/spec-review/SKILL.md +13 -0
- package/bin/skitterspec-linear.js +5 -1
- 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 +216 -13
- package/src/vendor/linear/config.js +20 -1
- package/src/vendor/linear/mcp.js +2 -1
- package/src/vendor/sync-core/index.js +7 -2
- package/src/vendor/sync-core/src/legacy.js +90 -0
- package/src/vendor/sync-core/src/normalize.js +188 -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,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 }
|
|
@@ -253,14 +253,53 @@ function phaseTitle(body) {
|
|
|
253
253
|
return t || null
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
// The raw ⬜/🔄/✅ on a phase file's h1, or undefined when it carries none.
|
|
257
|
+
// Kept separate from `phaseStateBucket` because the LINT has to tell "absent"
|
|
258
|
+
// apart from "not-started" — the projection deliberately cannot (see below).
|
|
259
|
+
function headingEmoji(body) {
|
|
260
|
+
const h1 = /^#\s+(.*)$/m.exec(body)
|
|
261
|
+
return h1 ? (h1[1].match(/[⬜🔄✅]/u) || [])[0] : undefined
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The `> **Status:** …` line's value, or null when the file has none. This is
|
|
265
|
+
// the human mirror of the heading emoji, not a source of truth — `lintPhases`
|
|
266
|
+
// cross-checks it, and nothing else reads it.
|
|
267
|
+
function phaseStatusLine(body) {
|
|
268
|
+
const m = /^>.*\*\*Status:\*\*\s*(.+?)\s*$/m.exec(body)
|
|
269
|
+
return m ? m[1] : null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Read a `> **Status:**` value leniently into the canonical vocabulary: an emoji
|
|
273
|
+
// if it carries one, else a word we recognise. Returns null for anything else —
|
|
274
|
+
// the line is free prose, and warning on an unrecognised phrasing would train
|
|
275
|
+
// the warning away.
|
|
276
|
+
// `pending` is deliberately NOT here: "pending review" matches as not-started
|
|
277
|
+
// but means roughly the opposite, and a false positive is worse than a missed
|
|
278
|
+
// check — it teaches the reader to ignore the warning.
|
|
279
|
+
const STATUS_WORDS = [
|
|
280
|
+
[/\b(not[\s-]?started|todo|to[\s-]do|planned)\b/i, 'not-started'],
|
|
281
|
+
[/\b(in[\s-]?progress|started|doing|wip)\b/i, 'in-progress'],
|
|
282
|
+
[/\b(done|complete[d]?|finished|shipped)\b/i, 'done'],
|
|
283
|
+
]
|
|
284
|
+
function statusLineValue(line) {
|
|
285
|
+
if (!line) return null
|
|
286
|
+
const emoji = (line.match(/[⬜🔄✅]/u) || [])[0]
|
|
287
|
+
if (emoji) return EMOJI_STATUS[emoji]
|
|
288
|
+
for (const [re, status] of STATUS_WORDS) if (re.test(line)) return status
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
|
|
256
292
|
// A phase's status (from its heading emoji ⬜/🔄/✅) mapped to a state bucket the
|
|
257
293
|
// `states` table understands, so a sub-issue lands in the matching Linear issue
|
|
258
294
|
// state. Unknown/absent → backlog.
|
|
295
|
+
//
|
|
296
|
+
// That fallback conflates "author marked it not-started" with "author used a
|
|
297
|
+
// format we don't parse", which is silent corruption: the wrong state pushes
|
|
298
|
+
// cleanly and `record` then commits it as the INTENDED value. The fix is not
|
|
299
|
+
// leniency here — one convention beats two — it is `lintPhases`, which warns.
|
|
259
300
|
const PHASE_STATE_BUCKET = { 'not-started': 'backlog', 'in-progress': 'in-progress', done: 'complete' }
|
|
260
301
|
function phaseStateBucket(body) {
|
|
261
|
-
|
|
262
|
-
const emoji = h1 ? (h1[1].match(/[⬜🔄✅]/u) || [])[0] : undefined
|
|
263
|
-
return PHASE_STATE_BUCKET[EMOJI_STATUS[emoji]] || 'backlog'
|
|
302
|
+
return PHASE_STATE_BUCKET[EMOJI_STATUS[headingEmoji(body)]] || 'backlog'
|
|
264
303
|
}
|
|
265
304
|
|
|
266
305
|
// Parse a task line (already stripped of its leading "- ") into a keyed item:
|
|
@@ -303,7 +342,20 @@ function readPhaseFiles(snapshotDir) {
|
|
|
303
342
|
// and Linear may canonicalize a soft line break away on save. Collapsing
|
|
304
343
|
// both sides keeps a wrapped goal from diffing forever.
|
|
305
344
|
const goal = collapseHyphenAware((/\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body) || [])[1] || '')
|
|
306
|
-
|
|
345
|
+
// Rendered as markdown checklist lines, ready to drop into a sub-issue
|
|
346
|
+
// description: indentation kept so nesting survives, each bullet keeping
|
|
347
|
+
// the marker its author wrote, and any inline `(KEY-123)` stamped on a legacy task
|
|
348
|
+
// line stripped — those ids were per-task issues we no longer create, and
|
|
349
|
+
// they read as noise in the mirror.
|
|
350
|
+
const tasks = findTaskBlocks(body.split('\n')).map((b) => {
|
|
351
|
+
// `findTaskBlocks` also returns the plain sub-bullets written underneath
|
|
352
|
+
// a task. They carry no checkbox, so they render as the bullet their
|
|
353
|
+
// author used — emitting `- [ ]` here would invent a task that does not
|
|
354
|
+
// exist in the repo.
|
|
355
|
+
const parsed = parseTaskLine(`[${b.checkbox ? b.mark : ' '}] ${b.text}`)
|
|
356
|
+
const text = parsed ? parsed.text : b.text
|
|
357
|
+
return b.checkbox ? `${b.indent}- [${b.mark}] ${text}` : `${b.indent}${b.marker} ${text}`
|
|
358
|
+
})
|
|
307
359
|
return {
|
|
308
360
|
phase: file.replace(/\.md$/, ''),
|
|
309
361
|
file,
|
|
@@ -312,11 +364,85 @@ function readPhaseFiles(snapshotDir) {
|
|
|
312
364
|
name: phaseTitle(body),
|
|
313
365
|
goal: goal.trim(),
|
|
314
366
|
state: phaseStateBucket(body),
|
|
367
|
+
// Lint-only signals. `emoji` is undefined when the heading carries none
|
|
368
|
+
// — the distinction `state` throws away; `statusLine` is the raw
|
|
369
|
+
// `> **Status:**` value. Neither affects the projection.
|
|
370
|
+
emoji: headingEmoji(body),
|
|
371
|
+
statusLine: phaseStatusLine(body),
|
|
315
372
|
tasks,
|
|
316
373
|
}
|
|
317
374
|
})
|
|
318
375
|
}
|
|
319
376
|
|
|
377
|
+
// --- phase-status lint ------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Warn where a phase's status signals are absent or disagree.
|
|
381
|
+
*
|
|
382
|
+
* A spec carries the same status in three places — the phase file's h1 emoji,
|
|
383
|
+
* its `> **Status:**` line, and the `00-overview.md` phase-index row — and only
|
|
384
|
+
* the h1 is load-bearing. Writing the other two correctly while leaving the h1
|
|
385
|
+
* bare projects a finished phase as `backlog`, pushes cleanly, and records the
|
|
386
|
+
* wrong value as intended. Nothing looks wrong anywhere.
|
|
387
|
+
*
|
|
388
|
+
* Returns `[{ file, code, message }]`, `code` being `missing-status-emoji` or
|
|
389
|
+
* `status-disagreement`. Pure aside from reads; callers decide how loud to be
|
|
390
|
+
* (today: printed, never fatal).
|
|
391
|
+
*/
|
|
392
|
+
function lintPhases(snapshotDir, config) {
|
|
393
|
+
const phases = readPhaseFiles(snapshotDir)
|
|
394
|
+
if (!phases.length) return []
|
|
395
|
+
|
|
396
|
+
// The overview may be absent (a legacy bare `<name>.md` spec) — that is not
|
|
397
|
+
// itself a lint failure, it just removes one of the three cross-checks.
|
|
398
|
+
let indexRows = []
|
|
399
|
+
try {
|
|
400
|
+
const overviewFile = (config && config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
401
|
+
const raw = fs.readFileSync(path.join(snapshotDir, overviewFile), 'utf-8')
|
|
402
|
+
const { sections } = parseSections(parseFrontmatter(raw).body)
|
|
403
|
+
indexRows = parsePhaseIndex(sections.Phases)
|
|
404
|
+
} catch {
|
|
405
|
+
indexRows = []
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const warnings = []
|
|
409
|
+
phases.forEach((phase, i) => {
|
|
410
|
+
if (!phase.emoji) {
|
|
411
|
+
warnings.push({
|
|
412
|
+
file: phase.file,
|
|
413
|
+
code: 'missing-status-emoji',
|
|
414
|
+
message: `no ⬜/🔄/✅ in the heading — projecting as not-started`,
|
|
415
|
+
})
|
|
416
|
+
// Without a heading emoji there is nothing to disagree WITH: the other two
|
|
417
|
+
// signals can't be checked against a value that was never expressed.
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const heading = EMOJI_STATUS[phase.emoji]
|
|
422
|
+
|
|
423
|
+
const fromLine = statusLineValue(phase.statusLine)
|
|
424
|
+
if (fromLine && fromLine !== heading) {
|
|
425
|
+
warnings.push({
|
|
426
|
+
file: phase.file,
|
|
427
|
+
code: 'status-disagreement',
|
|
428
|
+
message: `heading says ${heading} but its Status line says ${fromLine}`,
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Match the index row by phase title, falling back to position — a renamed
|
|
433
|
+
// phase shouldn't silently drop the check.
|
|
434
|
+
const row = indexRows.find((r) => r.name === phase.name) || indexRows[i]
|
|
435
|
+
if (row && row.status !== heading) {
|
|
436
|
+
warnings.push({
|
|
437
|
+
file: phase.file,
|
|
438
|
+
code: 'status-disagreement',
|
|
439
|
+
message: `heading says ${heading} but the overview phase-index row says ${row.status}`,
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
})
|
|
443
|
+
return warnings
|
|
444
|
+
}
|
|
445
|
+
|
|
320
446
|
// --- ownership-driven field set ---------------------------------------------
|
|
321
447
|
|
|
322
448
|
// Reduce an `extracted` map to exactly the configured field keys, defaulting a
|
|
@@ -359,6 +485,22 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
|
|
|
359
485
|
return canonicalizeMarkdown(parts.join('\n\n')) || null
|
|
360
486
|
}
|
|
361
487
|
|
|
488
|
+
// A phase sub-issue's description: its `**Goal:**` line, plus the phase's task
|
|
489
|
+
// list as a markdown checklist when `mapping.tasks` is `checklist`.
|
|
490
|
+
//
|
|
491
|
+
// The checklist is a READ-ONLY mirror like everything else here — the repo is
|
|
492
|
+
// the source of truth, so a box ticked in the tracker is overwritten on the next
|
|
493
|
+
// push. Without it a sub-issue is a title and one sentence, which is too thin to
|
|
494
|
+
// act on; with it the phase is legible to someone working in the tracker without
|
|
495
|
+
// tasks becoming individually-synced objects again.
|
|
496
|
+
function subIssueBody(phase, tasksMode) {
|
|
497
|
+
if (tasksMode !== 'checklist' || !phase.tasks.length) return phase.goal
|
|
498
|
+
const parts = []
|
|
499
|
+
if (phase.goal) parts.push(phase.goal, '')
|
|
500
|
+
parts.push('## Tasks', '', ...phase.tasks)
|
|
501
|
+
return parts.join('\n')
|
|
502
|
+
}
|
|
503
|
+
|
|
362
504
|
/**
|
|
363
505
|
* Normalize a local spec snapshot into the configured field set.
|
|
364
506
|
*/
|
|
@@ -376,6 +518,7 @@ function normalizeLocal(snapshotDir, config) {
|
|
|
376
518
|
// so strip the `## Phases` index from the description to avoid duplicating it
|
|
377
519
|
// (as prose AND as sub-issues) in the Linear mirror.
|
|
378
520
|
const phasesProjected = !!(config.sync.fieldOwnership && 'subIssues' in config.sync.fieldOwnership)
|
|
521
|
+
const tasksMode = (config.mapping && config.mapping.tasks) || 'checklist'
|
|
379
522
|
const extracted = {
|
|
380
523
|
description: buildDescription(
|
|
381
524
|
title,
|
|
@@ -386,11 +529,12 @@ function normalizeLocal(snapshotDir, config) {
|
|
|
386
529
|
// Sub-issue projection: one per phase. `ref` is the phase-file basename — the
|
|
387
530
|
// local handle the push skill stamps a newly-created sub-issue id back into.
|
|
388
531
|
// `state` is the phase's status bucket (from its heading emoji), mapped to a
|
|
389
|
-
// Linear issue state via `config.states` at push time. Tasks
|
|
390
|
-
//
|
|
532
|
+
// Linear issue state via `config.states` at push time. Tasks ride along in
|
|
533
|
+
// the description as a read-only checklist (`mapping.tasks`), never as
|
|
534
|
+
// individually-synced objects.
|
|
391
535
|
subIssues: phases
|
|
392
536
|
.filter((p) => p.name)
|
|
393
|
-
.map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: p
|
|
537
|
+
.map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: subIssueBody(p, tasksMode), state: p.state })),
|
|
394
538
|
// Status is the spec's lifecycle bucket. The folder is the source of truth;
|
|
395
539
|
// an explicit `spec_status` frontmatter key overrides it if present.
|
|
396
540
|
workflowState:
|
|
@@ -524,8 +668,45 @@ function validateStates(config, workspaceStates) {
|
|
|
524
668
|
return configured.filter((name) => !have.has(name.toLowerCase().trim()))
|
|
525
669
|
}
|
|
526
670
|
|
|
671
|
+
// Words that identify a workspace state as belonging to a lifecycle bucket.
|
|
672
|
+
// Used only to SUGGEST a replacement for a configured name the workspace does
|
|
673
|
+
// not have — never to pick one silently. The 8→9 case this exists for is
|
|
674
|
+
// `complete`, where the correct value inverts: the project status `Completed`
|
|
675
|
+
// became the issue state `Done`, and no string-distance measure gets you from
|
|
676
|
+
// one to the other.
|
|
677
|
+
const BUCKET_WORDS = {
|
|
678
|
+
backlog: ['backlog', 'triage', 'todo', 'to do'],
|
|
679
|
+
'in-progress': ['in progress', 'in-progress', 'doing', 'started', 'in review'],
|
|
680
|
+
complete: ['done', 'complete', 'completed', 'shipped', 'merged', 'released'],
|
|
681
|
+
cancelled: ['canceled', 'cancelled', 'abandoned', "won't do", 'wont do', 'duplicate'],
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* For each configured state name the workspace lacks, what to use instead.
|
|
686
|
+
*
|
|
687
|
+
* `validateStates` says a name is wrong; this says what is right, which is the
|
|
688
|
+
* difference between an error you can act on and one you have to go look up.
|
|
689
|
+
*
|
|
690
|
+
* @returns {Array<{bucket:string, configured:string, suggestion:string|null}>}
|
|
691
|
+
*/
|
|
692
|
+
function stateSuggestions(config, workspaceStates) {
|
|
693
|
+
const names = (workspaceStates || []).map((s) => String(s)).filter(Boolean)
|
|
694
|
+
const have = new Set(names.map((n) => n.toLowerCase().trim()))
|
|
695
|
+
const out = []
|
|
696
|
+
for (const [bucket, configured] of Object.entries((config && config.states) || {})) {
|
|
697
|
+
if (typeof configured !== 'string') continue
|
|
698
|
+
if (have.has(configured.toLowerCase().trim())) continue
|
|
699
|
+
const words = BUCKET_WORDS[bucket] || []
|
|
700
|
+
const suggestion = names.find((n) => words.includes(n.toLowerCase().trim())) || null
|
|
701
|
+
out.push({ bucket, configured, suggestion })
|
|
702
|
+
}
|
|
703
|
+
return out
|
|
704
|
+
}
|
|
705
|
+
|
|
527
706
|
module.exports = {
|
|
707
|
+
stateSuggestions,
|
|
528
708
|
normalizeLocal,
|
|
709
|
+
lintPhases,
|
|
529
710
|
readSnapshot,
|
|
530
711
|
parseFrontmatter,
|
|
531
712
|
parseSections,
|
|
@@ -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
|
|
|
@@ -25,6 +25,43 @@ const LIST_MARKER_RE = /^[ \t]*(?:[-*+]|\d+\.)\s/
|
|
|
25
25
|
// A checkbox bullet — unambiguously a task, so it always starts its own block,
|
|
26
26
|
// at any indent. (A bare list marker is ambiguous; a checkbox never is.)
|
|
27
27
|
const CHECKBOX_RE = /^[ \t]*[-*+]\s*\[[ xX]\]/
|
|
28
|
+
// A bare list bullet — no checkbox — captured with its marker so a sub-bullet
|
|
29
|
+
// is re-rendered as the `-`/`*`/`1.` its author wrote.
|
|
30
|
+
const BULLET_RE = /^([ \t]*)([-*+]|\d+\.)\s+(.*)$/
|
|
31
|
+
|
|
32
|
+
function indentWidth(line) {
|
|
33
|
+
return line.length - line.trimStart().length
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Collect the wrapped continuation lines following the bullet opening at
|
|
37
|
+
// `start`, seeded with the opener's own text. Shared by task bullets and plain
|
|
38
|
+
// sub-bullets — both wrap the same way, and both stop at the first line that
|
|
39
|
+
// belongs to something else.
|
|
40
|
+
function collectContinuation(lines, start, hang, inFence, parts) {
|
|
41
|
+
let j = start + 1
|
|
42
|
+
for (; j < lines.length; j++) {
|
|
43
|
+
const l = lines[j]
|
|
44
|
+
if (!l.trim()) break
|
|
45
|
+
if (inFence[j]) break // a fence opening ends the bullet, never continues it
|
|
46
|
+
if (!CONTINUATION_RE.test(l)) break
|
|
47
|
+
if (BLOCK_BREAK_RE.test(l)) {
|
|
48
|
+
// Indent alone can't tell a nested child from a wrapped continuation —
|
|
49
|
+
// both sit at the hanging indent. The *marker* is the reliable signal:
|
|
50
|
+
// - a checkbox (- [ ] / - [x]) is unambiguously a task → always break;
|
|
51
|
+
// - a bare marker (-/*/+/N.) is wrapped continuation prose ONLY when it
|
|
52
|
+
// sits exactly at the hang; shallower or deeper it's a real sub/
|
|
53
|
+
// sibling list → break, and the caller's scan claims it as a block of
|
|
54
|
+
// its own. (Keeping the at-hang continuation preserves the task's
|
|
55
|
+
// stamped id, so the next push updates instead of creating a
|
|
56
|
+
// duplicate issue.)
|
|
57
|
+
// - headings, quotes, tables and fences always break.
|
|
58
|
+
if (CHECKBOX_RE.test(l)) break
|
|
59
|
+
if (!LIST_MARKER_RE.test(l) || indentWidth(l) !== hang) break
|
|
60
|
+
}
|
|
61
|
+
parts.push(l.trim())
|
|
62
|
+
}
|
|
63
|
+
return { end: j, parts }
|
|
64
|
+
}
|
|
28
65
|
|
|
29
66
|
// Mark every line that lies inside a fenced code block — the opening fence, its
|
|
30
67
|
// content, and the closing fence all count as `true`. Line scanners that hunt
|
|
@@ -107,51 +144,68 @@ function spanMask(body) {
|
|
|
107
144
|
}
|
|
108
145
|
|
|
109
146
|
/**
|
|
110
|
-
* Find every task bullet in `lines` as a logical block
|
|
111
|
-
*
|
|
147
|
+
* Find every task bullet in `lines` as a logical block, plus the non-checkbox
|
|
148
|
+
* bullets that live inside a task's list subtree.
|
|
149
|
+
* @returns {Array<{start:number, end:number, indent:string, marker:string,
|
|
150
|
+
* checkbox:boolean, mark:string|null, text:string}>}
|
|
112
151
|
* `end` is exclusive. `text` is the collapsed single-line form, id included.
|
|
152
|
+
* `checkbox` is false for a plain sub-bullet; its `mark` is then null and its
|
|
153
|
+
* `marker` is the bullet it was written with (`-`, `*`, `1.` …).
|
|
113
154
|
*/
|
|
114
155
|
function findTaskBlocks(lines) {
|
|
115
156
|
const blocks = []
|
|
116
157
|
const inFence = fenceMask(lines)
|
|
158
|
+
// The marker indents of the task bullets whose list subtree is still open,
|
|
159
|
+
// outermost first. Without this the scan had no model of nesting at all: a
|
|
160
|
+
// bare bullet that dedented out of a nested checkbox belonged to nothing and
|
|
161
|
+
// was silently dropped, taking its wrapped continuations with it.
|
|
162
|
+
const open = []
|
|
163
|
+
|
|
117
164
|
for (let i = 0; i < lines.length; i++) {
|
|
165
|
+
const line = lines[i]
|
|
166
|
+
// A blank line alone doesn't close a subtree — a loose list is still a list.
|
|
167
|
+
// What closes it is a later line at or shallower than the task's own indent.
|
|
168
|
+
if (!line.trim()) continue
|
|
169
|
+
const indent = indentWidth(line)
|
|
170
|
+
while (open.length && indent <= open[open.length - 1]) open.pop()
|
|
118
171
|
if (inFence[i]) continue // an example bullet inside a ``` block is not a task
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// sibling list → break. (Keeping the at-hang continuation preserves
|
|
138
|
-
// the task's stamped id, so the next push updates instead of
|
|
139
|
-
// creating a duplicate issue.)
|
|
140
|
-
// - headings, quotes, tables and fences always break.
|
|
141
|
-
if (CHECKBOX_RE.test(l)) break
|
|
142
|
-
const indent = l.length - l.trimStart().length
|
|
143
|
-
if (!LIST_MARKER_RE.test(l) || indent !== hang) break
|
|
144
|
-
}
|
|
145
|
-
parts.push(l.trim())
|
|
172
|
+
|
|
173
|
+
const t = TASK_START_RE.exec(line)
|
|
174
|
+
if (t) {
|
|
175
|
+
// The hanging indent: where the bullet's text starts (marker width). A
|
|
176
|
+
// wrapped continuation aligns exactly AT this column.
|
|
177
|
+
const { end, parts } = collectContinuation(lines, i, line.length - t[3].length, inFence, [t[3]])
|
|
178
|
+
blocks.push({
|
|
179
|
+
start: i,
|
|
180
|
+
end,
|
|
181
|
+
indent: t[1],
|
|
182
|
+
marker: '-',
|
|
183
|
+
checkbox: true,
|
|
184
|
+
mark: t[2].toLowerCase() === 'x' ? 'x' : ' ',
|
|
185
|
+
text: collapseHyphenAware(parts.join('\n')),
|
|
186
|
+
})
|
|
187
|
+
open.push(indent)
|
|
188
|
+
i = end - 1
|
|
189
|
+
continue
|
|
146
190
|
}
|
|
191
|
+
|
|
192
|
+
// A bare bullet is claimed ONLY inside an open task's subtree. Outside one
|
|
193
|
+
// it is ordinary prose in the phase file: the projection is the task list,
|
|
194
|
+
// not the whole body, and widening it here would mirror a Notes section.
|
|
195
|
+
if (!open.length) continue
|
|
196
|
+
const b = BULLET_RE.exec(line)
|
|
197
|
+
if (!b) continue
|
|
198
|
+
const { end, parts } = collectContinuation(lines, i, line.length - b[3].length, inFence, [b[3]])
|
|
147
199
|
blocks.push({
|
|
148
200
|
start: i,
|
|
149
|
-
end
|
|
150
|
-
indent:
|
|
151
|
-
|
|
201
|
+
end,
|
|
202
|
+
indent: b[1],
|
|
203
|
+
marker: b[2],
|
|
204
|
+
checkbox: false,
|
|
205
|
+
mark: null,
|
|
152
206
|
text: collapseHyphenAware(parts.join('\n')),
|
|
153
207
|
})
|
|
154
|
-
i =
|
|
208
|
+
i = end - 1
|
|
155
209
|
}
|
|
156
210
|
return blocks
|
|
157
211
|
}
|
|
@@ -128,6 +128,7 @@ function stampIssueId(snapshotDir, text, id) {
|
|
|
128
128
|
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
129
129
|
const width = inferWidth(lines)
|
|
130
130
|
for (const b of findTaskBlocks(lines)) {
|
|
131
|
+
if (!b.checkbox) continue // a plain sub-bullet is not a task — never stamp one
|
|
131
132
|
if (INLINE_ID_RE.test(b.text)) continue
|
|
132
133
|
if (b.text !== want) continue
|
|
133
134
|
const rendered = renderTaskBlock({ indent: b.indent, done: b.mark === 'x', text: want, id }, width)
|