release-skill 0.1.1

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 (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Closed-world artifact inventory builder.
3
+ *
4
+ * Enumerates every file that belongs to the artifact world:
5
+ * 1. Git-tracked files (`git ls-files -z`)
6
+ * 2. Relevant untracked files (`git ls-files -o --exclude-standard -z`)
7
+ * 3. Tombstone entries from a previous lock (paths removed since last accept)
8
+ *
9
+ * Protocol-reserved directories (`.git/`, `.release-skill/runs/`,
10
+ * `transactions/`, `resolution-worktree/`, `objects/`) are excluded from
11
+ * enumeration. Policy, lock, and project config files inside `.release-skill/`
12
+ * are NOT excluded — they are explicit closed-world entries.
13
+ *
14
+ * Each enumerated path is validated through `readEntry` which rejects
15
+ * symlinks, hardlinks, and other dangerous filesystem types with PATH_UNSAFE.
16
+ *
17
+ * @module artifacts/inventory
18
+ */
19
+
20
+ import { promisify } from 'node:util';
21
+ import { execFile } from 'node:child_process';
22
+ import { access } from 'node:fs/promises';
23
+ import { canonicalJson, sha256Hex } from '../core/digest.mjs';
24
+ import { readEntry, digestEntryManifest } from './entry.mjs';
25
+
26
+ const execFileAsync = promisify(execFile);
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Protocol-reserved directory prefixes (excluded from inventory)
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Paths starting with any of these prefixes are protocol-reserved and
34
+ * excluded from the closed-world inventory.
35
+ *
36
+ * Note: `.release-skill/` itself is NOT reserved — only its `runs/`
37
+ * subdirectory. Policy, lock, and project config files inside
38
+ * `.release-skill/` remain inventory entries.
39
+ */
40
+ const RESERVED_PREFIXES = Object.freeze([
41
+ '.git/',
42
+ '.release-skill/runs/',
43
+ 'transactions/',
44
+ 'resolution-worktree/',
45
+ 'objects/',
46
+ ]);
47
+
48
+ /**
49
+ * Check whether a relative path is inside a protocol-reserved directory.
50
+ *
51
+ * @param {string} relPath
52
+ * @returns {boolean}
53
+ */
54
+ function isReservedPath(relPath) {
55
+ // Exact match for directory names (without trailing slash)
56
+ if (relPath === '.git' || relPath === 'transactions' || relPath === 'objects') {
57
+ return true;
58
+ }
59
+ return RESERVED_PREFIXES.some((prefix) => relPath.startsWith(prefix));
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Internal helpers
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Run a git command and return NUL-delimited output as an array of strings.
68
+ *
69
+ * @param {string} cwd - Working directory (repo root).
70
+ * @param {string[]} args - Git arguments (must include -z or -rz).
71
+ * @returns {Promise<string[]>} Split paths (empty strings filtered out).
72
+ */
73
+ async function gitLsFilesRaw(cwd, args) {
74
+ const { stdout } = await execFileAsync('git', ['ls-files', ...args], {
75
+ cwd,
76
+ shell: false,
77
+ maxBuffer: 50 * 1024 * 1024,
78
+ });
79
+ // NUL-delimited; filter empty strings from trailing NUL
80
+ return stdout.split('\0').filter((s) => s.length > 0);
81
+ }
82
+
83
+ /**
84
+ * Check if a path exists on disk (file, directory, or symlink).
85
+ *
86
+ * @param {string} absPath
87
+ * @returns {Promise<boolean>}
88
+ */
89
+ async function pathExists(absPath) {
90
+ try {
91
+ await access(absPath);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Public API
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Build a closed-world inventory for a repository.
104
+ *
105
+ * The inventory captures every artifact entry that belongs to the current
106
+ * release scope:
107
+ * - Git-tracked files
108
+ * - Relevant untracked files (not in .gitignore, not in reserved dirs)
109
+ * - Tombstone entries for paths removed since the previous lock
110
+ *
111
+ * Each entry is read through `readEntry` which rejects symlinks and
112
+ * hardlinks with PATH_UNSAFE.
113
+ *
114
+ * @param {object} options
115
+ * @param {string} options.root - Repository root (absolute).
116
+ * @param {object} options.policy - Validated artifact policy (must have `repository.id`).
117
+ * @param {object} [options.previousLock] - Previous artifact-lock for tombstone recovery.
118
+ * @param {string} [options.gitRef] - Git ref for base state (reserved for Task 4).
119
+ * @returns {Promise<Inventory>}
120
+ */
121
+ export async function buildInventory({ root, policy, previousLock, gitRef } = {}) {
122
+ const repositoryId = policy.repository.id;
123
+
124
+ // 1. Enumerate git-tracked files
125
+ const tracked = await gitLsFilesRaw(root, ['-z']);
126
+
127
+ // 2. Enumerate relevant untracked files (not in .gitignore, not tracked)
128
+ const untracked = await gitLsFilesRaw(root, ['-o', '--exclude-standard', '-z']);
129
+
130
+ // 3. Merge and deduplicate, filtering reserved paths
131
+ const allPaths = new Set();
132
+ for (const p of [...tracked, ...untracked]) {
133
+ if (!isReservedPath(p)) {
134
+ allPaths.add(p);
135
+ }
136
+ }
137
+
138
+ // 4. Add tombstone entries from previous lock (paths that were accepted
139
+ // before but are no longer present on disk or in git)
140
+ const tombstoneEntries = [];
141
+ if (previousLock && Array.isArray(previousLock.entries)) {
142
+ for (const lockEntry of previousLock.entries) {
143
+ if (lockEntry.path && !allPaths.has(lockEntry.path)) {
144
+ const absPath = `${root}/${lockEntry.path}`;
145
+ const exists = await pathExists(absPath);
146
+ if (!exists) {
147
+ tombstoneEntries.push(
148
+ Object.freeze({
149
+ kind: 'absent',
150
+ path: lockEntry.path,
151
+ }),
152
+ );
153
+ allPaths.add(lockEntry.path);
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ // 5. Read each enumerated path through readEntry (validates filesystem type)
160
+ const fileEntries = [];
161
+ for (const p of tracked) {
162
+ if (!isReservedPath(p)) {
163
+ fileEntries.push(await readEntry({ root, path: p, source: 'worktree' }));
164
+ }
165
+ }
166
+ for (const p of untracked) {
167
+ if (!isReservedPath(p)) {
168
+ fileEntries.push(await readEntry({ root, path: p, source: 'worktree' }));
169
+ }
170
+ }
171
+
172
+ // 6. Merge file entries with tombstone entries
173
+ const entries = [...fileEntries, ...tombstoneEntries];
174
+
175
+ // 7. Compute sorted paths and manifest digest
176
+ const paths = Object.freeze([...allPaths].sort());
177
+
178
+ return Object.freeze({
179
+ repositoryId,
180
+ entries: Object.freeze(entries),
181
+ paths,
182
+ manifestDigest: digestEntryManifest(
183
+ entries.map((e) => ({
184
+ path: e.path ?? '',
185
+ type: e.type ?? (e.kind === 'absent' ? 'absent' : ''),
186
+ mode: e.mode ?? '',
187
+ sha256: e.sha256 ?? '',
188
+ size: e.size ?? 0,
189
+ })),
190
+ ),
191
+ });
192
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Binary artifact three-way merge.
3
+ *
4
+ * Binary entries can only be fast-forwarded: exactly one side may differ
5
+ * from the base. Divergent two-side changes are always CONFLICT.
6
+ *
7
+ * @module artifacts/merge/binary
8
+ */
9
+
10
+ import { sha256Hex } from '../../core/digest.mjs';
11
+
12
+ /**
13
+ * Merge two binary modifications of the same base content.
14
+ *
15
+ * Accepts the changed side when only one side differs from the base.
16
+ * Returns CONFLICT when both sides changed to different content.
17
+ *
18
+ * @param {object} options
19
+ * @param {Buffer} options.base - Base (accepted) content.
20
+ * @param {Buffer} options.current - Current (human) content.
21
+ * @param {Buffer} options.generated - Generated (producer) content.
22
+ * @returns {{ status: 'MERGEABLE'|'CONFLICT', bytes?: Buffer, conflicts: object[] }}
23
+ */
24
+ export function mergeBinary({ base, current, generated }) {
25
+ const baseHash = sha256Hex(base);
26
+ const currentHash = sha256Hex(current);
27
+ const generatedHash = sha256Hex(generated);
28
+
29
+ const currentChanged = baseHash !== currentHash;
30
+ const generatedChanged = baseHash !== generatedHash;
31
+
32
+ // Both unchanged — MERGEABLE with base content
33
+ if (!currentChanged && !generatedChanged) {
34
+ return Object.freeze({
35
+ status: 'MERGEABLE',
36
+ bytes: base,
37
+ conflicts: Object.freeze([]),
38
+ });
39
+ }
40
+
41
+ // Only one side changed — accept that side
42
+ if (currentChanged && !generatedChanged) {
43
+ return Object.freeze({
44
+ status: 'MERGEABLE',
45
+ bytes: current,
46
+ conflicts: Object.freeze([]),
47
+ });
48
+ }
49
+
50
+ if (!currentChanged && generatedChanged) {
51
+ return Object.freeze({
52
+ status: 'MERGEABLE',
53
+ bytes: generated,
54
+ conflicts: Object.freeze([]),
55
+ });
56
+ }
57
+
58
+ // Both sides changed
59
+ if (currentHash === generatedHash) {
60
+ // Same change — accept
61
+ return Object.freeze({
62
+ status: 'MERGEABLE',
63
+ bytes: current,
64
+ conflicts: Object.freeze([]),
65
+ });
66
+ }
67
+
68
+ // Divergent — CONFLICT
69
+ return Object.freeze({
70
+ status: 'CONFLICT',
71
+ conflicts: Object.freeze([Object.freeze({
72
+ reason: 'binary divergent change',
73
+ currentHash,
74
+ generatedHash,
75
+ })]),
76
+ });
77
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Three-way artifact entry merge dispatcher.
3
+ *
4
+ * Classifies the existence/state of (base, current, generated) and
5
+ * delegates content merge to the appropriate driver (text, binary, tree).
6
+ *
7
+ * The existence table (design §10.1) is implemented here; content-level
8
+ * merge logic lives in the driver modules.
9
+ *
10
+ * Real artifact targets are never written by this module — results are
11
+ * returned as in-memory candidates.
12
+ *
13
+ * @module artifacts/merge/entry-merge
14
+ */
15
+
16
+ import { sha256Hex } from '../../core/digest.mjs';
17
+ import { mergeText } from './text.mjs';
18
+ import { mergeBinary } from './binary.mjs';
19
+ import { mergeTree } from './tree.mjs';
20
+ import { mergeMarkdown } from './markdown.mjs';
21
+ import { mergeJson } from './json.mjs';
22
+ import { mergeYaml } from './yaml.mjs';
23
+
24
+ // Re-export sub-module APIs for convenience
25
+ export { mergeText } from './text.mjs';
26
+ export { mergeBinary } from './binary.mjs';
27
+ export { mergeTree } from './tree.mjs';
28
+ export { mergeMarkdown } from './markdown.mjs';
29
+ export { mergeJson } from './json.mjs';
30
+ export { mergeYaml } from './yaml.mjs';
31
+
32
+ /**
33
+ * Merge two modifications of the same base artifact entry.
34
+ *
35
+ * Dispatches to the appropriate driver after applying the existence table
36
+ * (absent/present combinations).
37
+ *
38
+ * @param {object} options
39
+ * @param {object|null} options.base - Base entry ({ kind: 'absent' } or entry object).
40
+ * @param {object|null} options.current - Current entry.
41
+ * @param {object|null} options.generated - Generated entry.
42
+ * @param {'text'|'binary'|'tree'} [options.driver='text'] - Merge driver.
43
+ * @param {object} [options.options] - Driver-specific options (renameMap for tree).
44
+ * @returns {{ status: 'MERGEABLE'|'CONFLICT', candidate?: object }}
45
+ */
46
+ export function mergeEntry({ base, current, generated, driver = 'text', options } = {}) {
47
+ const hasBase = base && base.kind !== 'absent';
48
+ const hasCurrent = current && current.kind !== 'absent';
49
+ const hasGenerated = generated && generated.kind !== 'absent';
50
+
51
+ // -------------------------------------------------------------------
52
+ // Existence table — base absent
53
+ // -------------------------------------------------------------------
54
+ if (!hasBase) {
55
+ if (!hasCurrent && !hasGenerated) {
56
+ return Object.freeze({ status: 'MERGEABLE', candidate: Object.freeze({ kind: 'absent' }) });
57
+ }
58
+ if (hasCurrent && !hasGenerated) {
59
+ return Object.freeze({ status: 'MERGEABLE', candidate: current });
60
+ }
61
+ if (!hasCurrent && hasGenerated) {
62
+ return Object.freeze({ status: 'MERGEABLE', candidate: generated });
63
+ }
64
+ // Both present and differ → CONFLICT
65
+ if (!entriesEqual(current, generated)) {
66
+ return Object.freeze({ status: 'CONFLICT', candidate: undefined });
67
+ }
68
+ // Both present and equal → MERGEABLE
69
+ return Object.freeze({ status: 'MERGEABLE', candidate: current });
70
+ }
71
+
72
+ // -------------------------------------------------------------------
73
+ // Existence table — base present
74
+ // -------------------------------------------------------------------
75
+ if (!hasCurrent && !hasGenerated) {
76
+ return Object.freeze({ status: 'MERGEABLE', candidate: Object.freeze({ kind: 'absent' }) });
77
+ }
78
+
79
+ if (hasCurrent && !hasGenerated) {
80
+ // Producer deleted, human kept → accept producer delete
81
+ if (entriesEqual(base, current)) {
82
+ return Object.freeze({ status: 'MERGEABLE', candidate: Object.freeze({ kind: 'absent' }) });
83
+ }
84
+ // Human modified, producer deleted → CONFLICT
85
+ return Object.freeze({ status: 'CONFLICT', candidate: undefined });
86
+ }
87
+
88
+ if (!hasCurrent && hasGenerated) {
89
+ // Human deleted, producer kept → accept human delete
90
+ if (entriesEqual(base, generated)) {
91
+ return Object.freeze({ status: 'MERGEABLE', candidate: Object.freeze({ kind: 'absent' }) });
92
+ }
93
+ // Producer modified, human deleted → CONFLICT
94
+ return Object.freeze({ status: 'CONFLICT', candidate: undefined });
95
+ }
96
+
97
+ // Both present — check type compatibility
98
+ if (current.kind === 'tree' || generated.kind === 'tree') {
99
+ if (current.kind !== generated.kind) {
100
+ return Object.freeze({ status: 'CONFLICT', candidate: undefined });
101
+ }
102
+ // Both trees — merge as tree regardless of driver
103
+ const result = mergeTree({ base, current, generated, ...options });
104
+ return Object.freeze({ status: result.status, candidate: result.candidate });
105
+ }
106
+
107
+ // Both regular files — type/mode conflict check
108
+ if (current.type !== generated.type || current.mode !== generated.mode) {
109
+ return Object.freeze({ status: 'CONFLICT', candidate: undefined });
110
+ }
111
+
112
+ // Content identical → MERGEABLE with current
113
+ if (current.sha256 === generated.sha256) {
114
+ return Object.freeze({ status: 'MERGEABLE', candidate: current });
115
+ }
116
+
117
+ // Dispatch to driver
118
+ if (driver === 'tree') {
119
+ const result = mergeTree({ base, current, generated, ...options });
120
+ return Object.freeze({ status: result.status, candidate: result.candidate });
121
+ }
122
+
123
+ if (driver === 'binary') {
124
+ const result = mergeBinary({ base: base.bytes, current: current.bytes, generated: generated.bytes });
125
+ if (result.status === 'CONFLICT') {
126
+ return Object.freeze({ status: 'CONFLICT', candidate: undefined });
127
+ }
128
+ return Object.freeze({
129
+ status: 'MERGEABLE',
130
+ candidate: Object.freeze({
131
+ ...current,
132
+ bytes: result.bytes,
133
+ sha256: sha256Hex(result.bytes),
134
+ size: result.bytes.length,
135
+ }),
136
+ });
137
+ }
138
+
139
+ if (driver === 'markdown') {
140
+ const result = mergeMarkdown({
141
+ base: base.bytes, current: current.bytes, generated: generated.bytes, ...options,
142
+ });
143
+ if (result.status === 'CONFLICT' || result.status === 'STRUCTURE_INVALID') {
144
+ return Object.freeze({ status: result.status, candidate: undefined });
145
+ }
146
+ return Object.freeze({
147
+ status: 'MERGEABLE',
148
+ candidate: Object.freeze({
149
+ ...current,
150
+ bytes: result.bytes,
151
+ sha256: sha256Hex(result.bytes),
152
+ size: result.bytes.length,
153
+ }),
154
+ });
155
+ }
156
+
157
+ if (driver === 'json') {
158
+ const result = mergeJson({
159
+ base: base.bytes, current: current.bytes, generated: generated.bytes, ...options,
160
+ });
161
+ if (result.status === 'CONFLICT' || result.status === 'STRUCTURE_INVALID') {
162
+ return Object.freeze({ status: result.status, candidate: undefined });
163
+ }
164
+ return Object.freeze({
165
+ status: 'MERGEABLE',
166
+ candidate: Object.freeze({
167
+ ...current,
168
+ bytes: result.bytes,
169
+ sha256: sha256Hex(result.bytes),
170
+ size: result.bytes.length,
171
+ }),
172
+ });
173
+ }
174
+
175
+ if (driver === 'yaml') {
176
+ const result = mergeYaml({
177
+ base: base.bytes, current: current.bytes, generated: generated.bytes, ...options,
178
+ });
179
+ if (result.status === 'CONFLICT' || result.status === 'STRUCTURE_INVALID') {
180
+ return Object.freeze({ status: result.status, candidate: undefined });
181
+ }
182
+ return Object.freeze({
183
+ status: 'MERGEABLE',
184
+ candidate: Object.freeze({
185
+ ...current,
186
+ bytes: result.bytes,
187
+ sha256: sha256Hex(result.bytes),
188
+ size: result.bytes.length,
189
+ }),
190
+ });
191
+ }
192
+
193
+ // Default: text driver
194
+ const result = mergeText({ base: base.bytes, current: current.bytes, generated: generated.bytes });
195
+ if (result.status === 'CONFLICT') {
196
+ return Object.freeze({ status: 'CONFLICT', candidate: undefined });
197
+ }
198
+ return Object.freeze({
199
+ status: 'MERGEABLE',
200
+ candidate: Object.freeze({
201
+ ...current,
202
+ bytes: result.bytes,
203
+ sha256: sha256Hex(result.bytes),
204
+ size: result.bytes.length,
205
+ }),
206
+ });
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Internal helpers
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Check if two entries are equal across all content dimensions.
215
+ *
216
+ * @param {object} a
217
+ * @param {object} b
218
+ * @returns {boolean}
219
+ */
220
+ function entriesEqual(a, b) {
221
+ if (!a && !b) return true;
222
+ if (!a || !b) return false;
223
+ if (a.type !== b.type) return false;
224
+ if (a.mode !== b.mode) return false;
225
+ if (a.sha256 !== b.sha256) return false;
226
+ if (a.manifestDigest !== b.manifestDigest) return false;
227
+ return true;
228
+ }