@skitterbyte/skitterspec-jira 0.0.0-stage → 1.0.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.
Files changed (109) hide show
  1. package/MIGRATION.md +1089 -0
  2. package/README.md +42 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +554 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +61 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +692 -0
  23. package/assets/rules/spec-reports.md +557 -0
  24. package/assets/skills/no-spec/SKILL.md +246 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +583 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +448 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +605 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +220 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +676 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +112 -0
  40. package/assets/skills/spec-sync/SKILL.md +218 -0
  41. package/assets/skills/spec-to-main/SKILL.md +139 -0
  42. package/bin/skitterspec-jira.js +61 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5901 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +753 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/hooks.js +226 -0
  53. package/src/env/hotfix.js +385 -0
  54. package/src/env/hotfixland.js +302 -0
  55. package/src/env/integrate.js +126 -0
  56. package/src/env/live.js +504 -0
  57. package/src/env/mainguard.js +299 -0
  58. package/src/env/provision.js +466 -0
  59. package/src/env/proxy.js +192 -0
  60. package/src/env/prune.js +119 -0
  61. package/src/env/registry.js +159 -0
  62. package/src/env/render.js +17 -0
  63. package/src/env/resolve.js +716 -0
  64. package/src/env/review.js +2577 -0
  65. package/src/env/reviewers/coderabbit.js +228 -0
  66. package/src/env/reviewers.js +517 -0
  67. package/src/env/serve.js +1044 -0
  68. package/src/env/supervise.js +157 -0
  69. package/src/env/teardown.js +248 -0
  70. package/src/env/trust.js +87 -0
  71. package/src/gating.js +155 -0
  72. package/src/init.js +1150 -0
  73. package/src/lines-diff.js +114 -0
  74. package/src/prompts.js +129 -0
  75. package/src/vendor/jira/adf.js +414 -0
  76. package/src/vendor/jira/api.js +295 -0
  77. package/src/vendor/jira/cli-sync.js +1256 -0
  78. package/src/vendor/jira/commands.js +44 -0
  79. package/src/vendor/jira/config.js +296 -0
  80. package/src/vendor/jira/credentials.js +108 -0
  81. package/src/vendor/jira/dialect.js +45 -0
  82. package/src/vendor/jira/mcp.js +129 -0
  83. package/src/vendor/provider-kit/index.js +41 -0
  84. package/src/vendor/provider-kit/src/adapter.js +23 -0
  85. package/src/vendor/provider-kit/src/apply.js +197 -0
  86. package/src/vendor/provider-kit/src/args.js +68 -0
  87. package/src/vendor/provider-kit/src/config.js +198 -0
  88. package/src/vendor/provider-kit/src/credentials.js +404 -0
  89. package/src/vendor/provider-kit/src/doctor.js +35 -0
  90. package/src/vendor/provider-kit/src/http.js +44 -0
  91. package/src/vendor/provider-kit/src/identity.js +93 -0
  92. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  93. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  94. package/src/vendor/provider-kit/src/prompt.js +50 -0
  95. package/src/vendor/provider-kit/src/released.js +226 -0
  96. package/src/vendor/provider-kit/src/specs.js +166 -0
  97. package/src/vendor/provider-kit/src/transport.js +22 -0
  98. package/src/vendor/sync-core/index.js +92 -0
  99. package/src/vendor/sync-core/src/base.js +81 -0
  100. package/src/vendor/sync-core/src/compare.js +306 -0
  101. package/src/vendor/sync-core/src/dialect.js +51 -0
  102. package/src/vendor/sync-core/src/fields.js +36 -0
  103. package/src/vendor/sync-core/src/legacy.js +100 -0
  104. package/src/vendor/sync-core/src/normalize.js +989 -0
  105. package/src/vendor/sync-core/src/push.js +85 -0
  106. package/src/vendor/sync-core/src/retarget.js +278 -0
  107. package/src/vendor/sync-core/src/task-block.js +308 -0
  108. package/src/vendor/sync-core/src/verify.js +67 -0
  109. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,114 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * A minimal line diff — just enough for `update` to say what it skipped.
