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,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe filesystem backend — JS loader, capability probe, and directory handle.
|
|
3
|
+
*
|
|
4
|
+
* Loads the native Node-API addon that provides no-follow/openat relative
|
|
5
|
+
* directory primitives. The addon must be explicitly built with `native:build`
|
|
6
|
+
* or loaded from a prebuild addon registered in prebuilds.json.
|
|
7
|
+
*
|
|
8
|
+
* There is NO automatic install/postinstall native build. If the addon is
|
|
9
|
+
* not available, all mutating commands fail closed with SAFE_WRITE_UNAVAILABLE.
|
|
10
|
+
*
|
|
11
|
+
* @module artifacts/safe-fs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { realpath } from 'node:fs/promises';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
|
|
17
|
+
import { ReleaseError, SAFE_WRITE_UNAVAILABLE } from '../core/errors.mjs';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
loadNativeAddon,
|
|
21
|
+
createBackend,
|
|
22
|
+
} from './safe-fs-backend-internal.mjs';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Public API
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load the safe filesystem backend.
|
|
30
|
+
*
|
|
31
|
+
* The public entry point does NOT accept `_testAddon` or any test hooks.
|
|
32
|
+
* All addon loading goes through `loadNativeAddon` which validates the
|
|
33
|
+
* addon path, lstat identity, and production manifest integrity.
|
|
34
|
+
*
|
|
35
|
+
* Tests that need a fake addon must import `createBackend` directly from
|
|
36
|
+
* `./safe-fs-backend-internal.mjs`.
|
|
37
|
+
*/
|
|
38
|
+
export async function loadSafeFs(options = {}) {
|
|
39
|
+
const addon = loadNativeAddon(options.addonPath);
|
|
40
|
+
return createBackend(addon);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Hard gate: load safe filesystem primitives and verify they work.
|
|
45
|
+
* Throws SAFE_WRITE_UNAVAILABLE if the native addon is not available or
|
|
46
|
+
* the real probe fails. Callers cannot proceed with writes based on
|
|
47
|
+
* platform name alone.
|
|
48
|
+
*/
|
|
49
|
+
export async function requireSafeFs(options = {}) {
|
|
50
|
+
const backend = await loadSafeFs(options);
|
|
51
|
+
|
|
52
|
+
// Real probe — creates, writes, renames, reads back actual data.
|
|
53
|
+
// Only realpath the default system tmpdir (macOS /var -> /private/var safety).
|
|
54
|
+
// Explicit user-provided root is NOT realpath'd — openRoot will reject
|
|
55
|
+
// ancestor symlinks via O_NOFOLLOW segment walk (C1).
|
|
56
|
+
let root;
|
|
57
|
+
if (options.root) {
|
|
58
|
+
root = options.root;
|
|
59
|
+
} else {
|
|
60
|
+
root = await realpath(tmpdir());
|
|
61
|
+
}
|
|
62
|
+
const result = await backend.probe(root);
|
|
63
|
+
|
|
64
|
+
if (!result.supported) {
|
|
65
|
+
throw new ReleaseError(
|
|
66
|
+
SAFE_WRITE_UNAVAILABLE,
|
|
67
|
+
'safe write primitives are not functional on this platform',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return backend;
|
|
72
|
+
}
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact state algebra: four-dimensional classification of artifact entries.
|
|
3
|
+
*
|
|
4
|
+
* Classifies an artifact by comparing its base (accepted), current (working
|
|
5
|
+
* tree or commit), and generated (producer output) entries across four
|
|
6
|
+
* dimensions: existence, type, mode, and content.
|
|
7
|
+
*
|
|
8
|
+
* @module artifacts/state
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ReleaseError, PATH_UNSAFE, STRUCTURE_INVALID } from '../core/errors.mjs';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Status priority (lower index = higher priority = shown first)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const PRIORITY = Object.freeze([
|
|
18
|
+
'BASE_UNAVAILABLE',
|
|
19
|
+
'POLICY_INVALID',
|
|
20
|
+
'POLICY_CHANGE_PENDING',
|
|
21
|
+
'ISOLATION_UNAVAILABLE',
|
|
22
|
+
'PATH_UNSAFE',
|
|
23
|
+
'PRODUCER_SCOPE_VIOLATION',
|
|
24
|
+
'PRODUCER_NONDETERMINISTIC',
|
|
25
|
+
'STRUCTURE_INVALID',
|
|
26
|
+
'CONFLICT',
|
|
27
|
+
'ADOPTION_REQUIRED',
|
|
28
|
+
'MERGEABLE',
|
|
29
|
+
'GENERATOR_CHANGED',
|
|
30
|
+
'HUMAN_CHANGED',
|
|
31
|
+
'NEW',
|
|
32
|
+
'CLEAN',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const PRIORITY_MAP = new Map(PRIORITY.map((s, i) => [s, i]));
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get the numeric priority index for a status (lower = higher priority).
|
|
39
|
+
*
|
|
40
|
+
* @param {string} status
|
|
41
|
+
* @returns {number}
|
|
42
|
+
*/
|
|
43
|
+
export function statusPriority(status) {
|
|
44
|
+
return PRIORITY_MAP.get(status) ?? PRIORITY.length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Entry equality
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if two artifact entries are equal across all four dimensions:
|
|
53
|
+
* existence, type, mode, and content.
|
|
54
|
+
*
|
|
55
|
+
* Absent entries are compared by identity (both absent = equal).
|
|
56
|
+
* Regular entries are compared by type, mode, path, and sha256.
|
|
57
|
+
* Tree entries are compared by manifestDigest.
|
|
58
|
+
*
|
|
59
|
+
* @param {object|null} a
|
|
60
|
+
* @param {object|null} b
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
function entriesEqual(a, b) {
|
|
64
|
+
// Both absent
|
|
65
|
+
if (!a && !b) return true;
|
|
66
|
+
// One absent, one present
|
|
67
|
+
if (!a || !b) return false;
|
|
68
|
+
// Both present
|
|
69
|
+
if (a.type !== b.type) return false;
|
|
70
|
+
if (a.mode !== b.mode) return false;
|
|
71
|
+
if (a.path !== b.path) return false;
|
|
72
|
+
// Tree comparison: use manifestDigest for full tree equality
|
|
73
|
+
if (a.type === 'tree' && b.type === 'tree') {
|
|
74
|
+
return a.manifestDigest === b.manifestDigest;
|
|
75
|
+
}
|
|
76
|
+
if (a.sha256 !== b.sha256) return false;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Entry validation
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate that an entry does not use a dangerous filesystem type
|
|
86
|
+
* (symlink, hardlink, special file).
|
|
87
|
+
*
|
|
88
|
+
* @param {object|null} entry
|
|
89
|
+
* @param {string} label - Label for error messages (e.g. 'current', 'generated').
|
|
90
|
+
* @throws {ReleaseError} PATH_UNSAFE on dangerous types.
|
|
91
|
+
*/
|
|
92
|
+
function validateEntry(entry, label) {
|
|
93
|
+
if (!entry || entry.kind === 'absent') return;
|
|
94
|
+
if (entry.kind === 'symlink' || entry.kind === 'special') {
|
|
95
|
+
throw new ReleaseError(
|
|
96
|
+
PATH_UNSAFE,
|
|
97
|
+
`dangerous entry type "${entry.kind}" in ${label}`,
|
|
98
|
+
{ path: entry.path, label },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
// Reject symlink mode
|
|
102
|
+
if (entry.mode === '120000') {
|
|
103
|
+
throw new ReleaseError(
|
|
104
|
+
PATH_UNSAFE,
|
|
105
|
+
`symlink mode 120000 rejected in ${label}`,
|
|
106
|
+
{ path: entry.path, label },
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Conflict analysis
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Analyze differences between two entries relative to a base entry.
|
|
117
|
+
*
|
|
118
|
+
* @param {object|null} base - Base entry (may be null if absent).
|
|
119
|
+
* @param {object} compare - The entry being analyzed.
|
|
120
|
+
* @param {string} label - Label for the change list.
|
|
121
|
+
* @returns {string[]} Array of human-readable change descriptions.
|
|
122
|
+
*/
|
|
123
|
+
function diffAgainstBase(base, compare, label) {
|
|
124
|
+
const changes = [];
|
|
125
|
+
|
|
126
|
+
if (!base) {
|
|
127
|
+
changes.push(`${label}: new ${compare.kind ?? compare.type} "${compare.path ?? '(unknown)'}"`);
|
|
128
|
+
return changes;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!compare) {
|
|
132
|
+
changes.push(`${label}: removed "${base.path ?? '(unknown)'}"`);
|
|
133
|
+
return changes;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (base.type !== compare.type) {
|
|
137
|
+
changes.push(`${label}: type ${base.type} → ${compare.type}`);
|
|
138
|
+
}
|
|
139
|
+
if (base.mode !== compare.mode) {
|
|
140
|
+
changes.push(`${label}: mode ${base.mode} → ${compare.mode}`);
|
|
141
|
+
}
|
|
142
|
+
if (base.sha256 !== compare.sha256) {
|
|
143
|
+
changes.push(`${label}: content changed`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return changes;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Allowed actions
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Compute allowed actions for an artifact decision.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} status - Decision status.
|
|
157
|
+
* @param {object|null} current - Current entry.
|
|
158
|
+
* @param {string} ownership - 'human' | 'generator' | 'system'.
|
|
159
|
+
* @returns {string[]}
|
|
160
|
+
*/
|
|
161
|
+
function computeAllowedActions(status, current, ownership) {
|
|
162
|
+
switch (status) {
|
|
163
|
+
case 'CLEAN':
|
|
164
|
+
return ['skip', 'inspect'];
|
|
165
|
+
|
|
166
|
+
case 'NEW':
|
|
167
|
+
return ownership === 'generator' ? ['adopt', 'inspect'] : ['skip', 'inspect'];
|
|
168
|
+
|
|
169
|
+
case 'GENERATOR_CHANGED':
|
|
170
|
+
return ['accept', 'inspect'];
|
|
171
|
+
|
|
172
|
+
case 'HUMAN_CHANGED':
|
|
173
|
+
return ['skip', 'inspect'];
|
|
174
|
+
|
|
175
|
+
case 'MERGEABLE':
|
|
176
|
+
return ['merge', 'accept', 'skip', 'inspect'];
|
|
177
|
+
|
|
178
|
+
case 'ADOPTION_REQUIRED':
|
|
179
|
+
return ['adopt', 'skip'];
|
|
180
|
+
|
|
181
|
+
case 'CONFLICT':
|
|
182
|
+
return ['merge', 'skip'];
|
|
183
|
+
|
|
184
|
+
default:
|
|
185
|
+
return ['skip', 'inspect'];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Public API
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Classify an artifact by comparing base, current, and generated entries.
|
|
195
|
+
*
|
|
196
|
+
* The four dimensions of comparison are:
|
|
197
|
+
* 1. **Existence** — absent vs present
|
|
198
|
+
* 2. **Type** — blob vs tree
|
|
199
|
+
* 3. **Mode** — 100644 vs 100755
|
|
200
|
+
* 4. **Content** — sha256 digest (or manifestDigest for trees)
|
|
201
|
+
*
|
|
202
|
+
* When `producerRelation` is provided, the producer's implementation and
|
|
203
|
+
* input drift are factored into the classification:
|
|
204
|
+
* - `implementationChanged: true` → at least GENERATOR_CHANGED
|
|
205
|
+
* - `inputChanged: true` → at least GENERATOR_CHANGED
|
|
206
|
+
*
|
|
207
|
+
* For generator-owned areas (`ownership='generator'` with `projection='write'`),
|
|
208
|
+
* any human delta not reproduced by the exact generated projection requires
|
|
209
|
+
* ADOPTION_REQUIRED (Design §10.1).
|
|
210
|
+
*
|
|
211
|
+
* @param {object} options
|
|
212
|
+
* @param {object|null} options.base - Base (accepted) entry, or `{ kind: 'absent' }`.
|
|
213
|
+
* @param {object|null} options.current - Current (worktree) entry, or `{ kind: 'absent' }`.
|
|
214
|
+
* @param {object|null} options.generated - Generated (producer) entry, or `{ kind: 'absent' }`.
|
|
215
|
+
* @param {'human'|'generator'|'system'} [options.ownership='human'] - Path ownership.
|
|
216
|
+
* @param {'read'|'write'} [options.projection='read'] - Projection scope.
|
|
217
|
+
* @param {object} [options.producerRelation] - Producer drift flags.
|
|
218
|
+
* @param {boolean} [options.producerRelation.implementationChanged=false]
|
|
219
|
+
* @param {boolean} [options.producerRelation.inputChanged=false]
|
|
220
|
+
* @param {Array<object>} [options.projections] - Required for mixed ownership.
|
|
221
|
+
* @returns {{ status: string, priority: number, safeToWrite: boolean, allowedActions: string[], humanChanges?: string[], generatorChanges?: string[] }}
|
|
222
|
+
*/
|
|
223
|
+
export function classifyArtifact({
|
|
224
|
+
base,
|
|
225
|
+
current,
|
|
226
|
+
generated,
|
|
227
|
+
ownership = 'human',
|
|
228
|
+
projection = 'read',
|
|
229
|
+
producerRelation,
|
|
230
|
+
projections,
|
|
231
|
+
} = {}) {
|
|
232
|
+
if (ownership === 'mixed') {
|
|
233
|
+
if (!Array.isArray(projections) || projections.length === 0) {
|
|
234
|
+
throw new ReleaseError(
|
|
235
|
+
STRUCTURE_INVALID,
|
|
236
|
+
'mixed artifact requires non-empty projections',
|
|
237
|
+
{ field: 'projections' },
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const ids = new Set();
|
|
242
|
+
const decisions = [];
|
|
243
|
+
for (const item of projections) {
|
|
244
|
+
if (!item || typeof item.id !== 'string' || item.id.length === 0 || ids.has(item.id)) {
|
|
245
|
+
throw new ReleaseError(
|
|
246
|
+
STRUCTURE_INVALID,
|
|
247
|
+
'mixed projection ids must be non-empty and unique',
|
|
248
|
+
{ field: 'projections.id', projectionId: item?.id },
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (item.ownership !== 'human' && item.ownership !== 'generator') {
|
|
252
|
+
throw new ReleaseError(
|
|
253
|
+
STRUCTURE_INVALID,
|
|
254
|
+
'mixed projection ownership must be human or generator',
|
|
255
|
+
{ field: 'projections.ownership', projectionId: item.id },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
ids.add(item.id);
|
|
259
|
+
decisions.push(Object.freeze({
|
|
260
|
+
id: item.id,
|
|
261
|
+
decision: classifyArtifact({
|
|
262
|
+
base: item.base,
|
|
263
|
+
current: item.current,
|
|
264
|
+
generated: item.generated,
|
|
265
|
+
ownership: item.ownership,
|
|
266
|
+
projection: item.ownership === 'generator' ? 'write' : 'read',
|
|
267
|
+
producerRelation: item.producerRelation,
|
|
268
|
+
}),
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const winner = decisions.reduce((best, item) =>
|
|
273
|
+
item.decision.priority < best.decision.priority ? item : best);
|
|
274
|
+
const actionSets = decisions.map((item) => new Set(item.decision.allowedActions));
|
|
275
|
+
const allowedActions = [...actionSets[0]].filter((action) =>
|
|
276
|
+
actionSets.every((set) => set.has(action)));
|
|
277
|
+
if (!allowedActions.includes('inspect')) allowedActions.push('inspect');
|
|
278
|
+
|
|
279
|
+
return Object.freeze({
|
|
280
|
+
status: winner.decision.status,
|
|
281
|
+
priority: winner.decision.priority,
|
|
282
|
+
safeToWrite: decisions.every((item) => item.decision.safeToWrite),
|
|
283
|
+
allowedActions: Object.freeze(allowedActions),
|
|
284
|
+
projectionDecisions: Object.freeze(decisions),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Validate entries for dangerous types
|
|
289
|
+
validateEntry(current, 'current');
|
|
290
|
+
validateEntry(generated, 'generated');
|
|
291
|
+
|
|
292
|
+
// Normalize: extract "has content" from kind
|
|
293
|
+
const hasBase = base && base.kind !== 'absent';
|
|
294
|
+
const hasCurrent = current && current.kind !== 'absent';
|
|
295
|
+
const hasGenerated = generated && generated.kind !== 'absent';
|
|
296
|
+
|
|
297
|
+
// Extract actual entries for comparison (null when absent)
|
|
298
|
+
const baseEntry = hasBase ? base : null;
|
|
299
|
+
const currentEntry = hasCurrent ? current : null;
|
|
300
|
+
const generatedEntry = hasGenerated ? generated : null;
|
|
301
|
+
|
|
302
|
+
// Compare across dimensions
|
|
303
|
+
const baseEqCurrent = entriesEqual(baseEntry, currentEntry);
|
|
304
|
+
const baseEqGenerated = entriesEqual(baseEntry, generatedEntry);
|
|
305
|
+
|
|
306
|
+
/** Helper: apply ADOPTION_REQUIRED when generator owns the area and human changed */
|
|
307
|
+
function applyAdoptionCheck(status, decision) {
|
|
308
|
+
// ADOPTION_REQUIRED when generator owns the area (projection=write) and
|
|
309
|
+
// the human side has a delta that the generated projection does not reproduce.
|
|
310
|
+
if (projection === 'write' && ownership === 'generator') {
|
|
311
|
+
// Any human delta in a generator-owned area requires adoption
|
|
312
|
+
if (!baseEqCurrent) {
|
|
313
|
+
return makeDecision({ status: 'ADOPTION_REQUIRED', current, generated, ownership });
|
|
314
|
+
}
|
|
315
|
+
// No base but current was added by human
|
|
316
|
+
if (!hasBase && hasCurrent) {
|
|
317
|
+
return makeDecision({ status: 'ADOPTION_REQUIRED', current, generated, ownership });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return decision;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- Base absent (no prior accepted state) ---
|
|
324
|
+
if (!hasBase) {
|
|
325
|
+
if (!hasCurrent && !hasGenerated) {
|
|
326
|
+
// All absent
|
|
327
|
+
if (ownership === 'generator') {
|
|
328
|
+
return applyAdoptionCheck('NEW', makeDecision({ status: 'NEW', current, generated, ownership }));
|
|
329
|
+
}
|
|
330
|
+
return makeDecision({ status: 'CLEAN', current, generated, ownership });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (hasCurrent && !hasGenerated) {
|
|
334
|
+
// Design 10.1: absent/present/absent → HUMAN_CHANGED for human area,
|
|
335
|
+
// but ADOPTION_REQUIRED for generator-owned area
|
|
336
|
+
if (projection === 'write' && ownership === 'generator') {
|
|
337
|
+
return makeDecision({ status: 'ADOPTION_REQUIRED', current, generated, ownership });
|
|
338
|
+
}
|
|
339
|
+
return makeDecision({ status: 'HUMAN_CHANGED', current, generated, ownership });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!hasCurrent && hasGenerated) {
|
|
343
|
+
// New file from generator — projection=write requires explicit adoption
|
|
344
|
+
if (ownership === 'human' && projection === 'write') {
|
|
345
|
+
return makeDecision({ status: 'ADOPTION_REQUIRED', current, generated, ownership });
|
|
346
|
+
}
|
|
347
|
+
return makeDecision({ status: 'GENERATOR_CHANGED', current, generated, ownership });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (!entriesEqual(currentEntry, generatedEntry)) {
|
|
351
|
+
// Both exist but differ — generator owns it if it's the source
|
|
352
|
+
if (ownership === 'generator') {
|
|
353
|
+
return makeDecision({ status: 'NEW', current, generated, ownership });
|
|
354
|
+
}
|
|
355
|
+
return makeDecision({
|
|
356
|
+
status: 'CONFLICT',
|
|
357
|
+
base: baseEntry,
|
|
358
|
+
current,
|
|
359
|
+
generated,
|
|
360
|
+
ownership,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Both exist and are identical → mergeable
|
|
365
|
+
return makeDecision({ status: 'MERGEABLE', current, generated, ownership });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// --- Base exists ---
|
|
369
|
+
// Both current and generated agree the entry was removed (or both absent)
|
|
370
|
+
if (!hasCurrent && !hasGenerated) {
|
|
371
|
+
if (ownership === 'human') {
|
|
372
|
+
return makeDecision({ status: 'CLEAN', current, generated, ownership });
|
|
373
|
+
}
|
|
374
|
+
return makeDecision({ status: 'MERGEABLE', current, generated, ownership });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (baseEqCurrent && baseEqGenerated) {
|
|
378
|
+
// Everything matches — but check producerRelation for drift
|
|
379
|
+
const withDrift = applyProducerDrift('CLEAN', { current, generated, ownership, producerRelation });
|
|
380
|
+
return withDrift;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (baseEqCurrent && !baseEqGenerated) {
|
|
384
|
+
// Generator changed the file while human left it alone
|
|
385
|
+
return makeDecision({ status: 'GENERATOR_CHANGED', current, generated, ownership });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!baseEqCurrent && baseEqGenerated) {
|
|
389
|
+
// Human changed the file while generator left it alone
|
|
390
|
+
// In generator-owned write area → ADOPTION_REQUIRED
|
|
391
|
+
if (projection === 'write' && ownership === 'generator') {
|
|
392
|
+
return makeDecision({ status: 'ADOPTION_REQUIRED', current, generated, ownership });
|
|
393
|
+
}
|
|
394
|
+
return makeDecision({ status: 'HUMAN_CHANGED', current, generated, ownership });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Both differ from base
|
|
398
|
+
if (entriesEqual(currentEntry, generatedEntry)) {
|
|
399
|
+
return makeDecision({ status: 'MERGEABLE', current, generated, ownership });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Both differ from base AND differ from each other → conflict
|
|
403
|
+
return makeDecision({
|
|
404
|
+
status: 'CONFLICT',
|
|
405
|
+
base: baseEntry,
|
|
406
|
+
current,
|
|
407
|
+
generated,
|
|
408
|
+
ownership,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Internal: Producer drift escalation
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Apply producer relation drift to a status. If the producer implementation
|
|
418
|
+
* or input changed, the status must be at least GENERATOR_CHANGED.
|
|
419
|
+
*
|
|
420
|
+
* @param {string} baseStatus - The status before drift check.
|
|
421
|
+
* @param {object} ctx - Classification context.
|
|
422
|
+
* @returns {object} Frozen ArtifactDecision.
|
|
423
|
+
*/
|
|
424
|
+
function applyProducerDrift(baseStatus, { current, generated, ownership, producerRelation }) {
|
|
425
|
+
if (!producerRelation) {
|
|
426
|
+
return makeDecision({ status: baseStatus, current, generated, ownership });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const implChanged = producerRelation.implementationChanged === true;
|
|
430
|
+
const inputChanged = producerRelation.inputChanged === true;
|
|
431
|
+
|
|
432
|
+
if (implChanged || inputChanged) {
|
|
433
|
+
// Escalate to at least GENERATOR_CHANGED
|
|
434
|
+
const baseP = PRIORITY_MAP.get(baseStatus) ?? PRIORITY.length;
|
|
435
|
+
const genP = PRIORITY_MAP.get('GENERATOR_CHANGED') ?? PRIORITY.length;
|
|
436
|
+
if (baseP > genP) {
|
|
437
|
+
// baseStatus is lower priority than GENERATOR_CHANGED — escalate
|
|
438
|
+
return makeDecision({ status: 'GENERATOR_CHANGED', current, generated, ownership });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return makeDecision({ status: baseStatus, current, generated, ownership });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Internal: Decision construction
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Construct a frozen ArtifactDecision object.
|
|
451
|
+
*
|
|
452
|
+
* @param {object} options
|
|
453
|
+
* @param {string} options.status - Decision status.
|
|
454
|
+
* @param {object|null} [options.base] - Base entry (for conflict analysis).
|
|
455
|
+
* @param {object} options.current - Current entry.
|
|
456
|
+
* @param {object} options.generated - Generated entry.
|
|
457
|
+
* @param {string} options.ownership - Path ownership.
|
|
458
|
+
* @returns {object} Frozen ArtifactDecision.
|
|
459
|
+
*/
|
|
460
|
+
function makeDecision({ status, base = null, current, generated, ownership }) {
|
|
461
|
+
const priority = PRIORITY_MAP.get(status) ?? PRIORITY.length;
|
|
462
|
+
const allowedActions = computeAllowedActions(status, current, ownership);
|
|
463
|
+
const safeToWrite = !BLOCKING_STATUSES.has(status);
|
|
464
|
+
|
|
465
|
+
const decision = {
|
|
466
|
+
status,
|
|
467
|
+
priority,
|
|
468
|
+
safeToWrite,
|
|
469
|
+
allowedActions,
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// Add conflict details for CONFLICT status
|
|
473
|
+
if (status === 'CONFLICT') {
|
|
474
|
+
decision.humanChanges = diffAgainstBase(base, current, 'current');
|
|
475
|
+
decision.generatorChanges = diffAgainstBase(base, generated, 'generated');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return Object.freeze(decision);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Statuses that block writes (safeToWrite = false).
|
|
483
|
+
*/
|
|
484
|
+
const BLOCKING_STATUSES = Object.freeze(new Set([
|
|
485
|
+
'BASE_UNAVAILABLE',
|
|
486
|
+
'POLICY_INVALID',
|
|
487
|
+
'POLICY_CHANGE_PENDING',
|
|
488
|
+
'ISOLATION_UNAVAILABLE',
|
|
489
|
+
'PATH_UNSAFE',
|
|
490
|
+
'PRODUCER_SCOPE_VIOLATION',
|
|
491
|
+
'PRODUCER_NONDETERMINISTIC',
|
|
492
|
+
'STRUCTURE_INVALID',
|
|
493
|
+
'CONFLICT',
|
|
494
|
+
'ADOPTION_REQUIRED',
|
|
495
|
+
]));
|