@skitterbyte/skitterspec-linear 10.0.1 → 10.2.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.
@@ -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 }