@skitterbyte/skitterspec-linear 7.0.0 → 7.0.2
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/assets/rules/spec-planning.md +4 -0
- package/bin/skitterspec-linear.js +5 -0
- package/package.json +1 -1
- package/src/vendor/linear/cli-sanitise.js +101 -0
- package/src/vendor/sync-core/index.js +2 -0
- package/src/vendor/sync-core/src/normalize.js +119 -10
- package/src/vendor/sync-core/src/sanitise.js +147 -0
- package/src/vendor/sync-core/src/task-block.js +176 -0
- package/src/vendor/sync-core/src/write.js +39 -33
|
@@ -190,3 +190,7 @@ queried directly.)
|
|
|
190
190
|
- Never delete historical notes.
|
|
191
191
|
- The spec file is the single source of truth for implementation progress.
|
|
192
192
|
- Move specs between buckets with `git mv` to preserve history.
|
|
193
|
+
- Never let inline emphasis or a link cross a hard line break — keep a whole
|
|
194
|
+
`**bold**`, `*italic*`, or `[text](url)` on one line (let it overflow the wrap
|
|
195
|
+
column rather than splitting it). Many round-tripping editors mangle a
|
|
196
|
+
`**`/`*`/link span that straddles a newline, so clean source avoids the churn.
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
const { run } = require('../src/cli.js')
|
|
12
12
|
const { specSync } = require('../src/vendor/linear/cli-sync.js')
|
|
13
|
+
const { specSanitise } = require('../src/vendor/linear/cli-sanitise.js')
|
|
13
14
|
|
|
14
15
|
async function main(argv) {
|
|
15
16
|
const [cmd, ...rest] = argv
|
|
@@ -17,6 +18,10 @@ async function main(argv) {
|
|
|
17
18
|
await specSync(rest)
|
|
18
19
|
return
|
|
19
20
|
}
|
|
21
|
+
if (cmd === 'spec-sanitise') {
|
|
22
|
+
process.exitCode = await specSanitise(rest)
|
|
23
|
+
return
|
|
24
|
+
}
|
|
20
25
|
await run(argv)
|
|
21
26
|
}
|
|
22
27
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.2",
|
|
4
4
|
"description": "Spec-driven development for Claude Code, with Linear hybrid-sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus git-like /spec-status · /spec-pull · /spec-push and the spec-sync CLI. Install this OR the base, not both.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `spec-sanitise` — a one-time maintenance pass over spec markdown.
|
|
5
|
+
*
|
|
6
|
+
* Rewrites files so no inline emphasis (`**bold**`, `*italic*`) or link text
|
|
7
|
+
* straddles a hard line break, and repairs any Linear-mangled `****` artifacts
|
|
8
|
+
* already committed. Hand-wrapped specs acquire straddles over time; Linear
|
|
9
|
+
* corrupts them on save, so this brings the corpus "inline" to round-trip cleanly.
|
|
10
|
+
*
|
|
11
|
+
* Dry-run by default (reports the files it WOULD change); pass `--write` to apply.
|
|
12
|
+
* Minimal-diff and idempotent — only paragraphs/items that actually straddle are
|
|
13
|
+
* reflowed (see sync-core `sanitizeSpecMarkdown`).
|
|
14
|
+
*
|
|
15
|
+
* skitterspec-linear spec-sanitise [paths...] [--write] [--width N]
|
|
16
|
+
*
|
|
17
|
+
* Defaults to scanning `specs/`. Exit code 1 in dry-run when changes are pending
|
|
18
|
+
* (so it's CI-friendly), 0 once everything is clean.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs')
|
|
22
|
+
const path = require('node:path')
|
|
23
|
+
const { sanitizeSpecMarkdown } = require('../sync-core')
|
|
24
|
+
|
|
25
|
+
// Recursively collect *.md files under a path (a file path is returned as-is).
|
|
26
|
+
function collectMarkdown(target) {
|
|
27
|
+
const out = []
|
|
28
|
+
const walk = (p) => {
|
|
29
|
+
let st
|
|
30
|
+
try {
|
|
31
|
+
st = fs.statSync(p)
|
|
32
|
+
} catch {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
if (st.isDirectory()) {
|
|
36
|
+
if (/(^|\/)(node_modules|\.git)$/.test(p)) return
|
|
37
|
+
for (const name of fs.readdirSync(p).sort()) walk(path.join(p, name))
|
|
38
|
+
} else if (st.isFile() && p.endsWith('.md')) {
|
|
39
|
+
out.push(p)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
walk(target)
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const paths = []
|
|
48
|
+
let write = false
|
|
49
|
+
let width = null
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const a = argv[i]
|
|
52
|
+
if (a === '--write') write = true
|
|
53
|
+
else if (a === '--width') width = parseInt(argv[++i], 10)
|
|
54
|
+
else if (a === '--help' || a === '-h') return { help: true }
|
|
55
|
+
else paths.push(a)
|
|
56
|
+
}
|
|
57
|
+
if (!paths.length) paths.push('specs')
|
|
58
|
+
return { paths, write, width: Number.isFinite(width) ? width : null }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function specSanitise(argv, { cwd = process.cwd(), out = process.stdout } = {}) {
|
|
62
|
+
const opts = parseArgs(argv)
|
|
63
|
+
if (opts.help) {
|
|
64
|
+
out.write('Usage: skitterspec-linear spec-sanitise [paths...] [--write] [--width N]\n')
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const files = new Set()
|
|
69
|
+
for (const p of opts.paths) for (const f of collectMarkdown(path.resolve(cwd, p))) files.add(f)
|
|
70
|
+
|
|
71
|
+
let changedFiles = 0
|
|
72
|
+
let totalFixes = 0
|
|
73
|
+
for (const file of [...files].sort()) {
|
|
74
|
+
const src = fs.readFileSync(file, 'utf-8')
|
|
75
|
+
const res = sanitizeSpecMarkdown(src, opts.width ? { width: opts.width } : {})
|
|
76
|
+
if (!res.changed) continue
|
|
77
|
+
changedFiles++
|
|
78
|
+
totalFixes += res.fixes
|
|
79
|
+
const rel = path.relative(cwd, file)
|
|
80
|
+
if (opts.write) {
|
|
81
|
+
fs.writeFileSync(file, res.text, 'utf-8')
|
|
82
|
+
out.write(`fixed ${rel} (${res.fixes} span${res.fixes === 1 ? '' : 's'})\n`)
|
|
83
|
+
} else {
|
|
84
|
+
out.write(`would fix ${rel} (${res.fixes} span${res.fixes === 1 ? '' : 's'})\n`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!changedFiles) {
|
|
89
|
+
out.write(`spec-sanitise: clean — no straddling spans in ${files.size} file(s).\n`)
|
|
90
|
+
return 0
|
|
91
|
+
}
|
|
92
|
+
const verb = opts.write ? 'Fixed' : 'Would fix'
|
|
93
|
+
out.write(`\n${verb} ${totalFixes} span(s) across ${changedFiles} file(s).\n`)
|
|
94
|
+
if (!opts.write) {
|
|
95
|
+
out.write('Re-run with --write to apply. Review the diff before committing.\n')
|
|
96
|
+
return 1
|
|
97
|
+
}
|
|
98
|
+
return 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { specSanitise, collectMarkdown, parseArgs }
|
|
@@ -16,6 +16,7 @@ const { pull } = require('./src/pull.js')
|
|
|
16
16
|
const { push } = require('./src/push.js')
|
|
17
17
|
const { writeFrontmatter } = require('./src/write.js')
|
|
18
18
|
const { frontmatterPatchFor, localWorkflowState } = require('./src/apply.js')
|
|
19
|
+
const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
|
|
19
20
|
|
|
20
21
|
module.exports = {
|
|
21
22
|
normalizeLocal,
|
|
@@ -32,4 +33,5 @@ module.exports = {
|
|
|
32
33
|
writeFrontmatter,
|
|
33
34
|
frontmatterPatchFor,
|
|
34
35
|
localWorkflowState,
|
|
36
|
+
sanitizeSpecMarkdown,
|
|
35
37
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require('node:fs')
|
|
18
18
|
const path = require('node:path')
|
|
19
|
+
const { findTaskBlocks, collapse } = require('./task-block.js')
|
|
19
20
|
|
|
20
21
|
// --- markdown / frontmatter parsing -----------------------------------------
|
|
21
22
|
|
|
@@ -82,21 +83,123 @@ function parseSections(body) {
|
|
|
82
83
|
return { title, sections }
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
// Linear mangles inline emphasis whose markers straddle a hard line break: it
|
|
87
|
+
// terminates the run at end-of-line and restarts it at the next, so `**a\nb**`
|
|
88
|
+
// comes back as `**a****\n****b**`, `*a\nb*` as `*a**\n**b*`, and a link splits
|
|
89
|
+
// into two. Canonicalise BOTH representations — the clean straddle we author and
|
|
90
|
+
// the mangled form Linear returns — to the same single-line span, so an
|
|
91
|
+
// already-mangled remote stops reading as a spurious `remote-only` diff and the
|
|
92
|
+
// payload we push carries no straddle for Linear to mangle again. Idempotent: a
|
|
93
|
+
// joined span has no interior newline, so nothing re-fires. Repo files are never
|
|
94
|
+
// rewritten — this only shapes the normalized projection the compare/push see.
|
|
95
|
+
function joinEmphasisAcrossBreaks(text) {
|
|
96
|
+
let s = String(text)
|
|
97
|
+
// (1) Repair Linear's mangle artifacts first — an emphasis run terminated at
|
|
98
|
+
// end-of-line and restarted at the next. These are very specific asterisk runs
|
|
99
|
+
// flanking a break, so a targeted regex is safe:
|
|
100
|
+
// bold: `**X****\n****Y**` → the `****\n****` empty-bold artifact → a space.
|
|
101
|
+
s = s.replace(/\*{4}[ \t]*\n[ \t]*\*{4}/g, ' ')
|
|
102
|
+
// italic: `*X**\n**Y*` — the `**\n**` artifact sits INSIDE a single-`*` span;
|
|
103
|
+
// gate on the enclosing single `*` so a genuine pair of adjacent bolds at a
|
|
104
|
+
// line boundary (`a**\n**b`) is left alone.
|
|
105
|
+
s = s.replace(/(^|[^*\n])\*([^*\n]+)\*\*[ \t]*\n[ \t]*\*\*([^*\n]+)\*(?![*])/g, '$1*$2 $3*')
|
|
106
|
+
// link split across a break onto the same url → one link.
|
|
107
|
+
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)[ \t]*\n[ \t]*\[([^\]]+)\]\(\2\)/g, '[$1 $3]($2)')
|
|
108
|
+
// (2) Join the clean straddle we author — a newline that falls while an
|
|
109
|
+
// emphasis/link span is OPEN. A scanner (not a regex) so an opening `**` is
|
|
110
|
+
// never mis-paired with an unrelated closing `**` on the next line, and markers
|
|
111
|
+
// inside a `code span` are ignored (Linear only rejoins those, harmlessly).
|
|
112
|
+
return joinOpenSpans(s)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Walk the text tracking whether we're inside a `**bold**`, `*italic*`, `[link
|
|
116
|
+
// text]`/`(url)`, or `` `code` `` span. A newline encountered while a
|
|
117
|
+
// non-code span is open joins the two lines with a single space (dropping the
|
|
118
|
+
// next line's indentation); every other newline is preserved.
|
|
119
|
+
function joinOpenSpans(text) {
|
|
120
|
+
const out = []
|
|
121
|
+
let bold = false
|
|
122
|
+
let italic = false
|
|
123
|
+
let code = false
|
|
124
|
+
let link = 0 // 0 none · 1 in link text · 2 in url
|
|
125
|
+
for (let i = 0; i < text.length; i++) {
|
|
126
|
+
const c = text[i]
|
|
127
|
+
if (c === '\n') {
|
|
128
|
+
if (!code && (bold || italic || link)) {
|
|
129
|
+
out.push(' ')
|
|
130
|
+
while (i + 1 < text.length && (text[i + 1] === ' ' || text[i + 1] === '\t')) i++
|
|
131
|
+
} else {
|
|
132
|
+
out.push('\n')
|
|
133
|
+
}
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
if (c === '`') {
|
|
137
|
+
code = !code
|
|
138
|
+
out.push(c)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (code) {
|
|
142
|
+
out.push(c)
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (c === '*' && text[i + 1] === '*') {
|
|
146
|
+
bold = !bold
|
|
147
|
+
out.push('**')
|
|
148
|
+
i++
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
if (c === '*') {
|
|
152
|
+
// Basic flanking so a stray `*` (e.g. `2 * 3`) doesn't open a phantom span:
|
|
153
|
+
// an opener needs a non-space to its right, a closer a non-space to its left.
|
|
154
|
+
const ok = italic ? !/\s/.test(text[i - 1] || '') : !/\s/.test(text[i + 1] || '')
|
|
155
|
+
if (ok) italic = !italic
|
|
156
|
+
out.push(c)
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
if (c === '[' && link === 0) {
|
|
160
|
+
link = 1
|
|
161
|
+
out.push(c)
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
if (c === ']' && link === 1) {
|
|
165
|
+
if (text[i + 1] === '(') {
|
|
166
|
+
link = 2
|
|
167
|
+
out.push('](')
|
|
168
|
+
i++
|
|
169
|
+
} else {
|
|
170
|
+
link = 0
|
|
171
|
+
out.push(c)
|
|
172
|
+
}
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
if (c === ')' && link === 2) {
|
|
176
|
+
link = 0
|
|
177
|
+
out.push(c)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
out.push(c)
|
|
181
|
+
}
|
|
182
|
+
return out.join('')
|
|
183
|
+
}
|
|
184
|
+
|
|
85
185
|
// Canonicalise markdown so semantically-equal content hashes equal across the
|
|
86
186
|
// boundary. Linear reserializes markdown on save (authored `-` bullets come back
|
|
87
|
-
// as `*`, trailing whitespace trimmed, blank runs collapsed
|
|
88
|
-
// clean push→pull would report
|
|
89
|
-
//
|
|
90
|
-
//
|
|
187
|
+
// as `*`, trailing whitespace trimmed, blank runs collapsed, emphasis spanning a
|
|
188
|
+
// line break mangled), so without this a clean push→pull would report
|
|
189
|
+
// `description` as perpetually changed. Applied to the description on BOTH sides.
|
|
190
|
+
// Conservative: only unifies list markers, whitespace, and emphasis-across-a-break
|
|
191
|
+
// — the transforms actually observed from Linear.
|
|
91
192
|
function canonicalizeMarkdown(text) {
|
|
92
193
|
if (text == null) return text
|
|
93
|
-
|
|
194
|
+
const marked = String(text)
|
|
94
195
|
.replace(/\r\n/g, '\n')
|
|
95
196
|
.split('\n')
|
|
96
197
|
// Unordered-list marker at line start (`*`/`+`/`-`) → `-`. Requires a space
|
|
97
|
-
// after the marker so bold/emphasis (`**Goal:**`) is untouched.
|
|
198
|
+
// after the marker so bold/emphasis (`**Goal:**`) is untouched. Done BEFORE
|
|
199
|
+
// the emphasis join so a `*` list bullet can't spoof an italic delimiter.
|
|
98
200
|
.map((line) => line.replace(/^(\s*)[*+-]( +)/, '$1-$2').replace(/[ \t]+$/, ''))
|
|
99
201
|
.join('\n')
|
|
202
|
+
return joinEmphasisAcrossBreaks(marked)
|
|
100
203
|
.replace(/\n{3,}/g, '\n\n')
|
|
101
204
|
.trim()
|
|
102
205
|
}
|
|
@@ -165,10 +268,15 @@ function readPhaseFiles(snapshotDir) {
|
|
|
165
268
|
.map((file) => {
|
|
166
269
|
const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
|
|
167
270
|
const { data, body } = parseFrontmatter(raw)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
271
|
+
// No /m on either: under /m, `$` matches end-of-LINE, so a non-greedy
|
|
272
|
+
// scan stops at the first newline and a hand-wrapped bullet or goal loses
|
|
273
|
+
// every continuation line. Tasks come from findTaskBlocks, which reads a
|
|
274
|
+
// wrapped bullet as one logical task.
|
|
275
|
+
// Collapsed, not just captured: the goal becomes a milestone description,
|
|
276
|
+
// and Linear may canonicalize a soft line break away on save. Collapsing
|
|
277
|
+
// both sides keeps a wrapped goal from diffing forever.
|
|
278
|
+
const goal = collapse((/\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body) || [])[1] || '')
|
|
279
|
+
const tasks = findTaskBlocks(body.split('\n')).map((b) => `[${b.mark}] ${b.text}`)
|
|
172
280
|
return {
|
|
173
281
|
phase: file.replace(/\.md$/, ''),
|
|
174
282
|
file,
|
|
@@ -372,5 +480,6 @@ module.exports = {
|
|
|
372
480
|
parseTaskLine,
|
|
373
481
|
canonicalRemoteStatus,
|
|
374
482
|
canonicalizeMarkdown,
|
|
483
|
+
joinEmphasisAcrossBreaks,
|
|
375
484
|
bucketForState,
|
|
376
485
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One-time sanitiser: rewrite spec markdown so no inline emphasis or link span
|
|
5
|
+
* straddles a hard line break, and repair any Linear-mangled `****` artifacts
|
|
6
|
+
* already committed. This brings hand-wrapped spec files "inline" so they round-
|
|
7
|
+
* trip through Linear cleanly (Linear mangles a straddling `**`/`*`/link on save).
|
|
8
|
+
*
|
|
9
|
+
* Deliberately minimal-diff: only a block that actually contains a straddle or a
|
|
10
|
+
* mangle is reflowed — every other paragraph, table, code fence, and heading is
|
|
11
|
+
* left byte-for-byte untouched. A reflowed block is re-wrapped emphasis-aware to
|
|
12
|
+
* the file's own inferred width, so spans stay whole. Idempotent: a second run is
|
|
13
|
+
* a no-op.
|
|
14
|
+
*
|
|
15
|
+
* Pure string→string (`sanitizeSpecMarkdown`); the CLI wraps it with fs walking.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
wrapEmphasisAware,
|
|
20
|
+
collapse,
|
|
21
|
+
inferWidth,
|
|
22
|
+
DEFAULT_WIDTH,
|
|
23
|
+
} = require('./task-block.js')
|
|
24
|
+
const { joinEmphasisAcrossBreaks } = require('./normalize.js')
|
|
25
|
+
|
|
26
|
+
// A run of non-blank lines contains an emphasis straddle / mangle iff joining
|
|
27
|
+
// spans across breaks changes it.
|
|
28
|
+
function hasStraddle(text) {
|
|
29
|
+
return joinEmphasisAcrossBreaks(text) !== text
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Turn a multi-line region into its clean single logical line: repair mangles and
|
|
33
|
+
// join straddles first (needs the newlines), then collapse whitespace.
|
|
34
|
+
function cleanLogicalLine(text) {
|
|
35
|
+
return collapse(joinEmphasisAcrossBreaks(text))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A bullet line: indent, marker (`- `, `* `, `+ `, `1. `, optionally a `[ ]`/`[x]`
|
|
39
|
+
// checkbox), then the text.
|
|
40
|
+
const BULLET_RE = /^(\s*)((?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?)(.*)$/
|
|
41
|
+
// Structural blocks we never reflow.
|
|
42
|
+
const PASSTHROUGH_RE = /^\s*(#{1,6}\s|>|\||[-*_]{3,}\s*$|<)/
|
|
43
|
+
// A GFM table separator row (only pipes/colons/dashes/spaces, with a pipe AND a
|
|
44
|
+
// dash). Detecting a table by this — not by any stray `|` — so a pipe inside an
|
|
45
|
+
// inline `code|span` doesn't make us treat a whole list as an untouchable table.
|
|
46
|
+
const TABLE_SEP_RE = /^[\s|:-]*\|[\s|:-]*-[\s|:-]*$|^[\s|:-]*-[\s|:-]*\|[\s|:-]*$/
|
|
47
|
+
|
|
48
|
+
// Split a block (consecutive non-blank lines) into items when it's a list; each
|
|
49
|
+
// item is its bullet line plus more-indented continuation lines.
|
|
50
|
+
function splitListItems(block) {
|
|
51
|
+
const items = []
|
|
52
|
+
let cur = null
|
|
53
|
+
for (const line of block) {
|
|
54
|
+
if (BULLET_RE.test(line)) {
|
|
55
|
+
cur = [line]
|
|
56
|
+
items.push(cur)
|
|
57
|
+
} else if (cur) {
|
|
58
|
+
cur.push(line) // continuation of the current bullet
|
|
59
|
+
} else {
|
|
60
|
+
return null // leading non-bullet line — not a clean list block
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return items
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Reflow one bullet item (raw lines) to width, emphasis-aware. Returns the item's
|
|
67
|
+
// lines unchanged when it carries no straddle.
|
|
68
|
+
function sanitizeItem(itemLines, width) {
|
|
69
|
+
const raw = itemLines.join('\n')
|
|
70
|
+
if (!hasStraddle(raw)) return { lines: itemLines, fixed: false }
|
|
71
|
+
const m = BULLET_RE.exec(itemLines[0])
|
|
72
|
+
const indent = m[1]
|
|
73
|
+
const marker = m[2]
|
|
74
|
+
const firstPrefix = indent + marker
|
|
75
|
+
const hang = ' '.repeat(firstPrefix.length)
|
|
76
|
+
// Body = the item's text (first line after the marker + continuations).
|
|
77
|
+
const bodyRaw = [m[3], ...itemLines.slice(1)].join('\n')
|
|
78
|
+
const body = cleanLogicalLine(bodyRaw)
|
|
79
|
+
return { lines: wrapEmphasisAware(body, { firstPrefix, hang, width }), fixed: true }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Reflow a plain prose paragraph (raw lines) to width, preserving its leading
|
|
83
|
+
// indent. Unchanged when it carries no straddle.
|
|
84
|
+
function sanitizeProse(block, width) {
|
|
85
|
+
const raw = block.join('\n')
|
|
86
|
+
if (!hasStraddle(raw)) return { lines: block, fixed: false }
|
|
87
|
+
const indent = (/^(\s*)/.exec(block[0]) || ['', ''])[1]
|
|
88
|
+
const body = cleanLogicalLine(raw)
|
|
89
|
+
return { lines: wrapEmphasisAware(body, { firstPrefix: indent, hang: indent, width }), fixed: true }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Sanitise one markdown document.
|
|
94
|
+
* @returns {{ text:string, changed:boolean, fixes:number }} fixes = blocks/items reflowed.
|
|
95
|
+
*/
|
|
96
|
+
function sanitizeSpecMarkdown(text, { width } = {}) {
|
|
97
|
+
const src = String(text)
|
|
98
|
+
const lines = src.split('\n')
|
|
99
|
+
const w = width || inferWidth(lines) || DEFAULT_WIDTH
|
|
100
|
+
const out = []
|
|
101
|
+
let i = 0
|
|
102
|
+
let fixes = 0
|
|
103
|
+
while (i < lines.length) {
|
|
104
|
+
const line = lines[i]
|
|
105
|
+
// Fenced code — copy verbatim through the closing fence.
|
|
106
|
+
if (/^[ \t]*```/.test(line)) {
|
|
107
|
+
out.push(line)
|
|
108
|
+
i++
|
|
109
|
+
while (i < lines.length && !/^[ \t]*```/.test(lines[i])) out.push(lines[i++])
|
|
110
|
+
if (i < lines.length) out.push(lines[i++])
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (!line.trim()) {
|
|
114
|
+
out.push(line)
|
|
115
|
+
i++
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
// Gather a block of consecutive non-blank, non-fence lines.
|
|
119
|
+
let j = i
|
|
120
|
+
while (j < lines.length && lines[j].trim() && !/^[ \t]*```/.test(lines[j])) j++
|
|
121
|
+
const block = lines.slice(i, j)
|
|
122
|
+
i = j
|
|
123
|
+
|
|
124
|
+
// Never reflow structural blocks (headings, tables, quotes, rules, HTML).
|
|
125
|
+
if (PASSTHROUGH_RE.test(block[0]) || block.some((l) => TABLE_SEP_RE.test(l))) {
|
|
126
|
+
out.push(...block)
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const items = BULLET_RE.test(block[0]) ? splitListItems(block) : null
|
|
131
|
+
if (items) {
|
|
132
|
+
for (const it of items) {
|
|
133
|
+
const r = sanitizeItem(it, w)
|
|
134
|
+
out.push(...r.lines)
|
|
135
|
+
if (r.fixed) fixes++
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
const r = sanitizeProse(block, w)
|
|
139
|
+
out.push(...r.lines)
|
|
140
|
+
if (r.fixed) fixes++
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const result = out.join('\n')
|
|
144
|
+
return { text: result, changed: result !== src, fixes }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { sanitizeSpecMarkdown, hasStraddle }
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Task bullets in a phase file are hand-wrapped prose, not single lines:
|
|
4
|
+
//
|
|
5
|
+
// - [x] Add `DbProcessEventOutbox` to `prisma/schema.prisma`, modelled on
|
|
6
|
+
// `DbNotificationOutbox`: status, attempts, `nextAttemptAt`, …
|
|
7
|
+
//
|
|
8
|
+
// A Linear issue title is single-line, so the two representations differ by
|
|
9
|
+
// wrapping alone. This module is the one place that converts between them:
|
|
10
|
+
// `findTaskBlocks` reads wrapped bullets into logical tasks, `renderTaskBlock`
|
|
11
|
+
// writes a logical task back out re-wrapped in the file's own style.
|
|
12
|
+
//
|
|
13
|
+
// Everything here is line-index based so callers can splice whole blocks.
|
|
14
|
+
|
|
15
|
+
const DEFAULT_WIDTH = 80
|
|
16
|
+
|
|
17
|
+
// Start of a task bullet. The continuation lines that follow are any indented,
|
|
18
|
+
// non-empty lines that are not themselves a bullet or heading.
|
|
19
|
+
const TASK_START_RE = /^([ \t]*)-\s*\[([ xX])\]\s*(.*)$/
|
|
20
|
+
const CONTINUATION_RE = /^[ \t]+\S/
|
|
21
|
+
const BLOCK_BREAK_RE = /^[ \t]*(?:[-*+]\s|\d+\.\s|#{1,6}\s|>|\||```)/
|
|
22
|
+
|
|
23
|
+
// Collapse a wrapped bullet's lines into the single logical line the rest of the
|
|
24
|
+
// sync engine (and Linear) works in.
|
|
25
|
+
function collapse(text) {
|
|
26
|
+
return String(text).replace(/\s+/g, ' ').trim()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Mark every character of `body` that lies inside an inline emphasis or link
|
|
30
|
+
// span — `**bold**`, `*italic*`, `[text](url)` — so wrapping can avoid breaking
|
|
31
|
+
// one across a line (Linear mangles a straddling `**`/`*`/link on save). Code
|
|
32
|
+
// spans are exempt (Linear only rejoins them, harmlessly) but their contents are
|
|
33
|
+
// neutralised first so a `*` inside code can't spoof an italic marker.
|
|
34
|
+
function spanMask(body) {
|
|
35
|
+
const mask = new Array(body.length).fill(false)
|
|
36
|
+
// Neutralise code-span contents to same-length filler (indices stay aligned).
|
|
37
|
+
const chars = body.split('')
|
|
38
|
+
let m
|
|
39
|
+
const codeRe = /`[^`]*`/g
|
|
40
|
+
while ((m = codeRe.exec(body)) !== null) {
|
|
41
|
+
for (let i = m.index; i < m.index + m[0].length; i++) chars[i] = 'x'
|
|
42
|
+
}
|
|
43
|
+
let masked = chars.join('')
|
|
44
|
+
const cover = (re) => {
|
|
45
|
+
re.lastIndex = 0
|
|
46
|
+
let mm
|
|
47
|
+
while ((mm = re.exec(masked)) !== null) {
|
|
48
|
+
for (let i = mm.index; i < mm.index + mm[0].length; i++) mask[i] = true
|
|
49
|
+
if (mm[0].length === 0) re.lastIndex++
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
cover(/\*\*.+?\*\*/g) // bold
|
|
53
|
+
cover(/\[[^\]]*\]\([^)]*\)/g) // link
|
|
54
|
+
// Neutralise the bold/link spans already found, so their asterisks aren't
|
|
55
|
+
// reused when scanning for single-`*` italics.
|
|
56
|
+
const m2 = masked.split('')
|
|
57
|
+
for (let i = 0; i < mask.length; i++) if (mask[i]) m2[i] = 'x'
|
|
58
|
+
masked = m2.join('')
|
|
59
|
+
cover(/\*[^*]+?\*/g) // italic
|
|
60
|
+
return mask
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Find every task bullet in `lines` as a logical block.
|
|
65
|
+
* @returns {Array<{start:number, end:number, indent:string, mark:string, text:string}>}
|
|
66
|
+
* `end` is exclusive. `text` is the collapsed single-line form, id included.
|
|
67
|
+
*/
|
|
68
|
+
function findTaskBlocks(lines) {
|
|
69
|
+
const blocks = []
|
|
70
|
+
for (let i = 0; i < lines.length; i++) {
|
|
71
|
+
const m = TASK_START_RE.exec(lines[i])
|
|
72
|
+
if (!m) continue
|
|
73
|
+
const parts = [m[3]]
|
|
74
|
+
let j = i + 1
|
|
75
|
+
for (; j < lines.length; j++) {
|
|
76
|
+
const l = lines[j]
|
|
77
|
+
if (!l.trim()) break
|
|
78
|
+
if (!CONTINUATION_RE.test(l)) break
|
|
79
|
+
if (BLOCK_BREAK_RE.test(l)) break
|
|
80
|
+
parts.push(l.trim())
|
|
81
|
+
}
|
|
82
|
+
blocks.push({
|
|
83
|
+
start: i,
|
|
84
|
+
end: j,
|
|
85
|
+
indent: m[1],
|
|
86
|
+
mark: m[2].toLowerCase() === 'x' ? 'x' : ' ',
|
|
87
|
+
text: collapse(parts.join(' ')),
|
|
88
|
+
})
|
|
89
|
+
i = j - 1
|
|
90
|
+
}
|
|
91
|
+
return blocks
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Render a logical task back into wrapped file lines, matching the surrounding
|
|
96
|
+
* style: `- [x] ` opener, continuations aligned under the text.
|
|
97
|
+
* @returns {string[]}
|
|
98
|
+
*/
|
|
99
|
+
// Wrap `body` (a single logical line) into file lines, emphasis-aware: a break is
|
|
100
|
+
// only taken at a word gap that lies OUTSIDE every `**`/`*`/link span, so an
|
|
101
|
+
// emphasis run never straddles a line (Linear mangles one that does). The first
|
|
102
|
+
// line is prefixed with `firstPrefix`, continuations with `hang`. An over-width
|
|
103
|
+
// single span overflows rather than being split.
|
|
104
|
+
function wrapEmphasisAware(body, { firstPrefix = '', hang = '', width = DEFAULT_WIDTH } = {}) {
|
|
105
|
+
const mask = spanMask(body)
|
|
106
|
+
const words = body.split(' ')
|
|
107
|
+
// The index in `body` of the space that precedes each word.
|
|
108
|
+
const spaceBefore = []
|
|
109
|
+
let pos = 0
|
|
110
|
+
for (let k = 0; k < words.length; k++) {
|
|
111
|
+
if (k > 0) {
|
|
112
|
+
spaceBefore[k] = pos
|
|
113
|
+
pos += 1
|
|
114
|
+
}
|
|
115
|
+
pos += words[k].length
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const out = []
|
|
119
|
+
let line = firstPrefix
|
|
120
|
+
let first = true
|
|
121
|
+
for (let k = 0; k < words.length; k++) {
|
|
122
|
+
const word = words[k]
|
|
123
|
+
// Break only at a safe gap; a gap inside a span overflows instead of
|
|
124
|
+
// splitting it (so an over-width single span stays whole on one line).
|
|
125
|
+
const canBreak = k > 0 && !mask[spaceBefore[k]]
|
|
126
|
+
if (!first && canBreak && line.length + 1 + word.length > width) {
|
|
127
|
+
out.push(line)
|
|
128
|
+
line = hang + word
|
|
129
|
+
} else {
|
|
130
|
+
line += (first ? '' : ' ') + word
|
|
131
|
+
first = false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
out.push(line)
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH) {
|
|
139
|
+
const opener = `${indent}- [${done ? 'x' : ' '}] `
|
|
140
|
+
const hang = ' '.repeat(opener.length)
|
|
141
|
+
const body = collapse(text) + (id ? ` (${id})` : '')
|
|
142
|
+
return wrapEmphasisAware(body, { firstPrefix: opener, hang, width })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Infer the wrap width a file already uses, so a rewrite doesn't reflow it to a
|
|
146
|
+
// different column. Only *prose* lines count — a single wide table row or a long
|
|
147
|
+
// line of fenced code would otherwise pull the whole file's prose to a wider
|
|
148
|
+
// column than the author wrapped at. Falls back to the default when there's
|
|
149
|
+
// nothing prose-like to learn from.
|
|
150
|
+
function inferWidth(lines, fallback = DEFAULT_WIDTH) {
|
|
151
|
+
let inFence = false
|
|
152
|
+
const widths = []
|
|
153
|
+
for (const l of lines) {
|
|
154
|
+
if (/^[ \t]*```/.test(l)) {
|
|
155
|
+
inFence = !inFence
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (inFence) continue
|
|
159
|
+
if (!l.trim()) continue
|
|
160
|
+
if (l.includes('|')) continue // table row
|
|
161
|
+
widths.push(l.length)
|
|
162
|
+
}
|
|
163
|
+
if (!widths.length) return fallback
|
|
164
|
+
const max = Math.max(...widths)
|
|
165
|
+
return max > 40 && max <= 120 ? Math.max(max, 60) : fallback
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
findTaskBlocks,
|
|
170
|
+
renderTaskBlock,
|
|
171
|
+
wrapEmphasisAware,
|
|
172
|
+
spanMask,
|
|
173
|
+
collapse,
|
|
174
|
+
inferWidth,
|
|
175
|
+
DEFAULT_WIDTH,
|
|
176
|
+
}
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
|
|
19
19
|
const fs = require('node:fs')
|
|
20
20
|
const path = require('node:path')
|
|
21
|
+
const {
|
|
22
|
+
findTaskBlocks,
|
|
23
|
+
renderTaskBlock,
|
|
24
|
+
collapse,
|
|
25
|
+
inferWidth,
|
|
26
|
+
} = require('./task-block.js')
|
|
21
27
|
|
|
22
28
|
// Serialize a JS value as a YAML-ish frontmatter scalar. null/undefined → the
|
|
23
29
|
// key is dropped (caller shouldn't pass those).
|
|
@@ -222,33 +228,31 @@ function applyMilestonesPull(snapshotDir, items) {
|
|
|
222
228
|
|
|
223
229
|
// --- task-line denormalizer (keyed issue pull) -----------------------------
|
|
224
230
|
//
|
|
225
|
-
// Tasks live as checkbox
|
|
226
|
-
//
|
|
227
|
-
// new
|
|
231
|
+
// Tasks live as (hand-wrapped) checkbox bullets inside phase files. A pulled
|
|
232
|
+
// issue edit rewrites the matching bullet — whole block, re-wrapped — by its
|
|
233
|
+
// inline id; a Linear-only issue appends a new bullet; a created issue's id is
|
|
234
|
+
// stamped inline. Removals report-only. See task-block.js for the wrapping.
|
|
228
235
|
|
|
229
|
-
const TASK_RE = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/
|
|
230
236
|
const INLINE_ID_RE = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/
|
|
231
237
|
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
// Update the task line carrying inline id `id` (text + checkbox), in place.
|
|
238
|
+
// Update the task carrying inline id `id` (text + checkbox), in place.
|
|
239
|
+
//
|
|
240
|
+
// Block-aware: a task bullet is hand-wrapped across several lines, so the whole
|
|
241
|
+
// block is replaced and the new text re-wrapped in the file's own style. Editing
|
|
242
|
+
// only the first line would strand its continuation lines as orphaned prose.
|
|
238
243
|
function updateTaskLine(snapshotDir, id, { text, done }) {
|
|
239
244
|
const want = String(id)
|
|
240
245
|
for (const file of listPhaseFiles(snapshotDir)) {
|
|
241
246
|
const p = path.join(snapshotDir, file)
|
|
242
247
|
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
248
|
+
const width = inferWidth(lines)
|
|
249
|
+
for (const b of findTaskBlocks(lines)) {
|
|
250
|
+
const idm = INLINE_ID_RE.exec(b.text)
|
|
251
|
+
if (!idm || idm[1] !== want) continue
|
|
252
|
+
const rendered = renderTaskBlock({ indent: b.indent, done, text, id: want }, width)
|
|
253
|
+
lines.splice(b.start, b.end - b.start, ...rendered)
|
|
254
|
+
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
255
|
+
return true
|
|
252
256
|
}
|
|
253
257
|
}
|
|
254
258
|
return false
|
|
@@ -262,11 +266,10 @@ function addTaskLine(snapshotDir, item) {
|
|
|
262
266
|
if (!file) return null
|
|
263
267
|
const p = path.join(snapshotDir, file)
|
|
264
268
|
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
else lines.push(line)
|
|
269
|
+
const blocks = findTaskBlocks(lines)
|
|
270
|
+
const rendered = renderTaskBlock({ indent: '', ...item }, inferWidth(lines))
|
|
271
|
+
if (blocks.length) lines.splice(blocks[blocks.length - 1].end, 0, ...rendered)
|
|
272
|
+
else lines.push(...rendered)
|
|
270
273
|
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
271
274
|
return file
|
|
272
275
|
}
|
|
@@ -274,18 +277,21 @@ function addTaskLine(snapshotDir, item) {
|
|
|
274
277
|
// Stamp an inline id onto the (idless) task line whose text matches — used after
|
|
275
278
|
// the skill creates an issue for a new local task.
|
|
276
279
|
function stampIssueId(snapshotDir, text, id) {
|
|
277
|
-
const want =
|
|
280
|
+
const want = collapse(text)
|
|
278
281
|
for (const file of listPhaseFiles(snapshotDir)) {
|
|
279
282
|
const p = path.join(snapshotDir, file)
|
|
280
283
|
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
284
|
+
const width = inferWidth(lines)
|
|
285
|
+
for (const b of findTaskBlocks(lines)) {
|
|
286
|
+
if (INLINE_ID_RE.test(b.text)) continue
|
|
287
|
+
if (b.text !== want) continue
|
|
288
|
+
const rendered = renderTaskBlock(
|
|
289
|
+
{ indent: b.indent, done: b.mark === 'x', text: want, id },
|
|
290
|
+
width,
|
|
291
|
+
)
|
|
292
|
+
lines.splice(b.start, b.end - b.start, ...rendered)
|
|
293
|
+
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
294
|
+
return file
|
|
289
295
|
}
|
|
290
296
|
}
|
|
291
297
|
return null
|