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.
- package/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- 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
|
+
}
|