5
+ *
6
+ * `update` reports a file it kept as `customized (kept)` and nothing else, so
7
+ * there is no way to learn WHICH upstream changes you declined without diffing
8
+ * against `node_modules` by hand. That is how a real behavioural change (the
9
+ * lifecycle skills learning to commit their own edits) went unnoticed through an
10
+ * upgrade in the field.
11
+ *
12
+ * Zero dependencies on purpose: this package ships with none, and `diff(1)` is
13
+ * not a portable guarantee. An LCS over lines is a few dozen lines of code and
14
+ * the inputs are markdown files of a few hundred lines.
15
+ */
16
+
17
+ // Longest-common-subsequence walk over two line arrays, as a flat op list.
18
+ // `t` is ' ' (context), '-' (only in `a`) or '+' (only in `b`).
19
+ function diffOps(a, b) {
20
+ const n = a.length
21
+ const m = b.length
22
+ // dp[i][j] = LCS length of a[i..] and b[j..], flattened.
23
+ const dp = new Int32Array((n + 1) * (m + 1))
24
+ const at = (i, j) => i * (m + 1) + j
25
+ for (let i = n - 1; i >= 0; i--) {
26
+ for (let j = m - 1; j >= 0; j--) {
27
+ dp[at(i, j)] = a[i] === b[j] ? dp[at(i + 1, j + 1)] + 1 : Math.max(dp[at(i + 1, j)], dp[at(i, j + 1)])
28
+ }
29
+ }
30
+
31
+ const ops = []
32
+ let i = 0
33
+ let j = 0
34
+ while (i < n && j < m) {
35
+ if (a[i] === b[j]) {
36
+ ops.push({ t: ' ', line: a[i], a: i, b: j })
37
+ i++
38
+ j++
39
+ } else if (dp[at(i + 1, j)] >= dp[at(i, j + 1)]) {
40
+ ops.push({ t: '-', line: a[i], a: i, b: j })
41
+ i++
42
+ } else {
43
+ ops.push({ t: '+', line: b[j], a: i, b: j })
44
+ j++
45
+ }
46
+ }
47
+ while (i < n) {
48
+ ops.push({ t: '-', line: a[i], a: i, b: j })
49
+ i++
50
+ }
51
+ while (j < m) {
52
+ ops.push({ t: '+', line: b[j], a: i, b: j })
53
+ j++
54
+ }
55
+ return ops
56
+ }
57
+
58
+ // Group the ops into unified-diff hunks, each carrying `context` unchanged lines
59
+ // either side of a run of changes. Runs closer together than 2×context merge, as
60
+ // `diff -u` does, so a cluster of edits reads as one hunk.
61
+ function toHunks(ops, context) {
62
+ const changed = ops.map((o) => o.t !== ' ')
63
+ const hunks = []
64
+ let k = 0
65
+ while (k < ops.length) {
66
+ if (!changed[k]) {
67
+ k++
68
+ continue
69
+ }
70
+ let start = Math.max(0, k - context)
71
+ let end = k
72
+ // Extend while the next change is near enough to keep in the same hunk.
73
+ for (let p = k; p < ops.length; p++) {
74
+ if (changed[p]) end = p
75
+ else if (p - end > context * 2) break
76
+ }
77
+ end = Math.min(ops.length - 1, end + context)
78
+
79
+ const body = ops.slice(start, end + 1)
80
+ const aStart = body[0].a + 1
81
+ const bStart = body[0].b + 1
82
+ const aLen = body.filter((o) => o.t !== '+').length
83
+ const bLen = body.filter((o) => o.t !== '-').length
84
+ hunks.push(
85
+ [`@@ -${aStart},${aLen} +${bStart},${bLen} @@`, ...body.map((o) => `${o.t}${o.line}`)].join('\n'),
86
+ )
87
+ k = end + 1
88
+ }
89
+ return hunks
90
+ }
91
+
92
+ /**
93
+ * Diff `a` (what is on disk) against `b` (what the package ships).
94
+ *
95
+ * `added`/`removed` count the lines an update WOULD add and remove — the summary
96
+ * printed beside a kept file. `hunks` are unified-diff blocks for `--diff`.
97
+ *
98
+ * @param {string|string[]} a
99
+ * @param {string|string[]} b
100
+ * @param {{context?:number}} [opts]
101
+ * @returns {{added:number, removed:number, hunks:string[]}}
102
+ */
103
+ function linesDiff(a, b, { context = 3 } = {}) {
104
+ const A = Array.isArray(a) ? a : String(a).split('\n')
105
+ const B = Array.isArray(b) ? b : String(b).split('\n')
106
+ const ops = diffOps(A, B)
107
+ return {
108
+ added: ops.filter((o) => o.t === '+').length,
109
+ removed: ops.filter((o) => o.t === '-').length,
110
+ hunks: toHunks(ops, context),
111
+ }
112
+ }
113
+
114
+ module.exports = { linesDiff }
package/src/prompts.js ADDED
@@ -0,0 +1,129 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Interactive setup flow for `skitterspec init`, built on the `prompts`
5
+ * (terkelg) library. Only required from the TTY branch of the CLI — the
6
+ * non-interactive path (flags / --yes / CI) never loads this module, so the
7
+ * test suite never imports the interactive UI.
8
+ *
9
+ * `isolationSeed` pre-fills the per-spec isolation question. Returns
10
+ * `{ isolation, mode }` — `mode` is only asked when isolation is enabled, and
11
+ * is `'worktree'` otherwise (the value the config defaults to anyway).
12
+ */
13
+
14
+ async function promptSetup({ isolationSeed = false, gatingSeed = false } = {}) {
15
+ const prompts = require('prompts')
16
+
17
+ let cancelled = false
18
+ const onCancel = () => {
19
+ cancelled = true
20
+ return false // stop the prompt chain
21
+ }
22
+
23
+ const questions = [
24
+ {
25
+ type: 'confirm',
26
+ name: 'isolation',
27
+ message: 'Enable per-spec isolation — build each spec on its own branch?',
28
+ initial: isolationSeed,
29
+ },
30
+ {
31
+ // Only reachable when isolation was accepted: `prev` is the previous
32
+ // answer, and returning null skips the question entirely.
33
+ type: (prev) => (prev ? 'select' : null),
34
+ name: 'mode',
35
+ message: 'Where should a spec be built?',
36
+ hint: '- this is the trade, not a preference',
37
+ initial: 0,
38
+ choices: [
39
+ {
40
+ title: 'Its own worktree (default)',
41
+ value: 'worktree',
42
+ description: 'several specs at once, main left free — one terminal session per spec',
43
+ },
44
+ {
45
+ title: 'The checkout you are in',
46
+ value: 'checkout',
47
+ description: 'one spec at a time, no second session — your terminal follows the work',
48
+ },
49
+ ],
50
+ },
51
+ {
52
+ // Orthogonal to isolation, so it is asked unconditionally rather than
53
+ // nested under it — a project can adopt either, both, or neither.
54
+ type: 'confirm',
55
+ name: 'gating',
56
+ message: 'Record a release-gating decision on each spec — flag, or land live?',
57
+ initial: gatingSeed,
58
+ },
59
+ ]
60
+
61
+ const ans = await prompts(questions, { onCancel })
62
+ if (cancelled) throw new Error('Setup cancelled')
63
+
64
+ // Anything other than an explicit 'checkout' resolves to the default, so a
65
+ // skipped or cancelled-into-default answer can never select the mode that
66
+ // puts a spec's work in the primary checkout.
67
+ return {
68
+ isolation: Boolean(ans.isolation),
69
+ mode: ans.mode === 'checkout' ? 'checkout' : 'worktree',
70
+ gating: Boolean(ans.gating),
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Interactive confirm for removing leftover release tooling on `update`. Returns
76
+ * true only on an explicit yes; a cancel (Ctrl-C / Esc) resolves to false so the
77
+ * default is always to keep the files.
78
+ */
79
+ async function confirmRemoveReleaseTooling() {
80
+ const prompts = require('prompts')
81
+ const ans = await prompts(
82
+ {
83
+ type: 'confirm',
84
+ name: 'remove',
85
+ message: 'Found release tooling (now in @skitterbyte/skittership). Remove it here?',
86
+ initial: false,
87
+ },
88
+ { onCancel: () => false },
89
+ )
90
+ return Boolean(ans.remove)
91
+ }
92
+
93
+ /**
94
+ * Interactive choice when `init` finds an already-set-up repo. Returns one of
95
+ * `leave` | `resync` | `reset`. Defaults to (and cancels to) `leave` — the safe
96
+ * no-op — and asks a second confirm before `reset` (destructive to managed files).
97
+ */
98
+ async function promptExistingSetup() {
99
+ const prompts = require('prompts')
100
+ const { action } = await prompts(
101
+ {
102
+ type: 'select',
103
+ name: 'action',
104
+ message: 'This project already has skitterspec set up. What would you like to do?',
105
+ initial: 0,
106
+ choices: [
107
+ { title: 'Leave alone — make no changes', value: 'leave' },
108
+ { title: 'Resync — update managed files to the latest, keep my edits', value: 'resync' },
109
+ { title: 'Start again — reset the scaffolding (your specs & config are kept)', value: 'reset' },
110
+ ],
111
+ },
112
+ { onCancel: () => {} },
113
+ )
114
+ if (action === 'reset') {
115
+ const { confirm } = await prompts(
116
+ {
117
+ type: 'confirm',
118
+ name: 'confirm',
119
+ message: 'Start again overwrites managed skills/rules. Your specs and config are untouched. Continue?',
120
+ initial: false,
121
+ },
122
+ { onCancel: () => false },
123
+ )
124
+ if (!confirm) return 'leave'
125
+ }
126
+ return action || 'leave'
127
+ }
128
+
129
+ module.exports = { promptSetup, confirmRemoveReleaseTooling, promptExistingSetup }
@@ -0,0 +1,414 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Markdown ⇄ ADF — the two halves of Jira Cloud's description dialect.
5
+ *
6
+ * Jira Cloud's REST v3 stores rich text as the Atlassian Document Format, a
7
+ * JSON tree — there is no markdown field. The engine's projections are
8
+ * markdown, so the adapter renders outbound text with `mdToAdf` and reduces
9
+ * every read-back with `adfToText`; ADF never escapes the adapter
10
+ * (`api.js` normalises at the boundary).
11
+ *
12
+ * FIDELITY IS WORD-LEVEL, BY DESIGN. Every comparison downstream — verify's
13
+ * `compareStored`, the snapshot's `descriptionStream`, remote-edit detection —
14
+ * reduces text through the dialect's `canonicalForCompare` and then strips to
15
+ * word characters (sync-core `src/verify.js`). So the round-trip contract this
16
+ * module owes is: `stream(adfToText(mdToAdf(md))) === stream(md)` — no word
17
+ * gained, none lost, in order. Byte-level markdown fidelity is deliberately
18
+ * NOT promised: Jira normalises its own tree on save anyway.
19
+ *
20
+ * The subset is what spec projections actually contain: headings, paragraphs,
21
+ * bullet/ordered lists (nested), task checklists, inline strong/em/code/links,
22
+ * fenced code, blockquotes, and top-level tables. Checklists render as ADF
23
+ * task lists (`taskList`/`taskItem`) so they are live checkboxes in Jira; the
24
+ * mirror is still read-only — a box ticked in Jira is overwritten on the next
25
+ * push, exactly like every other mirrored field.
26
+ *
27
+ * Pure string/JSON transforms: no I/O, no clock, no randomness (task-item
28
+ * localIds are sequential — Jira rewrites them on save and nothing here reads
29
+ * them back).
30
+ */
31
+
32
+ // --- inline: markdown text → ADF text nodes ---------------------------------
33
+
34
+ const text = (value, marks) => {
35
+ const node = { type: 'text', text: value }
36
+ if (marks && marks.length) node.marks = marks
37
+ return node
38
+ }
39
+
40
+ // Earliest match wins among: `code`, **strong**, *em*, [text](url). A `*`
41
+ // needs a non-space neighbour to open (so `2 * 3` stays literal), mirroring
42
+ // the flanking rule the rest of the toolchain uses.
43
+ function inlineNodes(src, marks = []) {
44
+ const out = []
45
+ let rest = String(src == null ? '' : src)
46
+ while (rest.length) {
47
+ const candidates = [
48
+ { at: rest.indexOf('`'), kind: 'code' },
49
+ { at: rest.indexOf('**'), kind: 'strong' },
50
+ { at: rest.search(/\*(?!\*)(?=\S)/), kind: 'em' },
51
+ { at: rest.search(/\[[^\]]*\]\([^)]*\)/), kind: 'link' },
52
+ ]
53
+ .filter((c) => c.at >= 0)
54
+ .sort((a, b) => a.at - b.at)
55
+ if (!candidates.length) {
56
+ out.push(text(rest, marks))
57
+ break
58
+ }
59
+ const { at, kind } = candidates[0]
60
+ if (at > 0) out.push(text(rest.slice(0, at), marks))
61
+ rest = rest.slice(at)
62
+ if (kind === 'code') {
63
+ const close = rest.indexOf('`', 1)
64
+ if (close < 0) {
65
+ out.push(text(rest, marks))
66
+ break
67
+ }
68
+ const body = rest.slice(1, close)
69
+ // ADF puts `code` in an exclusion group: Jira rejects the WHOLE document
70
+ // when a text node carries `code` beside strong/em (or any mark other
71
+ // than `link`, the one companion the schema allows). So inherited
72
+ // emphasis is DROPPED here rather than merged — `**x `+'`'+`y`+'`'+` z**`
73
+ // renders y as plain code, exactly as Jira's own editor treats the same
74
+ // markdown — which loses emphasis, never words (fidelity is word-level
75
+ // by design; see the module header).
76
+ const kept = marks.filter((m) => m.type === 'link')
77
+ if (body) out.push(text(body, [...kept, { type: 'code' }]))
78
+ rest = rest.slice(close + 1)
79
+ } else if (kind === 'strong') {
80
+ const close = rest.indexOf('**', 2)
81
+ if (close < 0) {
82
+ out.push(text(rest, marks))
83
+ break
84
+ }
85
+ out.push(...inlineNodes(rest.slice(2, close), [...marks, { type: 'strong' }]))
86
+ rest = rest.slice(close + 2)
87
+ } else if (kind === 'em') {
88
+ const m = /^\*(?!\*)([^*]+)\*/.exec(rest)
89
+ if (!m) {
90
+ out.push(text(rest.slice(0, 1), marks))
91
+ rest = rest.slice(1)
92
+ continue
93
+ }
94
+ out.push(...inlineNodes(m[1], [...marks, { type: 'em' }]))
95
+ rest = rest.slice(m[0].length)
96
+ } else {
97
+ const m = /^\[([^\]]*)\]\(([^)]*)\)/.exec(rest)
98
+ out.push(...inlineNodes(m[1], [...marks, { type: 'link', attrs: { href: m[2] } }]))
99
+ rest = rest.slice(m[0].length)
100
+ }
101
+ }
102
+ return out.length ? out : [text('', marks)]
103
+ }
104
+
105
+ const paragraph = (md) => ({ type: 'paragraph', content: inlineNodes(md) })
106
+
107
+ // --- blocks: markdown lines → ADF nodes -------------------------------------
108
+
109
+ const LIST_RE = /^(\s*)([-*+]|\d+\.)\s+(?:\[([ xX])\]\s+)?(.*)$/
110
+ const FENCE_RE = /^\s*```(.*)$/
111
+ const HEADING_RE = /^(#{1,6})\s+(.*)$/
112
+ const TABLE_SEP_RE = /^\s*\|[\s:|-]+\|\s*$/
113
+
114
+ // One list ITEM's parsed shape, before nesting is resolved.
115
+ function parseListLines(lines) {
116
+ const items = []
117
+ for (const line of lines) {
118
+ const m = LIST_RE.exec(line)
119
+ if (m) {
120
+ items.push({
121
+ indent: m[1].length,
122
+ ordered: /^\d+\.$/.test(m[2]),
123
+ check: m[3] === undefined ? null : m[3] !== ' ',
124
+ text: m[4],
125
+ })
126
+ } else if (items.length) {
127
+ // Continuation line: part of the previous item's text (soft wrap).
128
+ items[items.length - 1].text += ' ' + line.trim()
129
+ }
130
+ }
131
+ return items
132
+ }
133
+
134
+ // Fold flat items into nested ADF lists by indent. Task items become an ADF
135
+ // taskList (live checkboxes); plain items a bulletList/orderedList.
136
+ function buildList(items, taskIds, options) {
137
+ const root = { children: [] }
138
+ const stack = [{ indent: -1, node: root }]
139
+ for (const item of items) {
140
+ while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) stack.pop()
141
+ const parent = stack[stack.length - 1].node
142
+ const entry = { ...item, children: [] }
143
+ parent.children.push(entry)
144
+ stack.push({ indent: item.indent, node: entry })
145
+ }
146
+
147
+ function render(children) {
148
+ const groups = []
149
+ for (const child of children) {
150
+ const kind = child.check !== null ? 'task' : child.ordered ? 'ordered' : 'bullet'
151
+ const last = groups[groups.length - 1]
152
+ if (last && last.kind === kind) last.items.push(child)
153
+ else groups.push({ kind, items: [child] })
154
+ }
155
+ return groups.map((group) => {
156
+ if (group.kind === 'task' && options && options.taskLists === false) {
157
+ // The fallback switch: should the live smoke find a surface where Jira
158
+ // drops `taskList` nodes, checklists render as plain bullets carrying
159
+ // their checkbox as text — words preserved, interactivity forfeited.
160
+ return {
161
+ type: 'bulletList',
162
+ content: group.items.map((item) => ({
163
+ type: 'listItem',
164
+ content: [paragraph(`[${item.check ? 'x' : ' '}] ${item.text}`), ...render(item.children)],
165
+ })),
166
+ }
167
+ }
168
+ if (group.kind === 'task') {
169
+ return {
170
+ type: 'taskList',
171
+ attrs: { localId: `tasks-${++taskIds.n}` },
172
+ content: group.items.map((item) => ({
173
+ type: 'taskItem',
174
+ attrs: { localId: `task-${++taskIds.n}`, state: item.check ? 'DONE' : 'TODO' },
175
+ content: inlineNodes(item.text),
176
+ // ADF task items cannot nest lists; a nested child is rare enough in
177
+ // a projection that flattening it to a following sibling is fine.
178
+ })).concat(group.items.flatMap((item) => render(item.children)).flat()),
179
+ }
180
+ }
181
+ const type = group.kind === 'ordered' ? 'orderedList' : 'bulletList'
182
+ return {
183
+ type,
184
+ content: group.items.map((item) => ({
185
+ type: 'listItem',
186
+ content: [paragraph(item.text), ...render(item.children)],
187
+ })),
188
+ }
189
+ })
190
+ }
191
+ return render(root.children)
192
+ }
193
+
194
+ function buildTable(rows) {
195
+ const split = (line) => {
196
+ const cells = []
197
+ let cur = ''
198
+ let code = false
199
+ for (const ch of line.replace(/^\s*\|/, '').replace(/\|\s*$/, '')) {
200
+ if (ch === '`') code = !code
201
+ if (ch === '|' && !code) {
202
+ cells.push(cur.trim())
203
+ cur = ''
204
+ continue
205
+ }
206
+ cur += ch
207
+ }
208
+ cells.push(cur.trim())
209
+ return cells
210
+ }
211
+ const bodyRows = rows.filter((r) => !TABLE_SEP_RE.test(r))
212
+ return {
213
+ type: 'table',
214
+ content: bodyRows.map((row, i) => ({
215
+ type: 'tableRow',
216
+ content: split(row).map((cell) => ({
217
+ type: i === 0 ? 'tableHeader' : 'tableCell',
218
+ content: [paragraph(cell)],
219
+ })),
220
+ })),
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Markdown → an ADF document (`{ version: 1, type: 'doc', content: [...] }`).
226
+ */
227
+ function mdToAdf(md, options) {
228
+ const lines = String(md == null ? '' : md).replace(/\r\n/g, '\n').split('\n')
229
+ const content = []
230
+ const taskIds = { n: 0 }
231
+ let i = 0
232
+ while (i < lines.length) {
233
+ const line = lines[i]
234
+ if (!line.trim()) {
235
+ i++
236
+ continue
237
+ }
238
+ const fence = FENCE_RE.exec(line)
239
+ if (fence) {
240
+ const body = []
241
+ i++
242
+ while (i < lines.length && !FENCE_RE.test(lines[i])) body.push(lines[i++])
243
+ if (i < lines.length) i++
244
+ const node = { type: 'codeBlock', content: body.length ? [text(body.join('\n'))] : [] }
245
+ if (fence[1].trim()) node.attrs = { language: fence[1].trim() }
246
+ content.push(node)
247
+ continue
248
+ }
249
+ const heading = HEADING_RE.exec(line)
250
+ if (heading) {
251
+ content.push({ type: 'heading', attrs: { level: heading[1].length }, content: inlineNodes(heading[2]) })
252
+ i++
253
+ continue
254
+ }
255
+ if (/^\s*>/.test(line)) {
256
+ const quoted = []
257
+ while (i < lines.length && /^\s*>/.test(lines[i])) quoted.push(lines[i++].replace(/^\s*> ?/, ''))
258
+ content.push({ type: 'blockquote', content: mdToAdf(quoted.join('\n')).content })
259
+ continue
260
+ }
261
+ if (/^\s*\|.*\|\s*$/.test(line) && TABLE_SEP_RE.test(lines[i + 1] || '')) {
262
+ const rows = []
263
+ while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(lines[i++])
264
+ content.push(buildTable(rows))
265
+ continue
266
+ }
267
+ if (LIST_RE.test(line)) {
268
+ const block = []
269
+ while (i < lines.length && lines[i].trim() && (LIST_RE.test(lines[i]) || /^\s{2,}/.test(lines[i]))) {
270
+ block.push(lines[i++])
271
+ }
272
+ content.push(...buildList(parseListLines(block), taskIds, options))
273
+ continue
274
+ }
275
+ if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(line)) {
276
+ content.push({ type: 'rule' })
277
+ i++
278
+ continue
279
+ }
280
+ // Paragraph: consecutive plain lines are one soft-wrapped paragraph.
281
+ const para = []
282
+ while (
283
+ i < lines.length &&
284
+ lines[i].trim() &&
285
+ !FENCE_RE.test(lines[i]) &&
286
+ !HEADING_RE.test(lines[i]) &&
287
+ !LIST_RE.test(lines[i]) &&
288
+ !/^\s*>/.test(lines[i]) &&
289
+ !/^\s*\|.*\|\s*$/.test(lines[i])
290
+ ) {
291
+ para.push(lines[i++].trim())
292
+ }
293
+ content.push(paragraph(para.join(' ')))
294
+ }
295
+ return { version: 1, type: 'doc', content }
296
+ }
297
+
298
+ // --- ADF → text --------------------------------------------------------------
299
+
300
+ function inlineText(nodes) {
301
+ let out = ''
302
+ for (const node of nodes || []) {
303
+ if (node.type === 'text') {
304
+ let t = node.text || ''
305
+ for (const mark of node.marks || []) {
306
+ if (mark.type === 'strong') t = `**${t}**`
307
+ else if (mark.type === 'em') t = `*${t}*`
308
+ else if (mark.type === 'code') t = `\`${t}\``
309
+ else if (mark.type === 'link') t = `[${t}](${(mark.attrs && mark.attrs.href) || ''})`
310
+ }
311
+ out += t
312
+ } else if (node.type === 'hardBreak') {
313
+ out += '\n'
314
+ } else if (node.type === 'mention') {
315
+ out += (node.attrs && node.attrs.text) || ''
316
+ } else if (node.type === 'emoji') {
317
+ out += (node.attrs && node.attrs.text) || ''
318
+ } else if (node.content) {
319
+ out += inlineText(node.content)
320
+ }
321
+ }
322
+ return out
323
+ }
324
+
325
+ function listToText(node, indent, ordered) {
326
+ const lines = []
327
+ let n = 0
328
+ for (const item of node.content || []) {
329
+ n++
330
+ const marker = ordered ? `${n}.` : '-'
331
+ const inner = []
332
+ const nested = []
333
+ for (const child of item.content || []) {
334
+ if (child.type === 'paragraph') inner.push(inlineText(child.content))
335
+ else nested.push(child)
336
+ }
337
+ lines.push(`${indent}${marker} ${inner.join(' ')}`)
338
+ for (const child of nested) lines.push(blockToText(child, indent + ' '))
339
+ }
340
+ return lines.join('\n')
341
+ }
342
+
343
+ function blockToText(node, indent = '') {
344
+ switch (node.type) {
345
+ case 'heading':
346
+ return `${indent}${'#'.repeat((node.attrs && node.attrs.level) || 1)} ${inlineText(node.content)}`
347
+ case 'paragraph':
348
+ return indent + inlineText(node.content)
349
+ case 'bulletList':
350
+ return listToText(node, indent, false)
351
+ case 'orderedList':
352
+ return listToText(node, indent, true)
353
+ case 'taskList': {
354
+ const lines = []
355
+ for (const item of node.content || []) {
356
+ if (item.type === 'taskItem') {
357
+ const mark = item.attrs && item.attrs.state === 'DONE' ? 'x' : ' '
358
+ lines.push(`${indent}- [${mark}] ${inlineText(item.content)}`)
359
+ } else {
360
+ lines.push(blockToText(item, indent + ' '))
361
+ }
362
+ }
363
+ return lines.join('\n')
364
+ }
365
+ case 'codeBlock': {
366
+ const lang = (node.attrs && node.attrs.language) || ''
367
+ return `${indent}\`\`\`${lang}\n${inlineText(node.content)}\n${indent}\`\`\``
368
+ }
369
+ case 'blockquote':
370
+ return (node.content || [])
371
+ .map((child) => blockToText(child, indent))
372
+ .join('\n\n')
373
+ .split('\n')
374
+ .map((l) => `${indent}> ${l}`)
375
+ .join('\n')
376
+ case 'table': {
377
+ const rows = []
378
+ for (const row of node.content || []) {
379
+ const cells = (row.content || []).map((cell) => inlineText((cell.content || [])).replace(/\n/g, ' '))
380
+ rows.push(`${indent}| ${cells.join(' | ')} |`)
381
+ if (!rows.seenSep && row.content && row.content[0] && row.content[0].type === 'tableHeader') {
382
+ rows.push(`${indent}|${' -- |'.repeat(row.content.length)}`)
383
+ rows.seenSep = true
384
+ }
385
+ }
386
+ return rows.join('\n')
387
+ }
388
+ case 'rule':
389
+ return `${indent}---`
390
+ case 'mediaGroup':
391
+ case 'mediaSingle':
392
+ return ''
393
+ default:
394
+ // Unknown block: render its children rather than dropping words —
395
+ // losing content silently is the one failure verify exists to catch.
396
+ return node.content ? node.content.map((child) => blockToText(child, indent)).join('\n\n') : ''
397
+ }
398
+ }
399
+
400
+ /**
401
+ * An ADF document (or fragment) → markdown-ish text. Accepts anything
402
+ * plausible: a full doc, a content array, a string (returned as-is), or null.
403
+ */
404
+ function adfToText(adf) {
405
+ if (adf == null) return ''
406
+ if (typeof adf === 'string') return adf
407
+ const content = Array.isArray(adf) ? adf : adf.content || []
408
+ return content
409
+ .map((node) => blockToText(node))
410
+ .filter((s) => s !== '')
411
+ .join('\n\n')
412
+ }
413
+
414
+ module.exports = { mdToAdf, adfToText }