@skitterbyte/skitterspec-linear 10.0.1 → 10.1.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/README.md +16 -0
- package/assets/core/linear.config.md +68 -0
- package/assets/skills/spec-go/SKILL.md +10 -3
- package/assets/skills/spec-push/SKILL.md +36 -0
- package/package.json +1 -1
- package/src/vendor/linear/cli-sync.js +88 -1
- package/src/vendor/linear/config.js +18 -0
- package/src/vendor/sync-core/index.js +8 -1
- package/src/vendor/sync-core/src/normalize.js +75 -13
- package/src/vendor/sync-core/src/push.js +9 -1
- package/src/vendor/sync-core/src/tables.js +102 -0
- package/src/vendor/sync-core/src/verify.js +83 -0
package/README.md
CHANGED
|
@@ -114,6 +114,22 @@ never clobbered. A workflow-state a teammate moves in Linear is surfaced by
|
|
|
114
114
|
(`specs/.core/linear-base/`, content hashes) are committed so push sends only what
|
|
115
115
|
changed.
|
|
116
116
|
|
|
117
|
+
**Fidelity safeguards.** Linear's markdown parser silently drops characters from
|
|
118
|
+
tables nested inside list items, so a nested table is reshaped before sending —
|
|
119
|
+
2-column tables become a bullet list, others a code block — and column-0 tables
|
|
120
|
+
are left alone. Your spec files are never modified. After a push, each stored
|
|
121
|
+
description is read back and compared against what was sent, ignoring Linear's
|
|
122
|
+
own reformatting and reporting anything genuinely lost. Both are automatic.
|
|
123
|
+
|
|
124
|
+
**Adopting on a long backlog.** By default a spec costs one `save_issue` call
|
|
125
|
+
plus one per phase, so mirroring a backlog that already runs to dozens of specs
|
|
126
|
+
front-loads hundreds of calls for work nobody has started. Set
|
|
127
|
+
`mapping.phases: "deferred"` and a spec sitting in `specs/backlog/` mirrors as
|
|
128
|
+
**the issue alone**, keeping its phase list in the description; its sub-issues are
|
|
129
|
+
created by the push that follows `/spec-go`, when the work actually starts. Phases
|
|
130
|
+
that are already linked keep syncing either way, so switching an existing project
|
|
131
|
+
over never strands a live sub-issue. See `linear.config.md` for the details.
|
|
132
|
+
|
|
117
133
|
**Which Project a spec lands in** is asked once, when the issue is first created
|
|
118
134
|
— a filterable list of your team's projects, defaulting to `linear.projectId` and
|
|
119
135
|
always offering *None*. It's passed on the create call only and never stored, so
|
|
@@ -47,6 +47,10 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
|
|
|
47
47
|
// "none" leaves the description as the phase's Goal line alone. Either way no
|
|
48
48
|
// issue is created per task. These are the defaults.
|
|
49
49
|
//
|
|
50
|
+
// `phases` selects WHEN a phase becomes a sub-issue: "subissue" from the
|
|
51
|
+
// spec's first push (default), or "deferred" only once the work starts — see
|
|
52
|
+
// "Deferring sub-issues until a spec starts" below.
|
|
53
|
+
//
|
|
50
54
|
// Under "checklist" the mirror keeps the phase file's OWN section headings: a
|
|
51
55
|
// phase with `## Tasks` and `## Acceptance` arrives as two headed sections, in
|
|
52
56
|
// source order, each heading reproduced as written. Checkboxes written before
|
|
@@ -145,6 +149,70 @@ Unlinked local items (a spec with no `linear_identifier`, a phase with no
|
|
|
145
149
|
`linear_issue_id`) are created in Linear on the next `/spec-push`, which stamps
|
|
146
150
|
the new id back so they link from then on.
|
|
147
151
|
|
|
152
|
+
## What is reshaped before sending, and what is checked afterwards
|
|
153
|
+
|
|
154
|
+
Two safeguards sit either side of the push. Neither is configurable — both exist
|
|
155
|
+
because Linear's markdown parser does not always store what it is given.
|
|
156
|
+
|
|
157
|
+
**Nested tables are reshaped.** A markdown table written *inside* a list item is
|
|
158
|
+
corrupted by Linear: every data cell loses its first N characters, N being the
|
|
159
|
+
list-content indent Linear renders at (3 per ordered level, 2 per bullet),
|
|
160
|
+
whatever indent the source used. The header row survives, which makes it easy to
|
|
161
|
+
miss. Measured, not inferred — a table at source indent 3, 4 or 6 inside a
|
|
162
|
+
numbered list loses exactly 3 characters per cell. So before sending, a nested
|
|
163
|
+
table is re-emitted as a **bullet list** (2 columns) or a **fenced code block**
|
|
164
|
+
(any other count), both of which round-trip byte-identically. Column-0 tables —
|
|
165
|
+
the `## Phases` index, every Impact map — are never touched, and neither are
|
|
166
|
+
tables inside a fenced example. **Your spec files are not modified**: the source
|
|
167
|
+
markdown is valid and renders correctly in GitHub and every editor, so this
|
|
168
|
+
shapes only the projection.
|
|
169
|
+
|
|
170
|
+
**The round-trip is verified.** After `/spec-push` applies a plan it reads each
|
|
171
|
+
description back and runs `spec-sync verify`, which compares word characters and
|
|
172
|
+
ignores the reformatting Linear legitimately applies (renumbered ordered lists,
|
|
173
|
+
`-`→`*`, collapsed table separators, checkbox case, whitespace). Anything lost or
|
|
174
|
+
altered is reported with both sides of the first difference. It warns rather than
|
|
175
|
+
fails — the repo is unaffected and re-pushing overwrites the mirror. This is not
|
|
176
|
+
a pull: nothing read back is merged, stamped or written anywhere.
|
|
177
|
+
|
|
178
|
+
## Deferring sub-issues until a spec starts
|
|
179
|
+
|
|
180
|
+
`mapping.phases` decides *when* a phase becomes a sub-issue:
|
|
181
|
+
|
|
182
|
+
- `"subissue"` (default) — from the spec's first push. A spec costs `1 + N`
|
|
183
|
+
`save_issue` calls to mirror, N being its phase count.
|
|
184
|
+
- `"deferred"` — only once the work starts. A spec sitting in `specs/backlog/`
|
|
185
|
+
mirrors as **the issue alone**; its sub-issues are created by the push that
|
|
186
|
+
follows `/spec-go`.
|
|
187
|
+
|
|
188
|
+
Deferral is worth setting when you adopt sync on a project that already has a
|
|
189
|
+
long backlog, where the default means mirroring every phase of every spec nobody
|
|
190
|
+
has started yet — in this repo, 130 calls where 35 would do.
|
|
191
|
+
|
|
192
|
+
What defers and what does not:
|
|
193
|
+
|
|
194
|
+
- **Unlinked phases defer; linked ones never do.** A phase already carrying a
|
|
195
|
+
`linear_issue_id` keeps projecting whatever the mode. One-way sync has no
|
|
196
|
+
delete, so withholding a live sub-issue would not remove it from Linear — it
|
|
197
|
+
would freeze it there, never updated again. That makes switching an existing
|
|
198
|
+
project to `"deferred"` safe: it only changes what has yet to be minted.
|
|
199
|
+
- **The trigger is the spec's projected state**, not its folder alone — so a
|
|
200
|
+
`spec_status` frontmatter override moves the issue's state and its sub-issues
|
|
201
|
+
together. Phases defer while that state is `backlog` or `cancelled`; a spec
|
|
202
|
+
cancelled without ever starting never mints phases it never worked, while one
|
|
203
|
+
cancelled mid-flight has ids already and keeps them.
|
|
204
|
+
- **A deferred spec keeps its `## Phases` index in the description.** That
|
|
205
|
+
section is normally stripped because the sub-issues carry it; while they are
|
|
206
|
+
withheld it is the only place the phase breakdown appears. It drops out of the
|
|
207
|
+
description in the same push that creates the sub-issues.
|
|
208
|
+
- **`/spec-push` and `/spec-status` say so**, printing `N phase(s) deferred`, and
|
|
209
|
+
the JSON plan carries a `phasesDeferred` count — a spec with no sub-issues
|
|
210
|
+
reads as deliberate rather than as phase files that failed to parse.
|
|
211
|
+
|
|
212
|
+
There is no snapshot state behind this and nothing to migrate: the last-pushed
|
|
213
|
+
snapshot only ever recorded sub-issues that have an id, so a deferred phase is
|
|
214
|
+
simply absent from it and arrives as an ordinary `create` when it projects.
|
|
215
|
+
|
|
148
216
|
## Which Project a spec issue belongs to
|
|
149
217
|
|
|
150
218
|
`linear.projectId` is the **default**, not a mandate. When a spec issue is first
|
|
@@ -152,9 +152,16 @@ below. Follow the provider's steps below (nothing to do here without one).
|
|
|
152
152
|
to bring down before building — the repo is already the source of truth. (A
|
|
153
153
|
workflow-state a teammate moved in Linear is advisory only; `/spec-status`
|
|
154
154
|
surfaces it. It is overwritten on the next push.)
|
|
155
|
-
- **
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
- **Refresh the mirror.** Run `/spec-push` to send the now in-progress spec up.
|
|
156
|
+
Whether that is optional depends on `mapping.phases`:
|
|
157
|
+
- `"subissue"` (the default) — **optional**. The phase sub-issues already
|
|
158
|
+
exist; this push only moves their states. Refresh now or later.
|
|
159
|
+
- `"deferred"` — **do it now, without asking.** Under deferral a spec sitting
|
|
160
|
+
in the backlog is mirrored as the issue alone, and this push is what mints
|
|
161
|
+
its phase sub-issues. Skip it and a started spec stays mirrored as a
|
|
162
|
+
phase-less issue until someone happens to run `/spec-push` by hand. Run it
|
|
163
|
+
straight after the step-2 commit, so the sub-issues land with the same
|
|
164
|
+
branch push that fires the tracker's automation.
|
|
158
165
|
- Linear's GitHub branch/PR automation may drive status transitions off the
|
|
159
166
|
branch/PR you pushed in step 2; that's expected and the repo still wins on the
|
|
160
167
|
next `/spec-push`.
|
|
@@ -69,6 +69,12 @@ date — say so and stop. `state` values are local buckets
|
|
|
69
69
|
(`backlog`/`in-progress`/`complete`/`cancelled`); map each to the Linear
|
|
70
70
|
issue-state NAME via `config.states` at apply time.
|
|
71
71
|
|
|
72
|
+
A **`phasesDeferred`** field means `mapping.phases` is `"deferred"` and this spec
|
|
73
|
+
has not started, so its phases are deliberately absent from the plan — the issue
|
|
74
|
+
pushes alone and the sub-issues are minted by the push that follows `/spec-go`.
|
|
75
|
+
Relay the count; it is not a sign the phase files failed to parse. Nothing else
|
|
76
|
+
about applying the plan changes.
|
|
77
|
+
|
|
72
78
|
### Stop if the plan reports a pre-9.0 mirror
|
|
73
79
|
|
|
74
80
|
If the plan carries a **`legacy`** field, this spec was linked under the pre-9.0
|
|
@@ -97,6 +103,36 @@ migrated or explicitly confirms they want a new mirror.
|
|
|
97
103
|
Priority, labels, cycles and comments are Linear-native triage — do **not** push
|
|
98
104
|
them; they're the PM's.
|
|
99
105
|
|
|
106
|
+
## 4b. Verify what Linear actually stored
|
|
107
|
+
|
|
108
|
+
Linear reserialises markdown on save, and it does not always preserve what you
|
|
109
|
+
sent — a table nested in a list item comes back with characters missing from
|
|
110
|
+
every data cell, silently. Check before you record the push as good.
|
|
111
|
+
|
|
112
|
+
For each issue you created or updated in step 4, read its `description` back
|
|
113
|
+
(`get_issue`) and write what you got to a JSON file:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{ "issue": "<stored description>", "subIssues": { "01-outbox": "<stored>" } }
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Then:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
skitterspec spec-sync verify <spec> --stored <file>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
It compares against what the engine sent, ignoring the reformatting Linear
|
|
126
|
+
legitimately applies (renumbered ordered lists, `-`→`*`, collapsed table
|
|
127
|
+
separators, checkbox case, whitespace) and reporting only lost or altered **word
|
|
128
|
+
characters**. Relay any divergence — it prints both sides around the first
|
|
129
|
+
difference. It exits 0 either way: the repo is unaffected and still correct, so
|
|
130
|
+
this is a warning, not a failure.
|
|
131
|
+
|
|
132
|
+
This is **not a pull**. Nothing read here is merged, stamped, or written
|
|
133
|
+
anywhere; the repo remains the only source of truth. Do it before step 5 so a
|
|
134
|
+
corrupted push is visible before the snapshot records it as good.
|
|
135
|
+
|
|
100
136
|
## 5. Stamp the ids, then record the snapshot
|
|
101
137
|
|
|
102
138
|
Write every id you collected back into the spec in **one** call — the engine
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "10.0
|
|
3
|
+
"version": "10.1.0",
|
|
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",
|
|
@@ -41,6 +41,7 @@ const {
|
|
|
41
41
|
writeFrontmatter,
|
|
42
42
|
stampSubIssueId,
|
|
43
43
|
listPhaseFiles,
|
|
44
|
+
compareStored,
|
|
44
45
|
} = require('../sync-core')
|
|
45
46
|
|
|
46
47
|
const { loadLinearConfig } = require('./config.js')
|
|
@@ -196,6 +197,7 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
|
196
197
|
const p = r.plan
|
|
197
198
|
const lines = [`spec-sync push: ${identifier}`, ...warningLines(snapshotDir, config)]
|
|
198
199
|
if (p.legacy) lines.push(...legacyLines(p.legacy))
|
|
200
|
+
if (p.phasesDeferred) lines.push(...deferredLines(p.phasesDeferred))
|
|
199
201
|
if (r.empty) lines.push(' nothing to push — mirror matches the last push')
|
|
200
202
|
else {
|
|
201
203
|
if (p.issue) lines.push(' issue: description/state')
|
|
@@ -207,6 +209,16 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
|
207
209
|
return 0
|
|
208
210
|
}
|
|
209
211
|
|
|
212
|
+
// `mapping.phases: 'deferred'` is holding phases back. Said plainly wherever a
|
|
213
|
+
// plan or a status report is printed, because the alternative reading of a spec
|
|
214
|
+
// with no sub-issues is that its phase files failed to parse.
|
|
215
|
+
function deferredLines(n) {
|
|
216
|
+
return [
|
|
217
|
+
` ${n} phase(s) deferred — mapping.phases is "deferred" and this spec has not started`,
|
|
218
|
+
' they are created on the push that follows /spec-go',
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
|
|
210
222
|
// The pre-9.0 mirror block. Loud on purpose: the plan below it looks entirely
|
|
211
223
|
// ordinary — an all-creates plan for a spec that reads as unlinked — and
|
|
212
224
|
// applying it mints a second mirror and abandons the first.
|
|
@@ -404,6 +416,9 @@ function specSyncStatus(dir, config, specArg, flags, out) {
|
|
|
404
416
|
const projection = projectionOf(snapshotDir, config)
|
|
405
417
|
const snapshot = readBase(dir, identifier, config)
|
|
406
418
|
const plan = planChanges(projection, snapshot)
|
|
419
|
+
// From the projection, not the plan: `status` builds its plan with
|
|
420
|
+
// `planChanges` directly rather than going through `push`.
|
|
421
|
+
if (projection.phasesWithheld) lines.push(...deferredLines(projection.phasesWithheld))
|
|
407
422
|
if (!snapshot) lines.push(' push: never pushed — everything is pending')
|
|
408
423
|
else if (isEmptyPlan(plan)) lines.push(' push: up to date — nothing changed since the last push')
|
|
409
424
|
else {
|
|
@@ -427,17 +442,86 @@ function specSyncStatus(dir, config, specArg, flags, out) {
|
|
|
427
442
|
return 0
|
|
428
443
|
}
|
|
429
444
|
|
|
445
|
+
/**
|
|
446
|
+
* `spec-sync verify <spec> --stored <file>` — compare what the tracker STORED
|
|
447
|
+
* against what we sent, and report any lost text.
|
|
448
|
+
*
|
|
449
|
+
* Not a pull: it merges nothing and writes nothing (see sync-core `verify.js`).
|
|
450
|
+
* The engine is offline, so `/spec-push` does the read over MCP and hands the
|
|
451
|
+
* result over in a file — the same split `--workspace-states` uses. The file is
|
|
452
|
+
* `{ "issue": "…", "subIssues": { "<ref>": "…" } }`; any key may be omitted.
|
|
453
|
+
*
|
|
454
|
+
* Warns, never fails (exit 0). The mirror is generated and disposable, and a
|
|
455
|
+
* hard failure after the plan is applied would strand it half-written.
|
|
456
|
+
*/
|
|
457
|
+
function specSyncVerify(dir, config, specArg, flags, out) {
|
|
458
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
459
|
+
if (!snapshotDir) return 1
|
|
460
|
+
if (!flags.stored) {
|
|
461
|
+
out.write(
|
|
462
|
+
'spec-sync verify: refusing to run without --stored <file>.\n' +
|
|
463
|
+
' The engine is offline: /spec-push reads each description back over MCP\n' +
|
|
464
|
+
' and writes {"issue": "…", "subIssues": {"<ref>": "…"}} for this command.\n',
|
|
465
|
+
)
|
|
466
|
+
return 1
|
|
467
|
+
}
|
|
468
|
+
let stored
|
|
469
|
+
try {
|
|
470
|
+
stored = JSON.parse(fs.readFileSync(flags.stored, 'utf-8'))
|
|
471
|
+
} catch (error) {
|
|
472
|
+
out.write(`spec-sync verify: cannot read --stored ${flags.stored}: ${error.message}\n`)
|
|
473
|
+
return 1
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const identifier = specIdentifier(snapshotDir, config)
|
|
477
|
+
const projection = projectionOf(snapshotDir, config)
|
|
478
|
+
const checks = []
|
|
479
|
+
if (typeof stored.issue === 'string') checks.push(['issue', projection.description, stored.issue])
|
|
480
|
+
for (const [ref, text] of Object.entries(stored.subIssues || {})) {
|
|
481
|
+
const sub = projection.subIssues.find((s) => s.ref === ref)
|
|
482
|
+
if (!sub) {
|
|
483
|
+
checks.push([`sub-issue ${ref}`, null, text])
|
|
484
|
+
continue
|
|
485
|
+
}
|
|
486
|
+
checks.push([`sub-issue ${ref}`, sub.goal, text])
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const lines = [`spec-sync verify: ${identifier}`]
|
|
490
|
+
let bad = 0
|
|
491
|
+
for (const [label, sent, got] of checks) {
|
|
492
|
+
if (sent == null) {
|
|
493
|
+
lines.push(` ?? ${label}: read back, but the projection has no such phase — stale ref?`)
|
|
494
|
+
bad++
|
|
495
|
+
continue
|
|
496
|
+
}
|
|
497
|
+
const r = compareStored(sent, got)
|
|
498
|
+
if (r.ok) continue
|
|
499
|
+
bad++
|
|
500
|
+
lines.push(
|
|
501
|
+
` !! ${label}: the tracker stored different text — ${Math.abs(r.lost)} character(s) ` +
|
|
502
|
+
`${r.lost > 0 ? 'lost' : 'added'}, first difference at ${r.at}`,
|
|
503
|
+
` sent: …${r.sentContext}…`,
|
|
504
|
+
` stored: …${r.storedContext}…`,
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
if (!bad) lines.push(` ${checks.length} description(s) round-tripped intact`)
|
|
508
|
+
else lines.push(' the repo is unchanged and still correct; re-push to overwrite the mirror')
|
|
509
|
+
out.write(lines.join('\n') + '\n')
|
|
510
|
+
return 0
|
|
511
|
+
}
|
|
512
|
+
|
|
430
513
|
async function specSync(rest, io = {}) {
|
|
431
514
|
const out = io.out || process.stdout
|
|
432
515
|
const err = io.err || process.stderr
|
|
433
516
|
const [sub, ...args] = rest
|
|
434
517
|
let dir = io.cwd || process.cwd()
|
|
435
518
|
const positional = []
|
|
436
|
-
const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [] }
|
|
519
|
+
const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [], stored: null }
|
|
437
520
|
for (let i = 0; i < args.length; i++) {
|
|
438
521
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
439
522
|
else if (args[i] === '--json') flags.json = true
|
|
440
523
|
else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
|
|
524
|
+
else if (args[i] === '--stored') flags.stored = path.resolve(args[++i])
|
|
441
525
|
else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
|
|
442
526
|
else if (args[i] === '--skip-state-check') flags.skipStateCheck = true
|
|
443
527
|
else if (args[i] === '--issue') flags.issue = args[++i]
|
|
@@ -469,6 +553,8 @@ async function specSync(rest, io = {}) {
|
|
|
469
553
|
return 0
|
|
470
554
|
case 'status':
|
|
471
555
|
return specSyncStatus(dir, config, positional[0], flags, out) || 0
|
|
556
|
+
case 'verify':
|
|
557
|
+
return specSyncVerify(dir, config, positional[0], flags, out) || 0
|
|
472
558
|
case 'linked':
|
|
473
559
|
specSyncLinked(dir, config, flags, out)
|
|
474
560
|
return 0
|
|
@@ -476,6 +562,7 @@ async function specSync(rest, io = {}) {
|
|
|
476
562
|
out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
|
|
477
563
|
' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
|
|
478
564
|
' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
|
|
565
|
+
' skitterspec spec-sync verify <spec> --stored <file>\n' +
|
|
479
566
|
' skitterspec spec-sync linked [--json]\n')
|
|
480
567
|
return 0
|
|
481
568
|
}
|
|
@@ -44,6 +44,17 @@ const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
|
|
|
44
44
|
// box ticked in the tracker is overwritten by the next push.
|
|
45
45
|
const TASK_MAPPINGS = Object.freeze(['checklist', 'none'])
|
|
46
46
|
|
|
47
|
+
// When a phase becomes a sub-issue.
|
|
48
|
+
// subissue — always, from the spec's first push (default)
|
|
49
|
+
// deferred — only once the work starts: a spec sitting in `backlog` (or
|
|
50
|
+
// `cancelled` without ever having started) projects the issue
|
|
51
|
+
// alone, so adopting sync on a long backlog costs one call per
|
|
52
|
+
// spec instead of one per spec PLUS one per phase. A phase that
|
|
53
|
+
// already carries an id keeps projecting either way — one-way sync
|
|
54
|
+
// has no delete, so withholding a LINKED sub-issue would freeze it
|
|
55
|
+
// in the tracker rather than remove it.
|
|
56
|
+
const PHASE_MAPPINGS = Object.freeze(['subissue', 'deferred'])
|
|
57
|
+
|
|
47
58
|
const DEFAULT_CONFIG = Object.freeze({
|
|
48
59
|
// `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
|
|
49
60
|
// first `/spec-push` offer the team's projects and pre-select this one; empty
|
|
@@ -201,6 +212,12 @@ function mergeConfig(base, parsed) {
|
|
|
201
212
|
`(expected one of ${TASK_MAPPINGS.join('|')})`,
|
|
202
213
|
)
|
|
203
214
|
}
|
|
215
|
+
if (!PHASE_MAPPINGS.includes(base.mapping.phases)) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Invalid ${CONFIG_FILE}: mapping.phases = ${JSON.stringify(base.mapping.phases)} ` +
|
|
218
|
+
`(expected one of ${PHASE_MAPPINGS.join('|')})`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
204
221
|
}
|
|
205
222
|
|
|
206
223
|
if (isObject(parsed.states)) {
|
|
@@ -266,4 +283,5 @@ module.exports = {
|
|
|
266
283
|
CONFIG_FILE,
|
|
267
284
|
OWNERSHIP,
|
|
268
285
|
TASK_MAPPINGS,
|
|
286
|
+
PHASE_MAPPINGS,
|
|
269
287
|
}
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* about any specific tracker. The repo is the source of truth: the engine builds
|
|
8
8
|
* a local projection, diffs it against a committed last-pushed snapshot
|
|
9
9
|
* (`planChanges`), and returns a create/update plan the provider skill applies
|
|
10
|
-
* over its API. No remote content is read
|
|
10
|
+
* over its API. No remote content is read for CONTENT: nothing the tracker holds
|
|
11
|
+
* ever feeds the projection, the snapshot, or a repo file. `compareStored` is the
|
|
12
|
+
* one function that looks at a tracker value, and it only checks that what was
|
|
13
|
+
* stored matches what was sent — it merges nothing (see `src/verify.js`).
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions } = require('./src/normalize.js')
|
|
@@ -17,6 +20,8 @@ const { push, recordPush, projectionOf } = require('./src/push.js')
|
|
|
17
20
|
const { writeFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
|
|
18
21
|
const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
|
|
19
22
|
const { detectLegacyMirror } = require('./src/legacy.js')
|
|
23
|
+
const { compareStored } = require('./src/verify.js')
|
|
24
|
+
const { flattenNestedTables } = require('./src/tables.js')
|
|
20
25
|
|
|
21
26
|
module.exports = {
|
|
22
27
|
normalizeLocal,
|
|
@@ -44,4 +49,6 @@ module.exports = {
|
|
|
44
49
|
listPhaseFiles,
|
|
45
50
|
sanitizeSpecMarkdown,
|
|
46
51
|
detectLegacyMirror,
|
|
52
|
+
compareStored,
|
|
53
|
+
flattenNestedTables,
|
|
47
54
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require('node:fs')
|
|
18
18
|
const path = require('node:path')
|
|
19
|
+
const { flattenNestedTables } = require('./tables.js')
|
|
19
20
|
const { fenceMask, findTaskBlocks, collapse, collapseHyphenAware } = require('./task-block.js')
|
|
20
21
|
|
|
21
22
|
// --- markdown / frontmatter parsing -----------------------------------------
|
|
@@ -527,7 +528,7 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
|
|
|
527
528
|
if (skip.has(heading)) continue
|
|
528
529
|
parts.push(`## ${heading}\n\n${content}`.trim())
|
|
529
530
|
}
|
|
530
|
-
return canonicalizeMarkdown(parts.join('\n\n')) || null
|
|
531
|
+
return flattenNestedTables(canonicalizeMarkdown(parts.join('\n\n'))) || null
|
|
531
532
|
}
|
|
532
533
|
|
|
533
534
|
// A phase sub-issue's description: its `**Goal:**` line, plus the phase's task
|
|
@@ -539,7 +540,7 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
|
|
|
539
540
|
// act on; with it the phase is legible to someone working in the tracker without
|
|
540
541
|
// tasks becoming individually-synced objects again.
|
|
541
542
|
function subIssueBody(phase, tasksMode) {
|
|
542
|
-
if (tasksMode !== 'checklist' || !phase.tasks.length) return phase.goal
|
|
543
|
+
if (tasksMode !== 'checklist' || !phase.tasks.length) return flattenNestedTables(phase.goal)
|
|
543
544
|
const parts = []
|
|
544
545
|
if (phase.goal) parts.push(phase.goal, '')
|
|
545
546
|
// One section per source heading, in source order. Checkboxes written before
|
|
@@ -550,7 +551,7 @@ function subIssueBody(phase, tasksMode) {
|
|
|
550
551
|
if (i) parts.push('')
|
|
551
552
|
parts.push(group.heading || '## Tasks', '', ...group.tasks)
|
|
552
553
|
})
|
|
553
|
-
return parts.join('\n')
|
|
554
|
+
return flattenNestedTables(parts.join('\n'))
|
|
554
555
|
}
|
|
555
556
|
|
|
556
557
|
/**
|
|
@@ -564,13 +565,57 @@ function bucketFromPath(snapshotDir) {
|
|
|
564
565
|
return LIFECYCLE_BUCKETS.includes(parent) ? parent : null
|
|
565
566
|
}
|
|
566
567
|
|
|
568
|
+
// Lifecycle buckets in which a spec's work has not begun: never started, or
|
|
569
|
+
// abandoned without ever starting. Under `mapping.phases: 'deferred'` these are
|
|
570
|
+
// the states in which phases are not yet worth minting as sub-issues.
|
|
571
|
+
const UNSTARTED_BUCKETS = ['backlog', 'cancelled']
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Which phases the projection sends, and how many the `deferred` mapping is
|
|
575
|
+
* holding back. Pure — split out so both the projection and the CLI's "N phases
|
|
576
|
+
* deferred" line read the SAME predicate rather than two copies of it.
|
|
577
|
+
*
|
|
578
|
+
* Only UNLINKED phases are withheld. A phase that already carries an id keeps
|
|
579
|
+
* projecting whatever the mode: one-way sync has no delete op, so withholding a
|
|
580
|
+
* live sub-issue would not remove it from the tracker — it would freeze it there,
|
|
581
|
+
* never updated again. That makes switching a project to `deferred` safe.
|
|
582
|
+
*/
|
|
583
|
+
// The spec's lifecycle status as projected: its folder bucket, unless the
|
|
584
|
+
// overview frontmatter pins `spec_status`. Shared by the projection and by
|
|
585
|
+
// `phasesWithheld` so the two can never disagree about whether work has started.
|
|
586
|
+
function specStatus(snapshotDir, frontmatter) {
|
|
587
|
+
return frontmatter && frontmatter.spec_status != null
|
|
588
|
+
? String(frontmatter.spec_status)
|
|
589
|
+
: bucketFromPath(snapshotDir)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function phaseProjection(phases, workflowState, config) {
|
|
593
|
+
const named = phases.filter((p) => p.name)
|
|
594
|
+
const deferring =
|
|
595
|
+
(config.mapping && config.mapping.phases) === 'deferred' && UNSTARTED_BUCKETS.includes(workflowState)
|
|
596
|
+
const projected = deferring ? named.filter((p) => p.id != null) : named
|
|
597
|
+
return { projected, withheld: named.length - projected.length }
|
|
598
|
+
}
|
|
599
|
+
|
|
567
600
|
function normalizeLocal(snapshotDir, config) {
|
|
568
601
|
const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
|
|
602
|
+
const tasksMode = (config.mapping && config.mapping.tasks) || 'checklist'
|
|
603
|
+
// Status is the spec's lifecycle bucket. The folder is the source of truth; an
|
|
604
|
+
// explicit `spec_status` frontmatter key overrides it if present. Resolved
|
|
605
|
+
// BEFORE the sub-issue projection because deferral withholds phases by this
|
|
606
|
+
// status — so the issue's state and its sub-issues always agree on whether the
|
|
607
|
+
// work has started, however that status was arrived at.
|
|
608
|
+
const workflowState = specStatus(snapshotDir, frontmatter)
|
|
609
|
+
const { projected, withheld } = phaseProjection(phases, workflowState, config)
|
|
610
|
+
|
|
569
611
|
// Phases sync as sub-issues whenever `subIssues` is in the pushed projection,
|
|
570
612
|
// so strip the `## Phases` index from the description to avoid duplicating it
|
|
571
|
-
// (as prose AND as sub-issues) in the Linear mirror.
|
|
572
|
-
|
|
573
|
-
|
|
613
|
+
// (as prose AND as sub-issues) in the Linear mirror. While deferral is holding
|
|
614
|
+
// a phase back, that index is the ONLY place the phase appears — stripping it
|
|
615
|
+
// too would leave a backlog issue with no phase breakdown at all — so it stays
|
|
616
|
+
// until the sub-issues arrive to replace it.
|
|
617
|
+
const phasesProjected =
|
|
618
|
+
!!(config.sync.fieldOwnership && 'subIssues' in config.sync.fieldOwnership) && withheld === 0
|
|
574
619
|
const extracted = {
|
|
575
620
|
description: buildDescription(
|
|
576
621
|
title,
|
|
@@ -584,17 +629,32 @@ function normalizeLocal(snapshotDir, config) {
|
|
|
584
629
|
// Linear issue state via `config.states` at push time. Tasks ride along in
|
|
585
630
|
// the description as a read-only checklist (`mapping.tasks`), never as
|
|
586
631
|
// individually-synced objects.
|
|
587
|
-
subIssues:
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
632
|
+
subIssues: projected.map((p) => ({
|
|
633
|
+
id: p.id,
|
|
634
|
+
ref: p.phase,
|
|
635
|
+
name: p.name,
|
|
636
|
+
goal: subIssueBody(p, tasksMode),
|
|
637
|
+
state: p.state,
|
|
638
|
+
})),
|
|
639
|
+
workflowState,
|
|
594
640
|
}
|
|
595
641
|
return toFieldSet(extracted, config)
|
|
596
642
|
}
|
|
597
643
|
|
|
644
|
+
/**
|
|
645
|
+
* How many phases `mapping.phases: 'deferred'` is currently holding back for
|
|
646
|
+
* this spec — 0 in every other mode.
|
|
647
|
+
*
|
|
648
|
+
* Deliberately NOT a key on `normalizeLocal`'s return: that is the configured
|
|
649
|
+
* field set and nothing else, so a reporting-only value cannot drift into the
|
|
650
|
+
* synced shape (or a hash). Callers that want to SAY "N phases deferred" ask for
|
|
651
|
+
* it, at the cost of a second read of a handful of small files.
|
|
652
|
+
*/
|
|
653
|
+
function phasesWithheld(snapshotDir, config) {
|
|
654
|
+
const { frontmatter, phases } = readSnapshot(snapshotDir, config)
|
|
655
|
+
return phaseProjection(phases, specStatus(snapshotDir, frontmatter), config).withheld
|
|
656
|
+
}
|
|
657
|
+
|
|
598
658
|
// --- remote projection ------------------------------------------------------
|
|
599
659
|
|
|
600
660
|
// Map a remote workflow-state name back to the local lifecycle bucket (the
|
|
@@ -758,6 +818,8 @@ function stateSuggestions(config, workspaceStates) {
|
|
|
758
818
|
module.exports = {
|
|
759
819
|
stateSuggestions,
|
|
760
820
|
normalizeLocal,
|
|
821
|
+
phaseProjection,
|
|
822
|
+
phasesWithheld,
|
|
761
823
|
lintPhases,
|
|
762
824
|
readSnapshot,
|
|
763
825
|
parseFrontmatter,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Date.now(). `recordPush` writes the snapshot sidecar.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const { normalizeLocal } = require('./normalize.js')
|
|
18
|
+
const { normalizeLocal, phasesWithheld } = require('./normalize.js')
|
|
19
19
|
const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
|
|
20
20
|
const { readBase, writeBase } = require('./base.js')
|
|
21
21
|
const { detectLegacyMirror } = require('./legacy.js')
|
|
@@ -30,6 +30,10 @@ function projectionOf(snapshotDir, config) {
|
|
|
30
30
|
description: local.description ?? null,
|
|
31
31
|
status: local.workflowState ?? null,
|
|
32
32
|
subIssues: Array.isArray(local.subIssues) ? local.subIssues : [],
|
|
33
|
+
// How many phases `mapping.phases: 'deferred'` is holding back. Reporting
|
|
34
|
+
// only — `snapshotOf`/`specIssueHash` read named fields, so this never
|
|
35
|
+
// reaches a hash and cannot make an unchanged spec look edited.
|
|
36
|
+
phasesWithheld: phasesWithheld(snapshotDir, config),
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
|
|
@@ -43,6 +47,10 @@ function push({ dir, snapshotDir, identifier, config }) {
|
|
|
43
47
|
// skill that applies this plan is exactly the consumer that would miss them.
|
|
44
48
|
const legacy = detectLegacyMirror({ dir, snapshotDir, identifier, config })
|
|
45
49
|
if (legacy) plan.legacy = legacy
|
|
50
|
+
// Same reasoning as `legacy`: carried ON THE PLAN, not as a stderr warning,
|
|
51
|
+
// because `--json` routes warnings to stderr and the skill applying the plan
|
|
52
|
+
// is the consumer that most needs to know the missing sub-issues are deliberate.
|
|
53
|
+
if (projection.phasesWithheld) plan.phasesDeferred = projection.phasesWithheld
|
|
46
54
|
return { ok: true, empty: isEmptyPlan(plan), plan, projection }
|
|
47
55
|
}
|
|
48
56
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Flatten markdown tables that sit INSIDE a list item, because Linear corrupts
|
|
5
|
+
* them.
|
|
6
|
+
*
|
|
7
|
+
* Measured on probe SKI-28 (2026-08-28): when Linear renders a table nested in a
|
|
8
|
+
* list item, every **data** cell loses its first N characters, where N is the
|
|
9
|
+
* list-content indent Linear renders at — 3 per ordered-list level, 2 per bullet
|
|
10
|
+
* level — regardless of the indent the source used. Source indents 3, 4 and 6
|
|
11
|
+
* all lose exactly 3. The header row is never touched, column-0 tables never
|
|
12
|
+
* corrupt, and the column count is irrelevant. Real damage from the field: the
|
|
13
|
+
* auth header `X-Extraction-Key` was stored as `Extraction-Key`.
|
|
14
|
+
*
|
|
15
|
+
* The engine passes the table through byte-identically — this is Linear's
|
|
16
|
+
* parser, not ours — but the projection is the only place that can stop the
|
|
17
|
+
* markdown reaching it in a shape it mangles. So nested tables are re-emitted as
|
|
18
|
+
* shapes SKI-28 proved survive nesting unchanged:
|
|
19
|
+
*
|
|
20
|
+
* - 2 columns → a bullet list (`- a — b`), the key/value case, and the shape
|
|
21
|
+
* the reporter hand-repaired in production
|
|
22
|
+
* - otherwise → a fenced code block wrapping the original rows verbatim
|
|
23
|
+
*
|
|
24
|
+
* This shapes the PROJECTION only. Repo files are never rewritten: the source
|
|
25
|
+
* markdown is valid and renders correctly in GitHub and every editor.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { fenceMask } = require('./task-block.js')
|
|
29
|
+
|
|
30
|
+
// A table row: optional indent, then a `|`-delimited line. We only ever act on
|
|
31
|
+
// indented ones — a column-0 table (the `## Phases` index, every Impact map) is
|
|
32
|
+
// rendered correctly by Linear and must project byte-identically.
|
|
33
|
+
const ROW_RE = /^([ \t]+)\|(.*)\|[ \t]*$/
|
|
34
|
+
// The separator under the header — `|---|:--:|`. Its presence is what makes the
|
|
35
|
+
// block a table rather than prose that happens to contain pipes.
|
|
36
|
+
const SEPARATOR_RE = /^[ \t]+\|[\s:|-]+\|[ \t]*$/
|
|
37
|
+
|
|
38
|
+
// Split a row into cells on pipes that are OUTSIDE an inline-code span, so a
|
|
39
|
+
// documented `` `a | b` `` alternation stays one cell instead of splitting.
|
|
40
|
+
function splitCells(body) {
|
|
41
|
+
const cells = []
|
|
42
|
+
let cur = ''
|
|
43
|
+
let code = false
|
|
44
|
+
for (const ch of body) {
|
|
45
|
+
if (ch === '`') code = !code
|
|
46
|
+
if (ch === '|' && !code) {
|
|
47
|
+
cells.push(cur.trim())
|
|
48
|
+
cur = ''
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
cur += ch
|
|
52
|
+
}
|
|
53
|
+
cells.push(cur.trim())
|
|
54
|
+
return cells
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Rewrite every indented table in `md`. Returns the text unchanged when there is
|
|
59
|
+
* nothing nested to flatten.
|
|
60
|
+
* @param {string} md
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function flattenNestedTables(md) {
|
|
64
|
+
if (md == null) return md
|
|
65
|
+
const lines = String(md).split('\n')
|
|
66
|
+
const inFence = fenceMask(lines)
|
|
67
|
+
const out = []
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const header = ROW_RE.exec(lines[i])
|
|
71
|
+
// A table shown as an EXAMPLE inside a ``` block is documentation — often of
|
|
72
|
+
// this very bug — so it is left exactly as written.
|
|
73
|
+
if (!header || inFence[i] || !SEPARATOR_RE.test(lines[i + 1] || '')) {
|
|
74
|
+
out.push(lines[i])
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const indent = header[1]
|
|
79
|
+
const rows = [splitCells(header[2])]
|
|
80
|
+
const raw = [lines[i], lines[i + 1]]
|
|
81
|
+
let j = i + 2
|
|
82
|
+
for (; j < lines.length && !inFence[j]; j++) {
|
|
83
|
+
const row = ROW_RE.exec(lines[j])
|
|
84
|
+
if (!row) break
|
|
85
|
+
rows.push(splitCells(row[2]))
|
|
86
|
+
raw.push(lines[j])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (rows[0].length === 2) {
|
|
90
|
+
// Header first, bolded: dropping it would lose content and inventing a
|
|
91
|
+
// caption would invent it.
|
|
92
|
+
out.push(`${indent}- **${rows[0][0]}** — **${rows[0][1]}**`)
|
|
93
|
+
for (const r of rows.slice(1)) out.push(`${indent}- ${r[0]} — ${r[1]}`)
|
|
94
|
+
} else {
|
|
95
|
+
out.push(`${indent}\`\`\``, ...raw, `${indent}\`\`\``)
|
|
96
|
+
}
|
|
97
|
+
i = j - 1
|
|
98
|
+
}
|
|
99
|
+
return out.join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { flattenNestedTables, splitCells }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compare what Linear STORED against what we sent, and report lost text.
|
|
5
|
+
*
|
|
6
|
+
* Why this is not a pull. One-way sync's rule is about **authority**: Linear
|
|
7
|
+
* must never influence repo content. This reads a description back to *check*
|
|
8
|
+
* it — it merges nothing, writes nothing, and feeds nothing into the projection
|
|
9
|
+
* or the snapshot. The repo remains the only source of truth; the only output is
|
|
10
|
+
* a warning for a human. Without it, a parser that silently eats characters
|
|
11
|
+
* produces a mirror that looks pushed and is wrong, which is exactly how the
|
|
12
|
+
* nested-table corruption went unnoticed (see `tables.js`).
|
|
13
|
+
*
|
|
14
|
+
* The engine is offline, so the read itself belongs to the `/spec-push` skill —
|
|
15
|
+
* it fetches over MCP and hands the result here, the same split
|
|
16
|
+
* `--workspace-states` already uses.
|
|
17
|
+
*
|
|
18
|
+
* Pure: no I/O, no clock, no randomness.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Linear reserialises markdown on save. These transforms are all observed and
|
|
22
|
+
// all harmless, so they are normalised away BEFORE comparing — otherwise every
|
|
23
|
+
// push would report a false divergence.
|
|
24
|
+
function canonicalForCompare(text) {
|
|
25
|
+
return String(text == null ? '' : text)
|
|
26
|
+
.replace(/\r\n/g, '\n')
|
|
27
|
+
.split('\n')
|
|
28
|
+
.map((line) =>
|
|
29
|
+
line
|
|
30
|
+
// Ordered-list markers → a placeholder. Linear renumbers lists, and the
|
|
31
|
+
// digits it rewrites are alphanumeric, so a naive alphanumeric compare
|
|
32
|
+
// would flag its own benign reformat as data loss. Normalising the
|
|
33
|
+
// marker keeps digits significant EVERYWHERE ELSE — a port, a version, a
|
|
34
|
+
// key length still count.
|
|
35
|
+
.replace(/^(\s*)\d+\.(\s)/, '$1#.$2')
|
|
36
|
+
// Unordered markers unify (`-`/`+` come back as `*`).
|
|
37
|
+
.replace(/^(\s*)[*+-](\s)/, '$1-$2')
|
|
38
|
+
// Checkbox marks case-fold. Targeted rather than lowercasing the whole
|
|
39
|
+
// text, so a genuine case corruption in prose is still caught.
|
|
40
|
+
.replace(/^(\s*-\s*\[)[xX](\])/, '$1x$2')
|
|
41
|
+
// Table separator rows collapse (`|-------|` → `| -- |`).
|
|
42
|
+
.replace(/^\s*\|[\s:|-]+\|\s*$/, '|--|')
|
|
43
|
+
.replace(/[ \t]+$/, ''),
|
|
44
|
+
)
|
|
45
|
+
.join('\n')
|
|
46
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
47
|
+
.trim()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The word-character stream: everything that carries meaning, with every
|
|
51
|
+
// reformatting artefact (whitespace, bullets, asterisk boundaries, pipes,
|
|
52
|
+
// separators) removed. Comparing these catches dropped characters while
|
|
53
|
+
// ignoring every benign transform above.
|
|
54
|
+
function stream(text) {
|
|
55
|
+
return canonicalForCompare(text).replace(/[^\p{L}\p{N}]/gu, '')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} sent what we pushed
|
|
60
|
+
* @param {string} stored what the tracker returned
|
|
61
|
+
* @returns {{ok:boolean, at:number|null, lost:number, sentContext:string, storedContext:string}}
|
|
62
|
+
* `ok` false means word characters differ — content was lost or altered.
|
|
63
|
+
* `at` is the index in the reduced stream where they first diverge, with ~40
|
|
64
|
+
* characters of each side around it so the warning names the damage.
|
|
65
|
+
*/
|
|
66
|
+
function compareStored(sent, stored) {
|
|
67
|
+
const a = stream(sent)
|
|
68
|
+
const b = stream(stored)
|
|
69
|
+
if (a === b) return { ok: true, at: null, lost: 0, sentContext: '', storedContext: '' }
|
|
70
|
+
|
|
71
|
+
let at = 0
|
|
72
|
+
while (at < a.length && at < b.length && a[at] === b[at]) at++
|
|
73
|
+
const window = (s) => s.slice(Math.max(0, at - 10), at + 30)
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
at,
|
|
77
|
+
lost: a.length - b.length,
|
|
78
|
+
sentContext: window(a),
|
|
79
|
+
storedContext: window(b),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { compareStored, canonicalForCompare, stream }
|