@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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 (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -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 +606 -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 +88 -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 +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -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 +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -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 +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,248 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Spec resolution and listing for a provider CLI — how a spec argument becomes
5
+ * a snapshot directory, how a spec names its snapshot sidecar, and which specs
6
+ * a repo holds with which tracker links.
7
+ *
8
+ * The frontmatter key that links a spec is the provider's
9
+ * (`config.fields.identifier` — sync-core `src/fields.js`); everything else
10
+ * here is filesystem shape shared by every provider. `program` prefixes the
11
+ * user-facing refusals (`spec-sync: spec not found: …`).
12
+ */
13
+
14
+ const fs = require('node:fs')
15
+ const path = require('node:path')
16
+
17
+ const { execFileSync } = require('node:child_process')
18
+
19
+ const {
20
+ BUCKETS,
21
+ findSpecFolder,
22
+ resolveSpec,
23
+ liveWorktreePaths,
24
+ resolvePrimaryCheckout,
25
+ } = require('../../../env/resolve.js')
26
+ const { loadEnvConfig } = require('../../../env/config.js')
27
+ const { readSnapshot, parseFrontmatter, lintPhases, fieldsOf, projectionOf, compareStored } = require('../../sync-core')
28
+
29
+ function makeSpecHelpers({ program = 'spec-sync' } = {}) {
30
+ // Resolve a spec argument to its snapshot dir. Accepts a spec name/folder
31
+ // found under specs/** (preferred) or a literal path to a snapshot directory.
32
+ function resolveSnapshotDir(specArg, dir) {
33
+ const found = findSpecFolder(specArg, dir)
34
+ if (found) return found.path
35
+ const literal = path.resolve(dir, specArg)
36
+ if (fs.existsSync(literal) && fs.statSync(literal).isDirectory()) return literal
37
+ return null
38
+ }
39
+
40
+ // The identifier keying the snapshot sidecar: the spec's stamped identifier
41
+ // if set, else its folder name (so the engine is usable before a spec is
42
+ // linked).
43
+ function specIdentifier(snapshotDir, config) {
44
+ const field = fieldsOf(config).identifier
45
+ try {
46
+ const { frontmatter } = readSnapshot(snapshotDir, config)
47
+ if (frontmatter[field]) return String(frontmatter[field])
48
+ } catch {
49
+ /* fall through to folder name */
50
+ }
51
+ return path.basename(snapshotDir)
52
+ }
53
+
54
+ // Read a spec's stamped identifier without throwing: an unlinked spec, an
55
+ // unreadable file and a spec with no frontmatter all read as `null`.
56
+ function linkedIdentifier(overviewPath, config) {
57
+ return linkedFields(overviewPath, config).identifier
58
+ }
59
+
60
+ // The stamped identifier AND url, each `null` when absent. The url is only
61
+ // ever what a push stamped — never built from the identifier, because a url
62
+ // constructed here would be a guess about a tracker's address scheme.
63
+ function linkedFields(overviewPath, config) {
64
+ const fields = fieldsOf(config)
65
+ let raw
66
+ try {
67
+ raw = fs.readFileSync(overviewPath, 'utf-8')
68
+ } catch {
69
+ return { identifier: null, url: null }
70
+ }
71
+ const { data } = parseFrontmatter(raw)
72
+ return {
73
+ identifier: data[fields.identifier] ? String(data[fields.identifier]) : null,
74
+ url: fields.url && data[fields.url] ? String(data[fields.url]) : null,
75
+ }
76
+ }
77
+
78
+ // Every spec under specs/<bucket>/, paired with the tracker issue it is
79
+ // linked to (`null` when it has never been pushed). Folder specs read
80
+ // `snapshot.overviewFile`; legacy bare `<name>.md` specs read the file
81
+ // itself. Sorted for stable output.
82
+ function listSpecs(dir, config, options = {}) {
83
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
84
+ const specs = []
85
+ for (const bucket of BUCKETS) {
86
+ const root = path.join(dir, 'specs', bucket)
87
+ let entries
88
+ try {
89
+ entries = fs.readdirSync(root, { withFileTypes: true })
90
+ } catch {
91
+ continue
92
+ }
93
+ for (const entry of entries) {
94
+ let name
95
+ let overviewPath
96
+ if (entry.isDirectory()) {
97
+ name = entry.name
98
+ overviewPath = path.join(root, name, overviewFile)
99
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
100
+ name = entry.name.slice(0, -3)
101
+ overviewPath = path.join(root, entry.name)
102
+ } else {
103
+ continue
104
+ }
105
+ const linked = linkedFields(overviewPath, config)
106
+ const row = { spec: name, bucket, identifier: linked.identifier }
107
+ // Opt-in, so every listing that never asked for a url keeps its shape.
108
+ if (options.urls) row.url = linked.url
109
+ specs.push(row)
110
+ }
111
+ }
112
+ return specs.sort((a, b) => a.spec.localeCompare(b.spec))
113
+ }
114
+
115
+ // `listSpecs`, but each spec's BUCKET read from where that spec actually
116
+ // lives. `/spec-start` moves a spec to `in-progress/` on its own branch, so
117
+ // on the base branch an in-flight spec still reads `backlog` — and a spec
118
+ // authored in its own worktree and not yet landed is not here at all. Each
119
+ // live worktree is read too, and a spec found in ITS OWN worktree (the path
120
+ // `resolveSpec` names for it) wins over every other copy. `source` says
121
+ // which: `here`, `worktree`, or `other-tree` for a spec only some other
122
+ // worktree carries.
123
+ //
124
+ // WHAT WOULD FOOL THIS: a worktree left behind by a declined teardown is
125
+ // still live, so a finished spec can read from a stale tree. And with no
126
+ // `env.config.json` there is no worktree layout to resolve, so this is
127
+ // exactly `listSpecs` — the current tree's answer, as before.
128
+ function listSpecsLive(dir, config) {
129
+ const here = listSpecs(dir, config).map((s) => ({ ...s, source: 'here' }))
130
+ const git = (args) => {
131
+ try {
132
+ return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
133
+ } catch {
134
+ return null
135
+ }
136
+ }
137
+ let primary
138
+ let env
139
+ try {
140
+ primary = resolvePrimaryCheckout(dir, git) || dir
141
+ env = loadEnvConfig(primary)
142
+ } catch {
143
+ return here
144
+ }
145
+ if (!env.present) return here
146
+ // Compared as REAL paths: git reports a worktree resolved through
147
+ // symlinks (macOS /var → /private/var) where `resolveSpec` does not.
148
+ const real = (p) => {
149
+ try {
150
+ return fs.realpathSync(p)
151
+ } catch {
152
+ return path.resolve(p)
153
+ }
154
+ }
155
+ const self = real(dir)
156
+ const byName = new Map(here.map((s) => [s.spec, s]))
157
+ for (const listed of liveWorktreePaths(git)) {
158
+ const tree = real(listed)
159
+ if (tree === self) continue
160
+ for (const s of listSpecs(tree, config)) {
161
+ let own = null
162
+ try {
163
+ own = real(resolveSpec(s.spec, primary, env.config, { searchDirs: [tree] }).worktreePath)
164
+ } catch {
165
+ own = null
166
+ }
167
+ if (own === tree) byName.set(s.spec, { ...s, source: 'worktree' })
168
+ else if (!byName.has(s.spec)) byName.set(s.spec, { ...s, source: 'other-tree' })
169
+ }
170
+ }
171
+ return [...byName.values()].sort((a, b) => a.spec.localeCompare(b.spec))
172
+ }
173
+
174
+ // Resolve a spec argument to its folder, or null with a message on stdout.
175
+ // EVERY failure path here must print and EVERY caller must return a non-zero
176
+ // code: a push skill checks $? before it applies a plan, so a resolve failure
177
+ // that exits 0 reads as "nothing to do" rather than "I could not find the
178
+ // spec".
179
+ function resolveOrExit(specArg, dir, out) {
180
+ if (!specArg) {
181
+ out.write(`${program}: no spec given\n`)
182
+ return null
183
+ }
184
+ const snapshotDir = resolveSnapshotDir(specArg, dir)
185
+ if (!snapshotDir) {
186
+ out.write(`${program}: spec not found: ${specArg}\n`)
187
+ return null
188
+ }
189
+ return snapshotDir
190
+ }
191
+
192
+ // Phase-status warnings for a spec, one formatted line each. A spec states
193
+ // each phase's status three times and only the h1 emoji is read — get it
194
+ // wrong and the phase projects as `backlog`, pushes cleanly, and is recorded
195
+ // as intended: invisible. Reported by every subcommand that reads a
196
+ // projection, fatal in none — a legacy spec must still push.
197
+ function warningLines(snapshotDir, config) {
198
+ return lintPhases(snapshotDir, config).map((w) => ` warning ${w.file}: ${w.message}`)
199
+ }
200
+
201
+ // Emit warnings on stderr, keeping stdout pure for a machine-readable payload.
202
+ function warnToErr(snapshotDir, config, err) {
203
+ const lines = warningLines(snapshotDir, config)
204
+ if (lines.length) err.write(lines.join('\n') + '\n')
205
+ }
206
+
207
+ // Render the read-back comparison — the same check `verify` and `apply`
208
+ // both report through, so the two cannot disagree about what "intact" means.
209
+ function verifyLines(snapshotDir, config, stored, identifier) {
210
+ const projection = projectionOf(snapshotDir, config)
211
+ const checks = []
212
+ if (typeof stored.issue === 'string') checks.push(['issue', projection.description, stored.issue])
213
+ for (const [ref, text] of Object.entries(stored.subIssues || {})) {
214
+ const sub = projection.subIssues.find((s) => s.ref === ref)
215
+ if (!sub) {
216
+ checks.push([`sub-issue ${ref}`, null, text])
217
+ continue
218
+ }
219
+ checks.push([`sub-issue ${ref}`, sub.goal, text])
220
+ }
221
+
222
+ const lines = [`${program} verify: ${identifier}`]
223
+ let bad = 0
224
+ for (const [label, sent, got] of checks) {
225
+ if (sent == null) {
226
+ lines.push(` ?? ${label}: read back, but the projection has no such phase — stale ref?`)
227
+ bad++
228
+ continue
229
+ }
230
+ const r = compareStored(sent, got, config)
231
+ if (r.ok) continue
232
+ bad++
233
+ lines.push(
234
+ ` !! ${label}: the tracker stored different text — ${Math.abs(r.lost)} character(s) ` +
235
+ `${r.lost > 0 ? 'lost' : 'added'}, first difference at ${r.at}`,
236
+ ` sent: …${r.sentContext}…`,
237
+ ` stored: …${r.storedContext}…`,
238
+ )
239
+ }
240
+ if (!bad) lines.push(` ${checks.length} description(s) round-tripped intact`)
241
+ else lines.push(' the repo is unchanged and still correct; re-push to overwrite the mirror')
242
+ return lines
243
+ }
244
+
245
+ return { resolveSnapshotDir, specIdentifier, linkedIdentifier, listSpecs, listSpecsLive, resolveOrExit, warningLines, warnToErr, verifyLines }
246
+ }
247
+
248
+ module.exports = { makeSpecHelpers }
@@ -0,0 +1,22 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Which transport a command runs over: `--via` wins, else the config's
5
+ * declared default, else "api when a credential resolved, mcp when it didn't".
6
+ *
7
+ * This expression was repeated verbatim nine times in the first provider —
8
+ * hoisted so the precedence is decided once. Per-command DEGRADATION stays with
9
+ * the command: which operations are impossible over mcp (exit 1, say so) and
10
+ * which hand the skill instructions (exit 0) is a per-command judgment, not a
11
+ * transport fact.
12
+ */
13
+
14
+ function resolveTransport(flags, config, credentialOk) {
15
+ return (
16
+ (flags && flags.via) ||
17
+ (config && config.apply && config.apply.transport) ||
18
+ (credentialOk ? 'api' : 'mcp')
19
+ )
20
+ }
21
+
22
+ module.exports = { resolveTransport }
@@ -0,0 +1,92 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Provider-neutral one-way sync engine (repo → tracker).
5
+ *
6
+ * Every function is parameterised by a plain `config` object — it knows nothing
7
+ * about any specific tracker. The repo is the source of truth: the engine builds
8
+ * a local projection, diffs it against a committed last-pushed snapshot
9
+ * (`planChanges`), and returns a create/update plan the provider skill applies
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`).
14
+ */
15
+
16
+ const { ownsField, normalizeLocal, LIFECYCLE_BUCKETS, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, remoteStateReading, stateNamesFor, titleFromText, validateStates, stateSuggestions, phaseModeFor } = require('./src/normalize.js')
17
+ const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify, remoteDescriptionEdited, remoteSubIssueEdited } = require('./src/compare.js')
18
+ const { readBase, writeBase } = require('./src/base.js')
19
+ const { push, recordPush, projectionOf } = require('./src/push.js')
20
+ const { writeFrontmatter, deleteFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
21
+ const { detectLegacyMirror } = require('./src/legacy.js')
22
+ const { compareStored, stream } = require('./src/verify.js')
23
+ const { identityDialect, dialectOf } = require('./src/dialect.js')
24
+ const { fieldsOf } = require('./src/fields.js')
25
+ const { renderTaskBlock, findTaskBlocks, wrapEmphasisAware, collapseHyphenAware, inferWidth, fenceMask, DEFAULT_WIDTH } = require('./src/task-block.js')
26
+ const { planRetarget, applyRetarget, deriveRecordedKey, isEmptyRetarget, dirtyPaths } = require('./src/retarget.js')
27
+
28
+ module.exports = {
29
+ ownsField,
30
+ normalizeLocal,
31
+ // Exported for `spec-sync list`: an `inline`-mode spec has no phase sub-issues
32
+ // to read, so the listing must resolve the mode rather than assume `subissue`.
33
+ phaseModeFor,
34
+ lintPhases,
35
+ readSnapshot,
36
+ parseFrontmatter,
37
+ projectionOf,
38
+ planChanges,
39
+ snapshotOf,
40
+ isEmptyPlan,
41
+ push,
42
+ recordPush,
43
+ remoteWorkflowState,
44
+ remoteStateReading,
45
+ stateNamesFor,
46
+ titleFromText,
47
+ validateStates,
48
+ stateSuggestions,
49
+ hashField,
50
+ stableStringify,
51
+ readBase,
52
+ writeBase,
53
+ writeFrontmatter,
54
+ deleteFrontmatter,
55
+ stampSubIssueId,
56
+ stampIssueId,
57
+ findPhaseFileByTitle,
58
+ listPhaseFiles,
59
+ detectLegacyMirror,
60
+ compareStored,
61
+ // The tolerant reduction behind both `compareStored` and the snapshot's
62
+ // `descriptionStream` hash — exported so a provider can reduce a read-back the
63
+ // same way rather than writing a second one that disagrees.
64
+ stream,
65
+ // Did someone else edit the tracker's description since the last push?
66
+ // Three-valued; `null` is cannot-tell and must never be reported as drift.
67
+ remoteDescriptionEdited,
68
+ remoteSubIssueEdited,
69
+ // Provider seams: the frontmatter field vocabulary and the markdown dialect
70
+ // a provider's config supplies (`config.fields` / `config.dialect`).
71
+ fieldsOf,
72
+ identityDialect,
73
+ dialectOf,
74
+ // The lifecycle buckets, exported once so providers and tests stop growing
75
+ // private copies (the tracker-free common package keeps its own — it cannot
76
+ // depend on this package).
77
+ LIFECYCLE_BUCKETS,
78
+ // Task-block helpers a provider's own text tooling builds on (the Linear
79
+ // sanitiser reflows emphasis-aware with these).
80
+ renderTaskBlock,
81
+ findTaskBlocks,
82
+ wrapEmphasisAware,
83
+ collapseHyphenAware,
84
+ inferWidth,
85
+ fenceMask,
86
+ DEFAULT_WIDTH,
87
+ planRetarget,
88
+ applyRetarget,
89
+ deriveRecordedKey,
90
+ isEmptyRetarget,
91
+ dirtyPaths,
92
+ }
@@ -0,0 +1,81 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The committed **last-pushed snapshot** sidecar.
5
+ *
6
+ * One-way sync (repo → Linear) records what it last pushed per spec at
7
+ * `{sync.baseDir}/{identifier}.base.json`, committed so each worktree carries its
8
+ * own snapshot. The snapshot is a set of content hashes
9
+ * (`{ issue, subIssues: {id:hash} }`, see compare.js
10
+ * `snapshotOf`); `planChanges` diffs the current projection against it to decide
11
+ * create/update/skip — no remote read. After a successful push the engine
12
+ * rewrites it (`writeBase`). Generic JSON read/write; the shape is the caller's.
13
+ */
14
+
15
+ const fs = require('node:fs')
16
+ const path = require('node:path')
17
+
18
+ function baseFile(dir, identifier, config) {
19
+ return path.join(dir, config.sync.baseDir, `${identifier}.base.json`)
20
+ }
21
+
22
+ /**
23
+ * Read a spec's committed base. Returns the parsed object, or `null` when no base
24
+ * exists yet (never synced) — the compare treats null as "no prior state".
25
+ */
26
+ function readBase(dir, identifier, config) {
27
+ const file = baseFile(dir, identifier, config)
28
+ let raw
29
+ try {
30
+ raw = fs.readFileSync(file, 'utf-8')
31
+ } catch (error) {
32
+ if (error.code === 'ENOENT') return null
33
+ throw error
34
+ }
35
+ try {
36
+ return JSON.parse(raw)
37
+ } catch (error) {
38
+ throw new Error(`Invalid base ${path.relative(dir, file)}: ${error.message}`)
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Rewrite a spec's committed base with the freshly-synced field set. Creates
44
+ * `{sync.baseDir}` if needed. Returns the absolute path written.
45
+ */
46
+ function writeBase(dir, identifier, config, data) {
47
+ const file = baseFile(dir, identifier, config)
48
+ fs.mkdirSync(path.dirname(file), { recursive: true })
49
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8')
50
+ return file
51
+ }
52
+
53
+ /**
54
+ * Back up the about-to-be-clobbered `side` ('local' | 'remote') into
55
+ * `{sync.backupDir}` before a --force. `timestamp` is caller-supplied (the engine
56
+ * never reads the clock); the name is made collision-safe with a `-N` counter.
57
+ * Returns the absolute path written, or null when `data` is nullish (nothing to
58
+ * back up — e.g. forcing a pull with no prior remote).
59
+ */
60
+ function backup(side, dir, identifier, config, { timestamp, data }) {
61
+ if (data == null) return null
62
+ const backupRoot = path.join(dir, config.sync.backupDir)
63
+ fs.mkdirSync(backupRoot, { recursive: true })
64
+
65
+ const stem = `${identifier}.${side}.${timestamp}`
66
+ let file = path.join(backupRoot, `${stem}.json`)
67
+ let n = 1
68
+ while (fs.existsSync(file)) {
69
+ file = path.join(backupRoot, `${stem}-${n}.json`)
70
+ n += 1
71
+ }
72
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8')
73
+ return file
74
+ }
75
+
76
+ module.exports = {
77
+ readBase,
78
+ writeBase,
79
+ backup,
80
+ baseFile,
81
+ }