@skitterbyte/skitterspec-linear 8.0.4 → 8.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "8.0.4",
3
+ "version": "8.0.5",
4
4
  "description": "Spec-driven development for Claude Code, with one-way Linear sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Linear is a generated mirror. Install this OR the base, not both.",
5
5
  "keywords": [
6
6
  "claude",
@@ -16,7 +16,7 @@
16
16
 
17
17
  const fs = require('node:fs')
18
18
  const path = require('node:path')
19
- const { findTaskBlocks, collapse, collapseHyphenAware } = require('./task-block.js')
19
+ const { fenceMask, findTaskBlocks, collapse, collapseHyphenAware } = require('./task-block.js')
20
20
 
21
21
  // --- markdown / frontmatter parsing -----------------------------------------
22
22
 
@@ -57,6 +57,7 @@ function parseScalar(v) {
57
57
  // heading text → its content (until the next `## `). The H1 `# ` is the title.
58
58
  function parseSections(body) {
59
59
  const lines = body.split('\n')
60
+ const inFence = fenceMask(lines)
60
61
  let title = null
61
62
  const sections = {}
62
63
  let current = null
@@ -64,9 +65,12 @@ function parseSections(body) {
64
65
  const flush = () => {
65
66
  if (current !== null) sections[current] = buf.join('\n').trim()
66
67
  }
67
- for (const line of lines) {
68
- const h1 = /^#\s+(.*)$/.exec(line)
69
- const h2 = /^##\s+(.*)$/.exec(line)
68
+ for (let i = 0; i < lines.length; i++) {
69
+ const line = lines[i]
70
+ // A `#`/`##` inside a fenced code block is example text, not a real heading —
71
+ // treat it as body content so it can't start a phantom section.
72
+ const h1 = inFence[i] ? null : /^#\s+(.*)$/.exec(line)
73
+ const h2 = inFence[i] ? null : /^##\s+(.*)$/.exec(line)
70
74
  if (h1 && title === null) {
71
75
  title = h1[1].trim()
72
76
  continue
@@ -22,6 +22,38 @@ const BLOCK_BREAK_RE = /^[ \t]*(?:[-*+]\s|\d+\.\s|#{1,6}\s|>|\||```)/
22
22
  // A list-marker line (unordered or ordered). Distinguished from other block
23
23
  // breaks because a wrapped continuation can legitimately begin with one.
24
24
  const LIST_MARKER_RE = /^[ \t]*(?:[-*+]|\d+\.)\s/
25
+ // A checkbox bullet — unambiguously a task, so it always starts its own block,
26
+ // at any indent. (A bare list marker is ambiguous; a checkbox never is.)
27
+ const CHECKBOX_RE = /^[ \t]*[-*+]\s*\[[ xX]\]/
28
+
29
+ // Mark every line that lies inside a fenced code block — the opening fence, its
30
+ // content, and the closing fence all count as `true`. Line scanners that hunt
31
+ // for markdown structure (task bullets, section headings) use this to ignore an
32
+ // EXAMPLE of that structure shown inside a fence: a spec that documents a
33
+ // checklist format in a ``` block must not have those example bullets harvested
34
+ // as real tasks (and pushed as real tracker issues). Supports ``` and ~~~ fences
35
+ // of any length ≥ 3; a closing fence is a bare marker (no info string) of the
36
+ // same character and at least the opening length.
37
+ function fenceMask(lines) {
38
+ const mask = new Array(lines.length).fill(false)
39
+ let open = null // the opening marker string (``` or ~~~ …), or null
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const trimmed = lines[i].trim()
42
+ if (open) {
43
+ mask[i] = true
44
+ if (/^(`{3,}|~{3,})$/.test(trimmed) && trimmed[0] === open[0] && trimmed.length >= open.length) {
45
+ open = null
46
+ }
47
+ continue
48
+ }
49
+ const m = /^[ \t]*(`{3,}|~{3,})/.exec(lines[i])
50
+ if (m) {
51
+ open = m[1]
52
+ mask[i] = true
53
+ }
54
+ }
55
+ return mask
56
+ }
25
57
 
26
58
  // Collapse a wrapped bullet's lines into the single logical line the rest of the
27
59
  // sync engine (and Linear) works in.
@@ -81,27 +113,34 @@ function spanMask(body) {
81
113
  */
82
114
  function findTaskBlocks(lines) {
83
115
  const blocks = []
116
+ const inFence = fenceMask(lines)
84
117
  for (let i = 0; i < lines.length; i++) {
118
+ if (inFence[i]) continue // an example bullet inside a ``` block is not a task
85
119
  const m = TASK_START_RE.exec(lines[i])
86
120
  if (!m) continue
87
121
  const parts = [m[3]]
88
122
  // The hanging indent: where the bullet's text starts (marker width). A
89
- // continuation aligns AT this column; a genuine nested bullet is SHALLOWER.
123
+ // wrapped continuation aligns exactly AT this column.
90
124
  const hang = lines[i].length - m[3].length
91
125
  let j = i + 1
92
126
  for (; j < lines.length; j++) {
93
127
  const l = lines[j]
94
128
  if (!l.trim()) break
129
+ if (inFence[j]) break // a fence opening ends the bullet, never continues it
95
130
  if (!CONTINUATION_RE.test(l)) break
96
131
  if (BLOCK_BREAK_RE.test(l)) {
97
- // A list-marker line is a real nested/sibling bullet only when indented
98
- // shallower than the hanging indent. AT/after it, a line beginning with
99
- // -/*/+/N. is wrapped continuation text, not a new bullet keep it (else
100
- // the task is truncated and its stamped id no longer matches, which makes
101
- // the next push create a duplicate issue). Headings, quotes, tables and
102
- // fences always break.
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
103
142
  const indent = l.length - l.trimStart().length
104
- if (!LIST_MARKER_RE.test(l) || indent < hang) break
143
+ if (!LIST_MARKER_RE.test(l) || indent !== hang) break
105
144
  }
106
145
  parts.push(l.trim())
107
146
  }
@@ -192,6 +231,7 @@ function inferWidth(lines, fallback = DEFAULT_WIDTH) {
192
231
  }
193
232
 
194
233
  module.exports = {
234
+ fenceMask,
195
235
  findTaskBlocks,
196
236
  renderTaskBlock,
197
237
  wrapEmphasisAware,