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,743 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrap adoption engine and downstream closure.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - `planAdoption({ plan, policy, artifactId, currentEntries, generatedEntries, runProducer })`
|
|
6
|
+
* — compute multi-hunk protected diff, resolve adoption routes, run producer
|
|
7
|
+
* closure, and project downstream closure.
|
|
8
|
+
* - `discardBootstrapHunk({ adoptionPlan, currentEntries, artifactId, hunkDigest, expectedPlanDigest, actor, reason, action?, replacementBytes? })`
|
|
9
|
+
* — record a per-hunk discard/replace decision with actor + reason,
|
|
10
|
+
* re-read currentEntries to verify bytes, and derive a new plan digest.
|
|
11
|
+
*
|
|
12
|
+
* Adoption routes are exact route objects: `{target, sourceArtifact, mode}`.
|
|
13
|
+
* v1 supports mode `exact-copy`. String routes are rejected.
|
|
14
|
+
*
|
|
15
|
+
* Protected hunks: text files produce multiple hunks from line-level diff;
|
|
16
|
+
* binary files produce one whole-file hunk. Each hunk carries:
|
|
17
|
+
* `{artifactId, hunkDigest, baseDigest, currentDigest, candidateDigest, range}`.
|
|
18
|
+
*
|
|
19
|
+
* Convergence gate: all protected hunks must be either reproduced by the
|
|
20
|
+
* producer closure (with matching candidateDigest) or explicitly decided
|
|
21
|
+
* (discard/replace) before the plan can transition from `ADOPTION_REQUIRED`.
|
|
22
|
+
*
|
|
23
|
+
* Decision binding: `{artifactId, hunkDigest, baseDigest, currentDigest,
|
|
24
|
+
* candidateDigest, action, actor, reason, decisionDigest}` — deterministic,
|
|
25
|
+
* no timestamp or random fields.
|
|
26
|
+
*
|
|
27
|
+
* @module artifacts/adoption
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
ReleaseError,
|
|
32
|
+
ADOPTION_AMBIGUOUS,
|
|
33
|
+
PLAN_STALE,
|
|
34
|
+
MISSING_PARAMETERS,
|
|
35
|
+
} from '../core/errors.mjs';
|
|
36
|
+
import { canonicalJson, sha256Hex } from '../core/digest.mjs';
|
|
37
|
+
import { buildProducerGraph } from './graph.mjs';
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Public API
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compute an adoption plan from the init/inspect plan and current worktree state.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} options
|
|
47
|
+
* @param {object} options.plan - The init/inspect artifact plan.
|
|
48
|
+
* @param {object} options.policy - Validated artifact policy.
|
|
49
|
+
* @param {string} [options.artifactId] - Specific artifact to adopt.
|
|
50
|
+
* @param {Map<string, object>} options.currentEntries - Current worktree entries.
|
|
51
|
+
* @param {Map<string, object>} options.generatedEntries - Producer-generated entries.
|
|
52
|
+
* @param {Function} [options.runProducer] - Injected producer runner for closure.
|
|
53
|
+
* @returns {Promise<AdoptionPlan>} Frozen adoption plan.
|
|
54
|
+
* @throws {ReleaseError} ADOPTION_AMBIGUOUS if route resolution is ambiguous.
|
|
55
|
+
*/
|
|
56
|
+
export async function planAdoption({
|
|
57
|
+
plan,
|
|
58
|
+
policy,
|
|
59
|
+
artifactId,
|
|
60
|
+
currentEntries,
|
|
61
|
+
generatedEntries,
|
|
62
|
+
runProducer,
|
|
63
|
+
} = {}) {
|
|
64
|
+
validateAdoptionRoutes(policy);
|
|
65
|
+
const graph = buildProducerGraph(policy);
|
|
66
|
+
let closure = [];
|
|
67
|
+
let targetArtifactId = null;
|
|
68
|
+
let sourceArtifactId = null;
|
|
69
|
+
let route = null;
|
|
70
|
+
let sourceCandidate = null;
|
|
71
|
+
|
|
72
|
+
if (artifactId) {
|
|
73
|
+
const target = policy.artifacts.find((a) => a.id === artifactId);
|
|
74
|
+
if (!target || target.type !== 'generated') {
|
|
75
|
+
throw new ReleaseError(
|
|
76
|
+
MISSING_PARAMETERS,
|
|
77
|
+
`adoption target "${artifactId}" must be a generated artifact`,
|
|
78
|
+
{ artifactId },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const rootRoutes = (target.adoptionRoutes ?? []).filter((candidate) => candidate.target === '/');
|
|
83
|
+
if (rootRoutes.length !== 1) {
|
|
84
|
+
throw new ReleaseError(
|
|
85
|
+
ADOPTION_AMBIGUOUS,
|
|
86
|
+
`generated artifact "${artifactId}" requires exactly one adoption route for target "/"`,
|
|
87
|
+
{ artifactId, candidates: rootRoutes },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
route = rootRoutes[0];
|
|
91
|
+
sourceArtifactId = route.sourceArtifact;
|
|
92
|
+
const source = policy.artifacts.find((candidate) => candidate.id === sourceArtifactId);
|
|
93
|
+
if (!source || !target.sourceArtifacts?.includes(sourceArtifactId)) {
|
|
94
|
+
throw new ReleaseError(
|
|
95
|
+
ADOPTION_AMBIGUOUS,
|
|
96
|
+
`route for "${artifactId}" does not identify one registered direct source`,
|
|
97
|
+
{ artifactId, sourceArtifactId },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const currentTarget = currentEntries?.get(artifactId);
|
|
102
|
+
const currentTargetBytes = extractEntryBytes(currentTarget);
|
|
103
|
+
if (!currentTargetBytes || currentTarget?.kind !== 'regular') {
|
|
104
|
+
throw new ReleaseError(
|
|
105
|
+
MISSING_PARAMETERS,
|
|
106
|
+
`exact-copy adoption target "${artifactId}" must be a content-bearing regular entry`,
|
|
107
|
+
{ artifactId },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
sourceCandidate = Object.freeze({
|
|
111
|
+
artifactId: sourceArtifactId,
|
|
112
|
+
path: source.sourcePath,
|
|
113
|
+
kind: 'regular',
|
|
114
|
+
type: 'blob',
|
|
115
|
+
mode: currentTarget.mode ?? '100644',
|
|
116
|
+
sha256: sha256Hex(currentTargetBytes),
|
|
117
|
+
size: currentTargetBytes.length,
|
|
118
|
+
bytes: Buffer.from(currentTargetBytes),
|
|
119
|
+
content: Buffer.from(currentTargetBytes),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
closure = [artifactId, ...graph.downstreamClosure(artifactId)];
|
|
123
|
+
targetArtifactId = artifactId;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const protectedHunks = [];
|
|
127
|
+
for (const planArtifact of plan.artifacts ?? []) {
|
|
128
|
+
if (artifactId && !closure.includes(planArtifact.id)) continue;
|
|
129
|
+
const current = currentEntries?.get(planArtifact.id);
|
|
130
|
+
const generated = generatedEntries?.get(planArtifact.id);
|
|
131
|
+
const candidate = resolveCandidate(planArtifact, generated);
|
|
132
|
+
const hunks = collectProtectedHunks(planArtifact.id, current, candidate);
|
|
133
|
+
protectedHunks.push(...hunks);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let closureResults = null;
|
|
137
|
+
if (runProducer && artifactId) {
|
|
138
|
+
const inputSnapshot = new Map();
|
|
139
|
+
for (const [id, entry] of currentEntries ?? []) {
|
|
140
|
+
inputSnapshot.set(id, [entry]);
|
|
141
|
+
}
|
|
142
|
+
for (const [id, entry] of generatedEntries ?? []) {
|
|
143
|
+
if (!inputSnapshot.has(id)) {
|
|
144
|
+
inputSnapshot.set(id, [entry]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const targetPlanPath = (plan.artifacts ?? []).find((item) => item.id === artifactId)?.path;
|
|
148
|
+
inputSnapshot.set(artifactId, [Object.freeze({
|
|
149
|
+
...sourceCandidate,
|
|
150
|
+
path: projectionRelativePath(sourceCandidate.path, targetPlanPath),
|
|
151
|
+
})]);
|
|
152
|
+
|
|
153
|
+
closureResults = await runProducer({
|
|
154
|
+
artifactIds: closure,
|
|
155
|
+
inputSnapshot,
|
|
156
|
+
graph,
|
|
157
|
+
route,
|
|
158
|
+
sourceCandidate,
|
|
159
|
+
targetArtifactId,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const enrichedHunks = protectedHunks.map((hunk) => {
|
|
164
|
+
if (!closureResults) return Object.freeze({ ...hunk, reproduced: false });
|
|
165
|
+
const candidateManifest = closureResults.byArtifact?.get(hunk.artifactId);
|
|
166
|
+
if (!candidateManifest) return Object.freeze({ ...hunk, reproduced: false });
|
|
167
|
+
const outputs = candidateManifest.outputs ?? candidateManifest.entries ?? [];
|
|
168
|
+
const producedEntry = outputs.length === 1 ? outputs[0] : null;
|
|
169
|
+
if (!producedEntry) return Object.freeze({ ...hunk, reproduced: false });
|
|
170
|
+
const producedBytes = producedEntry.bytes ?? producedEntry.content;
|
|
171
|
+
if (!producedBytes) return Object.freeze({ ...hunk, reproduced: false });
|
|
172
|
+
const currentEntry = currentEntries?.get(hunk.artifactId);
|
|
173
|
+
const currentBytes = currentEntry?.bytes ?? currentEntry?.content;
|
|
174
|
+
if (!currentBytes) return Object.freeze({ ...hunk, reproduced: false });
|
|
175
|
+
// Whole-entry equality is stricter than matching only one shifted range and
|
|
176
|
+
// proves every protected hunk converged in the same producer run.
|
|
177
|
+
const reproduced = sha256Hex(producedBytes) === sha256Hex(currentBytes);
|
|
178
|
+
return Object.freeze({ ...hunk, reproduced });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const closureManifests = closureResults
|
|
182
|
+
? Object.freeze(Object.fromEntries(
|
|
183
|
+
[...closureResults.byArtifact.entries()].map(([id, manifest]) => [
|
|
184
|
+
id,
|
|
185
|
+
Object.freeze({
|
|
186
|
+
artifactId: id,
|
|
187
|
+
implementationDigest: manifest.implementationDigest ?? closureResults.implementationDigest ?? null,
|
|
188
|
+
inputManifestDigest: manifest.inputManifestDigest ?? null,
|
|
189
|
+
outputManifestDigest: manifest.outputManifestDigest ?? null,
|
|
190
|
+
outputDigest: digestManifestOutputs(manifest.outputs ?? manifest.entries ?? []),
|
|
191
|
+
}),
|
|
192
|
+
]),
|
|
193
|
+
))
|
|
194
|
+
: undefined;
|
|
195
|
+
|
|
196
|
+
const hasTargetClosureEvidence = Boolean(
|
|
197
|
+
artifactId && closure.length > 0 && closure.every((id) => closureResults?.byArtifact?.has(id)),
|
|
198
|
+
);
|
|
199
|
+
const allReproduced = enrichedHunks.every((h) => h.reproduced);
|
|
200
|
+
const status = artifactId
|
|
201
|
+
? (hasTargetClosureEvidence && allReproduced ? 'CONVERGED' : 'ADOPTION_REQUIRED')
|
|
202
|
+
: (enrichedHunks.length === 0 ? 'CLEAN' : 'ADOPTION_REQUIRED');
|
|
203
|
+
const producerImplementationDigest = closureManifests
|
|
204
|
+
? `sha256:${sha256Hex(canonicalJson(Object.fromEntries(
|
|
205
|
+
Object.entries(closureManifests).map(([id, manifest]) => [id, manifest.implementationDigest]),
|
|
206
|
+
)))}`
|
|
207
|
+
: undefined;
|
|
208
|
+
|
|
209
|
+
const digestInput = {
|
|
210
|
+
planDigest: plan.planDigest,
|
|
211
|
+
targetArtifactId,
|
|
212
|
+
sourceArtifactId,
|
|
213
|
+
route,
|
|
214
|
+
transactionClosure: closure,
|
|
215
|
+
sourceCandidate,
|
|
216
|
+
closureManifests,
|
|
217
|
+
artifactPaths: Object.fromEntries(
|
|
218
|
+
(plan.artifacts ?? []).filter((item) => item.path).map((item) => [item.id, item.path]),
|
|
219
|
+
),
|
|
220
|
+
};
|
|
221
|
+
return Object.freeze({
|
|
222
|
+
planDigest: deriveAdoptionDigest(digestInput, [], enrichedHunks),
|
|
223
|
+
status,
|
|
224
|
+
protectedHunks: Object.freeze(enrichedHunks),
|
|
225
|
+
hunkDecisions: Object.freeze([]),
|
|
226
|
+
transactionClosure: Object.freeze(closure),
|
|
227
|
+
targetArtifactId,
|
|
228
|
+
sourceArtifactId,
|
|
229
|
+
route,
|
|
230
|
+
sourceCandidate,
|
|
231
|
+
artifactPaths: Object.freeze(Object.fromEntries(
|
|
232
|
+
(plan.artifacts ?? []).filter((item) => item.path).map((item) => [item.id, item.path]),
|
|
233
|
+
)),
|
|
234
|
+
safeToWrite: false,
|
|
235
|
+
targetUnchanged: true,
|
|
236
|
+
...(producerImplementationDigest ? { producerImplementationDigest } : {}),
|
|
237
|
+
...(closureManifests ? { closureManifests } : {}),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Record a discard or replace decision for a single protected hunk.
|
|
243
|
+
*
|
|
244
|
+
* Re-reads currentEntries to verify bytes have not changed since the plan
|
|
245
|
+
* was created. If bytes changed, the operation is rejected with PLAN_STALE.
|
|
246
|
+
*
|
|
247
|
+
* @param {object} options
|
|
248
|
+
* @param {object} options.adoptionPlan - The current adoption plan.
|
|
249
|
+
* @param {Map<string, object>} options.currentEntries - Current worktree entries.
|
|
250
|
+
* @param {string} options.artifactId - Artifact containing the hunk.
|
|
251
|
+
* @param {string} options.hunkDigest - Digest of the hunk to decide.
|
|
252
|
+
* @param {string} options.expectedPlanDigest - Expected plan digest (stale guard).
|
|
253
|
+
* @param {string} options.actor - Who made this decision.
|
|
254
|
+
* @param {string} options.reason - Why this decision was made.
|
|
255
|
+
* @param {'discard'|'replace'} [options.action='discard'] - Decision type.
|
|
256
|
+
* @param {Buffer} [options.replacementBytes] - New content for 'replace' action.
|
|
257
|
+
* @returns {Promise<AdoptionPlan>} Updated adoption plan with new digest.
|
|
258
|
+
* @throws {ReleaseError} PLAN_STALE if plan or hunk bytes changed.
|
|
259
|
+
*/
|
|
260
|
+
export async function discardBootstrapHunk({
|
|
261
|
+
adoptionPlan,
|
|
262
|
+
currentEntries,
|
|
263
|
+
artifactId,
|
|
264
|
+
hunkDigest,
|
|
265
|
+
expectedPlanDigest,
|
|
266
|
+
actor,
|
|
267
|
+
reason,
|
|
268
|
+
action = 'discard',
|
|
269
|
+
replacementBytes,
|
|
270
|
+
} = {}) {
|
|
271
|
+
if (!['discard', 'replace'].includes(action) || !actor?.trim() || !reason?.trim()) {
|
|
272
|
+
throw new ReleaseError(
|
|
273
|
+
MISSING_PARAMETERS,
|
|
274
|
+
'hunk decision requires discard|replace, actor, and reason',
|
|
275
|
+
{ action },
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
// Stale guard: plan digest must match
|
|
279
|
+
if (adoptionPlan.planDigest !== expectedPlanDigest) {
|
|
280
|
+
throw new ReleaseError(
|
|
281
|
+
PLAN_STALE,
|
|
282
|
+
'plan has changed since this adoption plan was created',
|
|
283
|
+
{ expectedPlanDigest, actualPlanDigest: adoptionPlan.planDigest },
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Locate the hunk by hunkDigest
|
|
288
|
+
const hunk = adoptionPlan.protectedHunks.find(
|
|
289
|
+
(h) => h.artifactId === artifactId && h.hunkDigest === hunkDigest,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
if (!hunk) {
|
|
293
|
+
throw new ReleaseError(
|
|
294
|
+
MISSING_PARAMETERS,
|
|
295
|
+
`protected hunk not found: artifact="${artifactId}", digest="${hunkDigest}"`,
|
|
296
|
+
{ artifactId, hunkDigest },
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Re-read currentEntries and verify bytes at the hunk range
|
|
301
|
+
const currentEntry = currentEntries?.get(artifactId);
|
|
302
|
+
const currentBytes = currentEntry?.bytes ?? currentEntry?.content;
|
|
303
|
+
if (!currentBytes || currentEntry?.kind !== 'regular' || !hunk.range) {
|
|
304
|
+
throw new ReleaseError(
|
|
305
|
+
PLAN_STALE,
|
|
306
|
+
`current bytes are unavailable for hunk revalidation: artifact="${artifactId}"`,
|
|
307
|
+
{ artifactId, hunkDigest },
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const slice = currentBytes.slice(hunk.range.start, hunk.range.start + hunk.range.length);
|
|
311
|
+
const sliceDigest = sha256Hex(slice);
|
|
312
|
+
if (hunk.currentDigest && sliceDigest !== hunk.currentDigest) {
|
|
313
|
+
throw new ReleaseError(
|
|
314
|
+
PLAN_STALE,
|
|
315
|
+
`current bytes changed for hunk at artifact="${artifactId}" range=${hunk.range.start}+${hunk.range.length}`,
|
|
316
|
+
{
|
|
317
|
+
expectedCurrentDigest: hunk.currentDigest,
|
|
318
|
+
actualCurrentDigest: sliceDigest,
|
|
319
|
+
artifactId,
|
|
320
|
+
hunkDigest,
|
|
321
|
+
},
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Already decided
|
|
326
|
+
const alreadyDecided = adoptionPlan.hunkDecisions.find(
|
|
327
|
+
(d) => d.artifactId === artifactId && d.hunkDigest === hunkDigest,
|
|
328
|
+
);
|
|
329
|
+
if (alreadyDecided) {
|
|
330
|
+
throw new ReleaseError(
|
|
331
|
+
PLAN_STALE,
|
|
332
|
+
`hunk already decided: artifact="${artifactId}", digest="${hunkDigest}"`,
|
|
333
|
+
{ artifactId, hunkDigest, existingDecision: alreadyDecided },
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Build decision record — deterministic, no timestamp/random
|
|
338
|
+
const candidateDigest = replacementBytes == null
|
|
339
|
+
? (hunk.candidateDigest ?? null)
|
|
340
|
+
: sha256Hex(Buffer.from(replacementBytes));
|
|
341
|
+
|
|
342
|
+
const decision = Object.freeze({
|
|
343
|
+
artifactId,
|
|
344
|
+
hunkDigest,
|
|
345
|
+
baseDigest: hunk.baseDigest ?? null,
|
|
346
|
+
currentDigest: hunk.currentDigest ?? null,
|
|
347
|
+
candidateDigest,
|
|
348
|
+
action,
|
|
349
|
+
actor,
|
|
350
|
+
reason,
|
|
351
|
+
decisionDigest: computeDecisionDigest({
|
|
352
|
+
artifactId,
|
|
353
|
+
hunkDigest,
|
|
354
|
+
baseDigest: hunk.baseDigest,
|
|
355
|
+
currentDigest: hunk.currentDigest,
|
|
356
|
+
candidateDigest,
|
|
357
|
+
action,
|
|
358
|
+
actor,
|
|
359
|
+
reason,
|
|
360
|
+
}),
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Update decisions
|
|
364
|
+
const updatedDecisions = [...adoptionPlan.hunkDecisions, decision];
|
|
365
|
+
|
|
366
|
+
// Derive new plan digest
|
|
367
|
+
const newPlanDigest = deriveAdoptionDigest(
|
|
368
|
+
adoptionPlan,
|
|
369
|
+
updatedDecisions,
|
|
370
|
+
adoptionPlan.protectedHunks,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
// Determine if all hunks are now decided
|
|
374
|
+
const allDecided = adoptionPlan.protectedHunks.every(
|
|
375
|
+
(h) => updatedDecisions.some(
|
|
376
|
+
(d) => d.artifactId === h.artifactId && d.hunkDigest === h.hunkDigest,
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
return Object.freeze({
|
|
381
|
+
...adoptionPlan,
|
|
382
|
+
planDigest: newPlanDigest,
|
|
383
|
+
hunkDecisions: Object.freeze(updatedDecisions),
|
|
384
|
+
// A human decision resolves protection, but does not fabricate producer
|
|
385
|
+
// convergence. Phase 3 must apply/re-produce before CONVERGED is possible.
|
|
386
|
+
status: allDecided ? 'DECISIONS_COMPLETE' : 'ADOPTION_REQUIRED',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Internal helpers
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Validate that all adoption routes are exact route objects, not strings.
|
|
396
|
+
*
|
|
397
|
+
* @param {object} policy
|
|
398
|
+
* @throws {ReleaseError} ARTIFACT_POLICY_INVALID if string routes found.
|
|
399
|
+
*/
|
|
400
|
+
function validateAdoptionRoutes(policy) {
|
|
401
|
+
for (const artifact of policy.artifacts) {
|
|
402
|
+
if (artifact.type !== 'generated') continue;
|
|
403
|
+
const routes = artifact.adoptionRoutes ?? [];
|
|
404
|
+
for (const route of routes) {
|
|
405
|
+
if (typeof route === 'string') {
|
|
406
|
+
throw new ReleaseError(
|
|
407
|
+
'ARTIFACT_POLICY_INVALID',
|
|
408
|
+
`artifact "${artifact.id}": adoptionRoutes must be objects {target, sourceArtifact, mode}, got string "${route}"`,
|
|
409
|
+
{ artifactId: artifact.id, route },
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
if (!route.target || !route.sourceArtifact || !route.mode) {
|
|
413
|
+
throw new ReleaseError(
|
|
414
|
+
'ARTIFACT_POLICY_INVALID',
|
|
415
|
+
`artifact "${artifact.id}": adoptionRoute must have {target, sourceArtifact, mode}`,
|
|
416
|
+
{ artifactId: artifact.id, route },
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
if (!(artifact.sourceArtifacts ?? []).includes(route.sourceArtifact)) {
|
|
420
|
+
throw new ReleaseError(
|
|
421
|
+
'ARTIFACT_POLICY_INVALID',
|
|
422
|
+
`artifact "${artifact.id}": adoption route source must be a direct sourceArtifact`,
|
|
423
|
+
{ artifactId: artifact.id, route },
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function digestManifestOutputs(outputs) {
|
|
431
|
+
return `sha256:${sha256Hex(canonicalJson(outputs.map((entry) => ({
|
|
432
|
+
path: entry.path ?? null,
|
|
433
|
+
type: entry.type ?? entry.kind ?? null,
|
|
434
|
+
mode: entry.mode ?? null,
|
|
435
|
+
sha256: entry.sha256 ?? sha256Hex(entry.bytes ?? entry.content ?? Buffer.alloc(0)),
|
|
436
|
+
size: entry.size ?? (entry.bytes ?? entry.content ?? Buffer.alloc(0)).length,
|
|
437
|
+
}))))}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function projectionRelativePath(sourcePath, targetPath) {
|
|
441
|
+
const sourceParts = String(sourcePath ?? '').split('/').filter(Boolean);
|
|
442
|
+
const targetParts = String(targetPath ?? '').split('/').filter(Boolean);
|
|
443
|
+
let common = 0;
|
|
444
|
+
while (common < sourceParts.length && common < targetParts.length &&
|
|
445
|
+
sourceParts[sourceParts.length - 1 - common] === targetParts[targetParts.length - 1 - common]) {
|
|
446
|
+
common += 1;
|
|
447
|
+
}
|
|
448
|
+
return common > 0
|
|
449
|
+
? sourceParts.slice(sourceParts.length - common).join('/')
|
|
450
|
+
: sourceParts.at(-1);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Resolve the candidate content for an artifact.
|
|
455
|
+
* Prefers firstCandidate from the plan, falls back to generated entry.
|
|
456
|
+
*/
|
|
457
|
+
function resolveCandidate(planArtifact, generatedEntry) {
|
|
458
|
+
if (planArtifact.firstCandidate) {
|
|
459
|
+
const fc = planArtifact.firstCandidate;
|
|
460
|
+
const bytes = decodePlanBytes(fc);
|
|
461
|
+
if (bytes) {
|
|
462
|
+
return {
|
|
463
|
+
kind: 'regular',
|
|
464
|
+
bytes,
|
|
465
|
+
content: bytes,
|
|
466
|
+
sha256: fc.sha256 ?? sha256Hex(bytes),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return generatedEntry;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function decodePlanBytes(value) {
|
|
474
|
+
if (Buffer.isBuffer(value.bytes)) return Buffer.from(value.bytes);
|
|
475
|
+
if (typeof value.bytesBase64 === 'string') return Buffer.from(value.bytesBase64, 'base64');
|
|
476
|
+
if (value.bytes?.type === 'Buffer' && Array.isArray(value.bytes.data)) {
|
|
477
|
+
return Buffer.from(value.bytes.data);
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Extract bytes from an entry for hunk comparison.
|
|
484
|
+
*/
|
|
485
|
+
function extractEntryBytes(entry) {
|
|
486
|
+
if (!entry || entry.kind === 'absent') return null;
|
|
487
|
+
if (entry.bytes) return entry.bytes;
|
|
488
|
+
if (entry.content) return entry.content;
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Compute protected hunks from current vs candidate comparison.
|
|
494
|
+
*
|
|
495
|
+
* For text files: produces multiple hunks from line-level diff.
|
|
496
|
+
* For binary files: produces one whole-file hunk.
|
|
497
|
+
*
|
|
498
|
+
* Each hunk: {artifactId, hunkDigest, baseDigest, currentDigest, candidateDigest, range}
|
|
499
|
+
*/
|
|
500
|
+
function collectProtectedHunks(artifactId, current, candidate) {
|
|
501
|
+
const currentBytes = extractEntryBytes(current);
|
|
502
|
+
const candidateBytes = extractEntryBytes(candidate);
|
|
503
|
+
|
|
504
|
+
const currentSha = currentBytes ? sha256Hex(currentBytes) : (current?.sha256 ?? null);
|
|
505
|
+
const candidateSha = candidateBytes ? sha256Hex(candidateBytes) : (candidate?.sha256 ?? null);
|
|
506
|
+
|
|
507
|
+
// Both absent → no hunk
|
|
508
|
+
if (!currentSha && !candidateSha) return [];
|
|
509
|
+
|
|
510
|
+
// Same content → no protected hunk
|
|
511
|
+
if (currentSha && candidateSha && currentSha === candidateSha) return [];
|
|
512
|
+
|
|
513
|
+
// If current is absent, no protected hunks (new artifact)
|
|
514
|
+
if (!currentBytes) return [];
|
|
515
|
+
|
|
516
|
+
// Determine if content is text or binary
|
|
517
|
+
const isText = currentBytes && isTextContent(currentBytes)
|
|
518
|
+
&& (!candidateBytes || isTextContent(candidateBytes));
|
|
519
|
+
|
|
520
|
+
if (isText && currentBytes && candidateBytes) {
|
|
521
|
+
return computeTextHunks(artifactId, currentBytes, candidateBytes);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Binary or single-side: one whole-file hunk
|
|
525
|
+
return [computeWholeFileHunk(artifactId, currentBytes, candidateBytes)];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Check if content is likely text.
|
|
530
|
+
* Binary if: contains null byte, or >30% non-printable/non-whitespace bytes.
|
|
531
|
+
*/
|
|
532
|
+
function isTextContent(bytes) {
|
|
533
|
+
if (bytes.length === 0) return true;
|
|
534
|
+
try {
|
|
535
|
+
new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
536
|
+
} catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
const check = bytes.length > 8192 ? bytes.subarray(0, 8192) : bytes;
|
|
540
|
+
let nonPrintable = 0;
|
|
541
|
+
for (let i = 0; i < check.length; i++) {
|
|
542
|
+
const b = check[i];
|
|
543
|
+
if (b === 0) return false; // null byte → definitely binary
|
|
544
|
+
// Count non-printable (except common whitespace: tab, LF, CR)
|
|
545
|
+
if (b < 0x20 && b !== 0x09 && b !== 0x0a && b !== 0x0d) {
|
|
546
|
+
nonPrintable++;
|
|
547
|
+
}
|
|
548
|
+
if (b === 0x7f) nonPrintable++;
|
|
549
|
+
}
|
|
550
|
+
// If >30% non-printable, treat as binary
|
|
551
|
+
return nonPrintable / check.length <= 0.30;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Compute text hunks from line-level diff between current and candidate.
|
|
556
|
+
*/
|
|
557
|
+
function computeTextHunks(artifactId, currentBytes, candidateBytes) {
|
|
558
|
+
const currentStr = currentBytes.toString('utf8');
|
|
559
|
+
const candidateStr = candidateBytes.toString('utf8');
|
|
560
|
+
|
|
561
|
+
const currentLines = currentStr.split('\n');
|
|
562
|
+
const candidateLines = candidateStr.split('\n');
|
|
563
|
+
|
|
564
|
+
// Find changed line indices
|
|
565
|
+
const changedLines = computeChangedLines(candidateLines, currentLines);
|
|
566
|
+
|
|
567
|
+
if (changedLines.length === 0) return [];
|
|
568
|
+
|
|
569
|
+
// Group into contiguous hunks
|
|
570
|
+
const hunkRanges = groupContiguousLines(changedLines, currentLines);
|
|
571
|
+
const candidateDigest = sha256Hex(candidateBytes);
|
|
572
|
+
|
|
573
|
+
return hunkRanges.map((hunkRange) => {
|
|
574
|
+
const { startByte, length } = hunkRange;
|
|
575
|
+
const hunkBytes = currentBytes.slice(startByte, startByte + length);
|
|
576
|
+
const hunkDigest = `sha256:${sha256Hex(`${artifactId}:hunk:${startByte}:${length}:${sha256Hex(hunkBytes)}`)}`;
|
|
577
|
+
|
|
578
|
+
return Object.freeze({
|
|
579
|
+
artifactId,
|
|
580
|
+
hunkDigest,
|
|
581
|
+
baseDigest: null, // bootstrap: no base
|
|
582
|
+
currentDigest: sha256Hex(hunkBytes),
|
|
583
|
+
candidateDigest,
|
|
584
|
+
range: Object.freeze({ start: startByte, length }),
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Compute which line indices differ between produced and current.
|
|
591
|
+
* Returns array of 0-based line indices in current that are changed.
|
|
592
|
+
*/
|
|
593
|
+
function computeChangedLines(producedLines, currentLines) {
|
|
594
|
+
// Find longest common prefix/suffix for each line position
|
|
595
|
+
const maxLen = Math.max(producedLines.length, currentLines.length);
|
|
596
|
+
const changed = [];
|
|
597
|
+
|
|
598
|
+
// Use simple LCS-based approach: find matching lines
|
|
599
|
+
const matched = new Set();
|
|
600
|
+
let pi = 0;
|
|
601
|
+
|
|
602
|
+
for (let ci = 0; ci < currentLines.length; ci++) {
|
|
603
|
+
let found = false;
|
|
604
|
+
for (let pj = pi; pj < producedLines.length; pj++) {
|
|
605
|
+
if (producedLines[pj] === currentLines[ci]) {
|
|
606
|
+
matched.add(ci);
|
|
607
|
+
pi = pj + 1;
|
|
608
|
+
found = true;
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (!found && ci < producedLines.length) {
|
|
613
|
+
// Check if this line exists later in produced (not a pure add)
|
|
614
|
+
const existsLater = producedLines.includes(currentLines[ci]);
|
|
615
|
+
if (!existsLater) {
|
|
616
|
+
changed.push(ci);
|
|
617
|
+
}
|
|
618
|
+
} else if (!found) {
|
|
619
|
+
// Extra lines in current (beyond produced length)
|
|
620
|
+
changed.push(ci);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Also check for lines that changed content (same position, different content)
|
|
625
|
+
for (let i = 0; i < Math.min(producedLines.length, currentLines.length); i++) {
|
|
626
|
+
if (!matched.has(i) && producedLines[i] !== currentLines[i]) {
|
|
627
|
+
if (!changed.includes(i)) {
|
|
628
|
+
changed.push(i);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
changed.sort((a, b) => a - b);
|
|
634
|
+
return changed;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Group changed line indices into contiguous hunk ranges with byte offsets.
|
|
639
|
+
*/
|
|
640
|
+
function groupContiguousLines(changedLines, lines) {
|
|
641
|
+
if (changedLines.length === 0) return [];
|
|
642
|
+
|
|
643
|
+
// Pre-compute line byte offsets
|
|
644
|
+
const lineOffsets = [];
|
|
645
|
+
let offset = 0;
|
|
646
|
+
const lastLine = lines.length - 1;
|
|
647
|
+
for (let i = 0; i < lines.length; i++) {
|
|
648
|
+
lineOffsets.push(offset);
|
|
649
|
+
const lineBytes = Buffer.byteLength(lines[i], 'utf8');
|
|
650
|
+
// Last empty line (from split on trailing \n) has no actual bytes
|
|
651
|
+
offset += (i === lastLine && lines[i] === '') ? 0 : lineBytes + 1;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function endOffset(lineIdx) {
|
|
655
|
+
const lineBytes = Buffer.byteLength(lines[lineIdx], 'utf8');
|
|
656
|
+
// Last empty line: ends at the previous line's end
|
|
657
|
+
if (lineIdx === lastLine && lines[lineIdx] === '') {
|
|
658
|
+
return lineOffsets[lineIdx];
|
|
659
|
+
}
|
|
660
|
+
return lineOffsets[lineIdx] + lineBytes + 1;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const ranges = [];
|
|
664
|
+
let groupStart = changedLines[0];
|
|
665
|
+
let groupEnd = changedLines[0];
|
|
666
|
+
|
|
667
|
+
for (let i = 1; i < changedLines.length; i++) {
|
|
668
|
+
if (changedLines[i] === groupEnd + 1) {
|
|
669
|
+
groupEnd = changedLines[i];
|
|
670
|
+
} else {
|
|
671
|
+
ranges.push({
|
|
672
|
+
startByte: lineOffsets[groupStart],
|
|
673
|
+
length: endOffset(groupEnd) - lineOffsets[groupStart],
|
|
674
|
+
});
|
|
675
|
+
groupStart = changedLines[i];
|
|
676
|
+
groupEnd = changedLines[i];
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Emit last group
|
|
681
|
+
ranges.push({
|
|
682
|
+
startByte: lineOffsets[groupStart],
|
|
683
|
+
length: endOffset(groupEnd) - lineOffsets[groupStart],
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
return ranges;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Compute a single whole-file hunk (for binary or non-text content).
|
|
691
|
+
*/
|
|
692
|
+
function computeWholeFileHunk(artifactId, currentBytes, candidateBytes) {
|
|
693
|
+
const currentSha = currentBytes ? sha256Hex(currentBytes) : null;
|
|
694
|
+
const candidateSha = candidateBytes ? sha256Hex(candidateBytes) : null;
|
|
695
|
+
const length = currentBytes ? currentBytes.length : 0;
|
|
696
|
+
|
|
697
|
+
const hunkDigest = `sha256:${sha256Hex(`${artifactId}:whole:${currentSha ?? 'absent'}:${candidateSha ?? 'absent'}`)}`;
|
|
698
|
+
|
|
699
|
+
return Object.freeze({
|
|
700
|
+
artifactId,
|
|
701
|
+
hunkDigest,
|
|
702
|
+
baseDigest: null,
|
|
703
|
+
currentDigest: currentSha,
|
|
704
|
+
candidateDigest: candidateSha,
|
|
705
|
+
range: Object.freeze({ start: 0, length }),
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Compute a deterministic digest for a hunk decision.
|
|
711
|
+
* No timestamp or random fields — pure deterministic.
|
|
712
|
+
*/
|
|
713
|
+
function computeDecisionDigest(decision) {
|
|
714
|
+
return `sha256:${sha256Hex(canonicalJson({
|
|
715
|
+
artifactId: decision.artifactId,
|
|
716
|
+
hunkDigest: decision.hunkDigest,
|
|
717
|
+
baseDigest: decision.baseDigest ?? null,
|
|
718
|
+
currentDigest: decision.currentDigest ?? null,
|
|
719
|
+
candidateDigest: decision.candidateDigest ?? null,
|
|
720
|
+
action: decision.action,
|
|
721
|
+
actor: decision.actor,
|
|
722
|
+
reason: decision.reason,
|
|
723
|
+
}))}`;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Derive a new adoption plan digest from decisions and protected hunks.
|
|
728
|
+
*/
|
|
729
|
+
function deriveAdoptionDigest(plan, decisions, hunks) {
|
|
730
|
+
const canonical = canonicalJson({
|
|
731
|
+
basePlanDigest: plan.planDigest ?? plan.plan?.planDigest,
|
|
732
|
+
targetArtifactId: plan.targetArtifactId ?? null,
|
|
733
|
+
sourceArtifactId: plan.sourceArtifactId ?? null,
|
|
734
|
+
route: plan.route ?? null,
|
|
735
|
+
transactionClosure: plan.transactionClosure ?? [],
|
|
736
|
+
sourceCandidateDigest: plan.sourceCandidate?.sha256 ?? null,
|
|
737
|
+
closureManifests: plan.closureManifests ?? null,
|
|
738
|
+
artifactPaths: plan.artifactPaths ?? null,
|
|
739
|
+
decisions: decisions.map((d) => d.decisionDigest ?? d),
|
|
740
|
+
hunkDigests: hunks.map((h) => h.hunkDigest ?? h.digest),
|
|
741
|
+
});
|
|
742
|
+
return `sha256:${sha256Hex(canonical)}`;
|
|
743
|
+
}
|