@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.
@@ -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
- * @returns {Array<{start:number, end:number, indent:string, mark:string, text:string}>}
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
- const m = TASK_START_RE.exec(lines[i])
120
- if (!m) continue
121
- const parts = [m[3]]
122
- // The hanging indent: where the bullet's text starts (marker width). A
123
- // wrapped continuation aligns exactly AT this column.
124
- const hang = lines[i].length - m[3].length
125
- let j = i + 1
126
- for (; j < lines.length; j++) {
127
- const l = lines[j]
128
- if (!l.trim()) break
129
- if (inFence[j]) break // a fence opening ends the bullet, never continues it
130
- if (!CONTINUATION_RE.test(l)) break
131
- if (BLOCK_BREAK_RE.test(l)) {
132
- // Indent alone can't tell a nested child from a wrapped continuation —
133
- // both sit at the hanging indent. The *marker* is the reliable signal:
134
- // - a checkbox (- [ ] / - [x]) is unambiguously a task → always break;
135
- // - a bare marker (-/*/+/N.) is wrapped continuation prose ONLY when it
136
- // sits exactly at the hang; shallower or deeper it's a real sub/
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: j,
150
- indent: m[1],
151
- mark: m[2].toLowerCase() === 'x' ? 'x' : ' ',
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 = j - 1
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)