@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,197 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The apply ORCHESTRATION — the ordering, stamping and accounting shared by
5
+ * every provider's `spec-sync apply`, with the tracker calls themselves left
6
+ * to provider `ops` (deferred to this shape until two providers existed to
7
+ * validate it: one folds workflow state into its create/update payloads, the
8
+ * other must move status by a separate transition call — so state application
9
+ * lives inside the ops, and the skeleton owns only what is identical).
10
+ *
11
+ * What the skeleton owns, in order:
12
+ *
13
+ * 1. resolve every workflow state BEFORE the first write, so a bad
14
+ * `config.states` value cannot strand the spec mid-apply;
15
+ * 2. the twin-mint guard: an EXACT-title issue held by no spec is a positive
16
+ * signal the issue already exists — refuse to mint a second (a fuzzy
17
+ * match must never count: being wrong here blocks a legitimate push);
18
+ * 3. mint-or-update the spec issue, stamping the identifier the moment the
19
+ * create lands — an interrupt after that point must not mint a twin;
20
+ * 4. sub-issue creates, each STAMPED AS IT LANDS, then updates (keyed by
21
+ * ref, never by id — the read-back matches by ref);
22
+ * 5. read back what the tracker stored and run the same check `verify` runs;
23
+ * 6. record the snapshot from the now-stamped files, so the next push is
24
+ * empty.
25
+ *
26
+ * Orphan accounting rides the whole way: `created` is what the tracker holds,
27
+ * `stamped` what the repo recorded, and the one case a re-run makes WORSE is a
28
+ * create whose stamp could not be written — the identifiers travel out on the
29
+ * error so the caller can name them.
30
+ *
31
+ * @param {object} ops the provider's tracker calls:
32
+ * - `prevalidateStates(buckets)` throw early on a bad state name
33
+ * - `mintIssue({ title, issue })` → `{ id, identifier, url }`
34
+ * - `updateIssue(existing, issue)` apply `plan.issue` to a live issue
35
+ * - `mintSubIssue(parentId, sub)` → `{ id, identifier }`
36
+ * - `updateSubIssue(sub)` apply a sub-issue update
37
+ * - `remoteEditedLine(identifier)` the someone-else-edited warning
38
+ * - `twinAdvice(twin, title, specName)` the refusal message for a twin
39
+ */
40
+
41
+ const path = require('node:path')
42
+
43
+ const {
44
+ readSnapshot,
45
+ readBase,
46
+ projectionOf,
47
+ recordPush,
48
+ writeFrontmatter,
49
+ stampSubIssueId,
50
+ fieldsOf,
51
+ remoteDescriptionEdited,
52
+ listPhaseFiles,
53
+ } = require('../../sync-core')
54
+ const { makeSpecHelpers } = require('./specs.js')
55
+
56
+ /**
57
+ * The outer accounting wrapper: run the inner apply, and on ANY throw attach
58
+ * what had been stamped versus created by the time it happened.
59
+ */
60
+ async function applySpecPlan(args) {
61
+ const progress = {}
62
+ try {
63
+ return await applyInner({ ...args, progress })
64
+ } catch (error) {
65
+ const r = progress.result
66
+ if (error && typeof error === 'object' && error.stamped === undefined) {
67
+ error.stamped = r ? (r.issue ? 1 : 0) + Object.keys(r.subIssues || {}).length : 0
68
+ error.created = progress.created || []
69
+ error.orphans = error.created.slice(error.stamped)
70
+ }
71
+ throw error
72
+ }
73
+ }
74
+
75
+ async function applyInner({ dir, config, snapshotDir, plan, adapter, ops, progress, forceNew = false, program = 'spec-sync' }) {
76
+ const fields = fieldsOf(config)
77
+ const helpers = makeSpecHelpers({ program })
78
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
79
+ const identifier = helpers.linkedIdentifier(path.join(snapshotDir, overviewFile), config)
80
+ const lines = []
81
+ const result = { issue: null, subIssues: {} }
82
+ // Handed out immediately, so a throw from anywhere below still reports what
83
+ // had been stamped by the time it happened.
84
+ if (progress) progress.result = result
85
+
86
+ // 1. Every state resolved BEFORE the first write.
87
+ const wanted = new Set()
88
+ if (plan.issue && plan.issue.state) wanted.add(plan.issue.state)
89
+ for (const s of (plan.subIssues && plan.subIssues.create) || []) if (s.state) wanted.add(s.state)
90
+ for (const s of (plan.subIssues && plan.subIssues.update) || []) if (s.state) wanted.add(s.state)
91
+ ops.prevalidateStates([...wanted])
92
+
93
+ let parentId = null
94
+ if (!identifier) {
95
+ // 2. REFUSE TO MINT A TWIN. An EXACT match held by no spec is a positive
96
+ // signal that the issue already exists; a near-match must never count —
97
+ // being wrong here blocks a legitimate push, and a renamed spec is
98
+ // answered by adopting the issue, not by fuzz.
99
+ if (!forceNew && adapter.searchIssues) {
100
+ const title = String(plan.title || readSnapshot(snapshotDir, config).title || '').trim()
101
+ if (title) {
102
+ let found = []
103
+ try {
104
+ found = (await ops.searchTwin(title)) || []
105
+ } catch {
106
+ // A search we could not run is not evidence of an orphan. Carry on
107
+ // and mint — the cannot-tell case goes to the harmless branch, which
108
+ // here is the one that does the work the caller asked for.
109
+ found = []
110
+ }
111
+ const claimed = new Set(helpers.listSpecs(dir, config).map((sp) => sp.identifier).filter(Boolean))
112
+ const twin = found.find(
113
+ (i) => i && i.identifier && !claimed.has(i.identifier) && String(i.title || '').trim() === title,
114
+ )
115
+ if (twin) throw new Error(ops.twinAdvice(twin, title, path.basename(snapshotDir)))
116
+ }
117
+ }
118
+ // 3. Mint, then stamp IMMEDIATELY: noted before it is stamped, so a stamp
119
+ // that cannot be written can still name the identifier it strands.
120
+ const created = await ops.mintIssue({ title: readSnapshot(snapshotDir, config).title, issue: plan.issue })
121
+ parentId = created.id
122
+ if (progress) (progress.created = progress.created || []).push(created.identifier)
123
+ writeFrontmatter(snapshotDir, config, {
124
+ [fields.identifier]: created.identifier,
125
+ [fields.url]: created.url,
126
+ })
127
+ result.issue = { id: created.id, identifier: created.identifier, url: created.url }
128
+ lines.push(` issue created: ${created.identifier}`)
129
+ } else {
130
+ const existing = await adapter.readIssue(identifier)
131
+ if (!existing || !existing.id) throw new Error(ops.missingIssueMessage(identifier))
132
+ parentId = existing.id
133
+ result.issue = { id: existing.id, identifier: existing.identifier, url: existing.url }
134
+
135
+ // SOMEONE ELSE EDITED THE DESCRIPTION SINCE THE LAST PUSH, and this write
136
+ // replaces it. A WARNING AND NEVER A REFUSAL: one-way sync means the repo
137
+ // wins; what was wrong was winning silently.
138
+ if (remoteDescriptionEdited(readBase(dir, identifier, config), existing, config) === true) {
139
+ lines.push(ops.remoteEditedLine(identifier))
140
+ }
141
+ if (plan.issue) {
142
+ await ops.updateIssue(existing, plan.issue)
143
+ lines.push(` issue updated: ${identifier}`)
144
+ }
145
+ }
146
+
147
+ // 4. Sub-issue creates — each stamped as it lands, for the same reason.
148
+ for (const sub of (plan.subIssues && plan.subIssues.create) || []) {
149
+ const created = await ops.mintSubIssue(parentId, sub)
150
+ if (progress) (progress.created = progress.created || []).push(created.identifier)
151
+ const file = resolvePhaseFileIn(snapshotDir, sub.ref)
152
+ if (!file) throw new Error(`no phase file for ref ${sub.ref}`)
153
+ stampSubIssueId(snapshotDir, file, created.identifier, config)
154
+ result.subIssues[sub.ref] = created.identifier
155
+ lines.push(` sub-issue created: ${sub.ref} → ${created.identifier}`)
156
+ }
157
+
158
+ // 5. Sub-issue updates — keyed by REF, never by id: the read-back matches
159
+ // sub-issues to phases by ref, and a plan written before updates carried a
160
+ // ref still resolves — by id, off the projection.
161
+ const refById = new Map()
162
+ for (const s of projectionOf(snapshotDir, config).subIssues || []) {
163
+ if (s.id != null) refById.set(String(s.id), s.ref)
164
+ }
165
+ for (const sub of (plan.subIssues && plan.subIssues.update) || []) {
166
+ await ops.updateSubIssue(sub)
167
+ result.subIssues[sub.ref || refById.get(String(sub.id)) || sub.id] = sub.id
168
+ lines.push(` sub-issue updated: ${sub.id}`)
169
+ }
170
+
171
+ // 6. Read back what the tracker stored and run the SAME check verify runs.
172
+ const stored = { issue: undefined, subIssues: {} }
173
+ if (result.issue) {
174
+ const back = await adapter.readIssue(result.issue.id)
175
+ if (back && typeof back.description === 'string') stored.issue = back.description
176
+ }
177
+ for (const [ref, id] of Object.entries(result.subIssues)) {
178
+ const back = await adapter.readIssue(id)
179
+ if (back && typeof back.description === 'string') stored.subIssues[ref] = back.description
180
+ }
181
+ const verify = helpers.verifyLines(snapshotDir, config, stored, result.issue ? result.issue.identifier : identifier)
182
+ lines.push(...verify.map((l) => ` ${l}`))
183
+ const lost = verify.some((l) => l.includes('!!') || l.includes('??'))
184
+
185
+ // 7. Record the snapshot from the now-stamped files, so the next push is empty.
186
+ const file = recordPush({ dir, snapshotDir, identifier: helpers.specIdentifier(snapshotDir, config), config })
187
+ lines.push(` snapshot: ${path.relative(dir, file)}`)
188
+ return { result, lines, lost }
189
+ }
190
+
191
+ // The phase file a sub-issue ref names — the ref is the phase-file basename.
192
+ function resolvePhaseFileIn(snapshotDir, ref) {
193
+ const want = String(ref).replace(/\.md$/, '')
194
+ return listPhaseFiles(snapshotDir).find((f) => f.replace(/\.md$/, '') === want) || null
195
+ }
196
+
197
+ module.exports = { applySpecPlan }
@@ -0,0 +1,68 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * A table-driven flag parser for a provider CLI, carrying the one discipline
5
+ * the first provider learned the hard way: an UNKNOWN `--flag` is a refusal,
6
+ * never a silent positional. (A renamed flag once parsed as a no-op — the
7
+ * repair invocation ran the wrong command, ignored the flag, and exited 0.)
8
+ *
9
+ * The table maps `--flag` → spec:
10
+ * - `{ kind: 'boolean' }` sets `flags[name] = true`
11
+ * - `{ kind: 'value' }` takes the next token verbatim
12
+ * - `{ kind: 'string' }` takes the next token, trimmed ('' kept)
13
+ * - `{ kind: 'number' }` Number(next token)
14
+ * - `{ kind: 'path' }` path.resolve(next token)
15
+ * - `{ kind: 'list' }` repeatable; pushes trimmed tokens
16
+ * - `{ kind: 'custom', take, handle }` `handle(flags, value, context)`;
17
+ * `take: false` consumes no token
18
+ * - any spec may set `name` (defaults to the camelCased flag)
19
+ *
20
+ * A spec name never starts with `--`, so the refusal cannot swallow a real
21
+ * argument. `renamed` maps an old flag to advice lines appended to the refusal,
22
+ * so a rename hands off instead of leaving the caller to guess.
23
+ */
24
+
25
+ const path = require('node:path')
26
+
27
+ const camel = (flag) => flag.replace(/^--/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())
28
+
29
+ function parseFlags(args, table, { defaults = {}, renamed = {}, context = {} } = {}) {
30
+ const flags = { ...defaults }
31
+ const positional = []
32
+ const unknown = []
33
+ for (let i = 0; i < args.length; i++) {
34
+ const token = args[i]
35
+ const spec = table[token]
36
+ if (!spec) {
37
+ if (String(token).startsWith('--')) unknown.push(token)
38
+ else positional.push(token)
39
+ continue
40
+ }
41
+ const name = spec.name || camel(token)
42
+ const kind = spec.kind || 'boolean'
43
+ if (kind === 'boolean') flags[name] = true
44
+ else if (kind === 'value') flags[name] = args[++i]
45
+ else if (kind === 'string') flags[name] = String(args[++i] || '').trim()
46
+ else if (kind === 'number') flags[name] = Number(args[++i])
47
+ else if (kind === 'path') flags[name] = path.resolve(args[++i])
48
+ else if (kind === 'list') (flags[name] = flags[name] || []).push(String(args[++i] || '').trim())
49
+ else if (kind === 'custom') {
50
+ const value = spec.take === false ? undefined : args[++i]
51
+ spec.handle(flags, value, context)
52
+ } else throw new Error(`parseFlags: unknown kind "${kind}" for ${token}`)
53
+ }
54
+ return { flags, positional, unknown }
55
+ }
56
+
57
+ // The refusal, phrased once. Returns the lines to print (the caller owns the
58
+ // stream and the exit code).
59
+ function unknownFlagLines(program, unknown, renamed = {}) {
60
+ const lines = [`${program}: unknown flag ${unknown.join(', ')}`]
61
+ for (const flag of unknown) {
62
+ if (renamed[flag]) for (const advice of [].concat(renamed[flag])) lines.push(` ${advice}`)
63
+ }
64
+ lines.push(` run \`${program}\` for the full usage.`)
65
+ return lines
66
+ }
67
+
68
+ module.exports = { parseFlags, unknownFlagLines }
@@ -0,0 +1,198 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Config-merge machinery shared by every provider's loader.
5
+ *
6
+ * A provider's config is a committed JSON file merged over its own defaults —
7
+ * only known keys copied (unknown ignored for forward-compat), typed fields
8
+ * validated LOUDLY where a typo would otherwise read as a deliberate choice.
9
+ * The defaults, the file name and the vocabularies are the provider's; the
10
+ * merge and validation shapes are identical across providers, so they live
11
+ * here, bound to the provider by `makeMergeHelpers`.
12
+ *
13
+ * Every thrown message names the provider's own config file, so a refusal
14
+ * points at the file the user actually edits.
15
+ */
16
+
17
+ function isObject(value) {
18
+ return value != null && typeof value === 'object' && !Array.isArray(value)
19
+ }
20
+
21
+ // Normalise an array config value to trimmed, non-empty strings.
22
+ function stringList(value) {
23
+ return value.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
24
+ }
25
+
26
+ // Copy a typed field from parsed[key] onto base[key] when it matches `type`.
27
+ // Strings are trimmed and must be non-empty to override; `string?` may be empty.
28
+ function assign(base, parsed, key, type) {
29
+ const v = parsed[key]
30
+ if (type === 'string') {
31
+ if (typeof v === 'string' && v.trim()) base[key] = v.trim()
32
+ } else if (type === 'string?') {
33
+ if (typeof v === 'string') base[key] = v
34
+ } else if (type === 'boolean') {
35
+ if (typeof v === 'boolean') base[key] = v
36
+ }
37
+ }
38
+
39
+ /**
40
+ * The provider-bound validators. `configFile` is only used in messages;
41
+ * `ownership` / `lifecycleBuckets` / `phaseMappings` are the provider's own
42
+ * vocabularies (they rarely differ between providers, but they are the
43
+ * provider's to declare).
44
+ */
45
+ function makeMergeHelpers({ configFile, ownership, lifecycleBuckets, phaseMappings }) {
46
+ // Merge (and validate) sync.fieldOwnership. Any key the caller lists joins
47
+ // the compared field set; the value MUST be one of the ownership modes.
48
+ function mergeFieldOwnership(base, parsed) {
49
+ if (!isObject(parsed)) return
50
+ for (const [field, dir] of Object.entries(parsed)) {
51
+ if (!ownership.includes(dir)) {
52
+ throw new Error(
53
+ `Invalid ${configFile}: sync.fieldOwnership.${field} = ${JSON.stringify(dir)} ` +
54
+ `(expected one of ${ownership.join('|')})`,
55
+ )
56
+ }
57
+ base[field] = dir
58
+ }
59
+ }
60
+
61
+ // Merge (and validate) `mapping.phases` in either of its two forms: a scalar
62
+ // mode for the whole repo, or a map of lifecycle bucket → mode. Loud on a bad
63
+ // key or value — a misspelt bucket would otherwise read as a deliberate
64
+ // default and go on minting the sub-issues the config was written to stop.
65
+ function mergePhaseMapping(base, parsed) {
66
+ const value = parsed.phases
67
+ if (typeof value === 'string') {
68
+ if (value.trim()) base.phases = value.trim()
69
+ } else if (isObject(value)) {
70
+ const byBucket = {}
71
+ for (const [bucket, mode] of Object.entries(value)) {
72
+ if (!lifecycleBuckets.includes(bucket)) {
73
+ throw new Error(
74
+ `Invalid ${configFile}: mapping.phases.${bucket} is not a lifecycle bucket ` +
75
+ `(expected one of ${lifecycleBuckets.join('|')})`,
76
+ )
77
+ }
78
+ if (!phaseMappings.includes(mode)) {
79
+ throw new Error(
80
+ `Invalid ${configFile}: mapping.phases.${bucket} = ${JSON.stringify(mode)} ` +
81
+ `(expected one of ${phaseMappings.join('|')})`,
82
+ )
83
+ }
84
+ byBucket[bucket] = mode
85
+ }
86
+ base.phases = byBucket
87
+ } else if (value !== undefined) {
88
+ throw new Error(
89
+ `Invalid ${configFile}: mapping.phases = ${JSON.stringify(value)} ` +
90
+ `(expected one of ${phaseMappings.join('|')}, or a map of lifecycle bucket to mode)`,
91
+ )
92
+ }
93
+ if (typeof base.phases === 'string' && !phaseMappings.includes(base.phases)) {
94
+ throw new Error(
95
+ `Invalid ${configFile}: mapping.phases = ${JSON.stringify(base.phases)} ` +
96
+ `(expected one of ${phaseMappings.join('|')}, or a map of lifecycle bucket to mode)`,
97
+ )
98
+ }
99
+ }
100
+
101
+ // Merge (and validate) `release.stages` — the project's deployment ladder.
102
+ //
103
+ // Loud on a malformed entry: this list names tracker states, and a tracker
104
+ // that silently ignores an unknown state would let a half-typed ladder push
105
+ // clean and move nothing — so a bad shape fails here, at load, rather than at
106
+ // the first deploy nobody is watching. An ABSENT `release` block is not an
107
+ // error: it is the opt-out, and the whole feature is unused without it.
108
+ function mergeReleaseStages(base, parsed) {
109
+ const value = parsed.stages
110
+ if (value === undefined) return
111
+ if (!Array.isArray(value)) {
112
+ throw new Error(
113
+ `Invalid ${configFile}: release.stages = ${JSON.stringify(value)} ` +
114
+ '(expected an array of { key, state })',
115
+ )
116
+ }
117
+ const stages = []
118
+ const seen = new Set()
119
+ value.forEach((entry, i) => {
120
+ if (!isObject(entry)) {
121
+ throw new Error(
122
+ `Invalid ${configFile}: release.stages[${i}] = ${JSON.stringify(entry)} ` +
123
+ '(expected { key, state })',
124
+ )
125
+ }
126
+ for (const field of ['key', 'state']) {
127
+ if (typeof entry[field] !== 'string' || !entry[field].trim()) {
128
+ throw new Error(
129
+ `Invalid ${configFile}: release.stages[${i}].${field} = ${JSON.stringify(entry[field])} ` +
130
+ '(expected a non-empty string)',
131
+ )
132
+ }
133
+ }
134
+ const key = entry.key.trim()
135
+ if (seen.has(key)) {
136
+ throw new Error(
137
+ `Invalid ${configFile}: release.stages[${i}].key = ${JSON.stringify(key)} is a duplicate ` +
138
+ '(a stage key is how CI names the rung, so it must be unique)',
139
+ )
140
+ }
141
+ seen.add(key)
142
+ stages.push({ key, state: entry.state.trim() })
143
+ })
144
+ base.stages = stages
145
+ }
146
+
147
+ // Merge (and validate) release.ignorePaths — the path prefixes whose commits
148
+ // are bookkeeping. Loud on anything but an array of non-empty strings: a bad
149
+ // value that quietly fell back would let a project believe it had opted out
150
+ // and go on double-counting tickets. A BLANK entry is rejected rather than
151
+ // dropped — `""` is a prefix of every path, so a stray empty string would
152
+ // silently ignore every commit in the range and report a release as
153
+ // containing nothing: the loudest possible wrong answer, arriving as silence.
154
+ function mergeReleaseIgnorePaths(base, parsed) {
155
+ const value = parsed.ignorePaths
156
+ if (value === undefined) return
157
+ if (!Array.isArray(value)) {
158
+ throw new Error(
159
+ `Invalid ${configFile}: release.ignorePaths = ${JSON.stringify(value)} ` +
160
+ '(expected an array of repo-relative path prefixes)',
161
+ )
162
+ }
163
+ value.forEach((entry, i) => {
164
+ if (typeof entry !== 'string' || !entry.trim()) {
165
+ throw new Error(
166
+ `Invalid ${configFile}: release.ignorePaths[${i}] = ${JSON.stringify(entry)} ` +
167
+ '(expected a non-empty repo-relative path prefix, e.g. "specs/")',
168
+ )
169
+ }
170
+ })
171
+ base.ignorePaths = stringList(value)
172
+ }
173
+
174
+ // Merge (and validate) sync.keyedFields. Each value is the item's id property
175
+ // name (a non-empty string); a field listed here is compared per item.
176
+ function mergeKeyedFields(base, parsed) {
177
+ if (!isObject(parsed)) return
178
+ for (const [field, idKey] of Object.entries(parsed)) {
179
+ if (typeof idKey !== 'string' || !idKey.trim()) {
180
+ throw new Error(
181
+ `Invalid ${configFile}: sync.keyedFields.${field} = ${JSON.stringify(idKey)} ` +
182
+ '(expected the item id property name, a non-empty string)',
183
+ )
184
+ }
185
+ base[field] = idKey.trim()
186
+ }
187
+ }
188
+
189
+ return {
190
+ mergeFieldOwnership,
191
+ mergePhaseMapping,
192
+ mergeReleaseStages,
193
+ mergeReleaseIgnorePaths,
194
+ mergeKeyedFields,
195
+ }
196
+ }
197
+
198
+ module.exports = { isObject, stringList, assign, makeMergeHelpers }