@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
|
@@ -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)
|