@gregiteen/ssss-cli 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -4
- package/README.md +20 -4
- package/VERSION +1 -1
- package/conformance/README.md +11 -7
- package/conformance/fixtures.json +68 -2
- package/conformance/reference-bundle.ucw.json +21 -4
- package/docs/help/bundle.md +6 -2
- package/docs/help/import.md +2 -0
- package/docs/help/localization.md +43 -0
- package/docs/help/overview.md +8 -4
- package/docs/help/semantic.md +37 -0
- package/docs/ssss-spec.md +112 -7
- package/package.json +8 -3
- package/registry/core.json +43 -3
- package/scripts/audit-registry-field-usage.mjs +10 -1
- package/scripts/build-reference-bundle.mjs +24 -1
- package/scripts/cmd-export.mjs +3 -3
- package/scripts/cmd-localize.mjs +44 -0
- package/scripts/cmd-semantic.mjs +88 -0
- package/scripts/conformance.mjs +374 -2
- package/scripts/ssss.mjs +4 -0
- package/skills/ssss/SKILL.md +225 -0
- package/skills/ssss/evals/evals.json +37 -0
- package/skills/ssss/references/contract-reference.md +96 -0
- package/skills/ssss/scripts/validate-schema.mjs +258 -0
- package/skills/ssss/subagents/audit-ssss-contract.md +22 -0
- package/src/bundle.mjs +170 -34
- package/src/engine.mjs +78 -4
- package/src/registry.mjs +246 -6
- package/src/semantic.mjs +402 -0
package/src/bundle.mjs
CHANGED
|
@@ -16,9 +16,16 @@
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import crypto from 'node:crypto';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
loadRegistries,
|
|
21
|
+
resolvePortability,
|
|
22
|
+
validateDataConstraints,
|
|
23
|
+
validateReferenceConstraints,
|
|
24
|
+
} from './registry.mjs';
|
|
20
25
|
import { parseDocument, serializeDocument } from './frontmatter.mjs';
|
|
21
26
|
|
|
27
|
+
export const DEFAULT_EXPORTER = '@gregiteen/ssss-cli';
|
|
28
|
+
|
|
22
29
|
const PROFILE_INCLUDES = {
|
|
23
30
|
backup: new Set(['structural', 'tenant_private', 'resource_bound']),
|
|
24
31
|
template: new Set(['structural', 'resource_bound']),
|
|
@@ -133,7 +140,7 @@ function walkVault(vaultRoot) {
|
|
|
133
140
|
*/
|
|
134
141
|
export function exportBundle(vaultRoot, opts = {}) {
|
|
135
142
|
const { profile = 'backup', registryDir, name = 'untitled', description = '', version = '0.1.0',
|
|
136
|
-
exporter =
|
|
143
|
+
exporter = DEFAULT_EXPORTER, requiredExtensions = [], parameters = [], provisioning = [],
|
|
137
144
|
sourceWorkspaceId, exportedAt = '1970-01-01T00:00:00.000Z' } = opts;
|
|
138
145
|
const includes = PROFILE_INCLUDES[profile];
|
|
139
146
|
if (!includes) throw new Error(`Unknown export profile '${profile}'. Use backup | template | sale.`);
|
|
@@ -145,8 +152,35 @@ export function exportBundle(vaultRoot, opts = {}) {
|
|
|
145
152
|
const manifestParameters = [...parameters];
|
|
146
153
|
const manifestProvisioning = [...provisioning];
|
|
147
154
|
const manifestDraft = { parameters: manifestParameters, provisioning: manifestProvisioning };
|
|
155
|
+
const vaultFiles = walkVault(vaultRoot);
|
|
156
|
+
const vaultDocuments = new Map(vaultFiles.map((file) => {
|
|
157
|
+
const parsed = parseDocument(file.content);
|
|
158
|
+
return [file.path, { ...file, ...parsed }];
|
|
159
|
+
}));
|
|
148
160
|
|
|
149
|
-
for (const
|
|
161
|
+
for (const document of vaultDocuments.values()) {
|
|
162
|
+
const base = path.basename(document.path);
|
|
163
|
+
if (base === 'index.md' || base === 'log.md') continue;
|
|
164
|
+
const definition = types.get(document.data.type);
|
|
165
|
+
if (!definition) throw new Error(`Cannot export '${document.path}': unknown type '${document.data.type || '(missing)'}'.`);
|
|
166
|
+
const issues = [
|
|
167
|
+
...validateDataConstraints(definition, document.data, core.universal_frontmatter?.required || ['type']),
|
|
168
|
+
...validateReferenceConstraints(
|
|
169
|
+
definition,
|
|
170
|
+
document.data,
|
|
171
|
+
(referencePath) => vaultDocuments.get(referencePath) || null,
|
|
172
|
+
types
|
|
173
|
+
),
|
|
174
|
+
];
|
|
175
|
+
if (issues.length) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Cannot export '${document.path}': ` +
|
|
178
|
+
issues.map((issue) => `${issue.field}: ${issue.issue}`).join('; ')
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const file of vaultFiles) {
|
|
150
184
|
const { data } = parseDocument(file.content);
|
|
151
185
|
const type = data.type;
|
|
152
186
|
const typeDef = types.get(type);
|
|
@@ -188,14 +222,29 @@ export function validateBundle(bundle, opts = {}) {
|
|
|
188
222
|
const errors = [];
|
|
189
223
|
const { types, core, extensions } = loadRegistries(opts.registryDir);
|
|
190
224
|
const m = bundle && bundle.manifest;
|
|
191
|
-
if (!m
|
|
225
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) {
|
|
226
|
+
return { valid: false, errors: ['bundle has no object manifest'] };
|
|
227
|
+
}
|
|
192
228
|
const files = Array.isArray(bundle.files) ? bundle.files : [];
|
|
229
|
+
const requiredExtensions = Array.isArray(m.required_extensions) ? m.required_extensions : [];
|
|
230
|
+
const provisioning = Array.isArray(m.provisioning) ? m.provisioning : [];
|
|
231
|
+
const parameters = Array.isArray(m.parameters) ? m.parameters : [];
|
|
193
232
|
|
|
194
233
|
for (const f of core.bundle.manifest_required_fields) {
|
|
195
234
|
if (m[f] === undefined || m[f] === null) errors.push(`manifest missing required field '${f}'`);
|
|
196
235
|
}
|
|
197
236
|
if (!Array.isArray(bundle.files)) errors.push('bundle.files must be an array');
|
|
198
|
-
|
|
237
|
+
if (!Array.isArray(m.required_extensions)) errors.push('manifest.required_extensions must be an array');
|
|
238
|
+
if (!Array.isArray(m.provisioning)) errors.push('manifest.provisioning must be an array');
|
|
239
|
+
if (!Array.isArray(m.parameters)) errors.push('manifest.parameters must be an array');
|
|
240
|
+
if (!m.primitive_inventory || typeof m.primitive_inventory !== 'object' || Array.isArray(m.primitive_inventory)) {
|
|
241
|
+
errors.push('manifest.primitive_inventory must be an object');
|
|
242
|
+
}
|
|
243
|
+
for (const ext of requiredExtensions) {
|
|
244
|
+
if (typeof ext !== 'string' || !ext) {
|
|
245
|
+
errors.push('required extension ids must be non-empty strings');
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
199
248
|
if (!extensions.has(ext)) errors.push(`required extension '${ext}' is not loaded`);
|
|
200
249
|
}
|
|
201
250
|
if (!PROFILE_INCLUDES[m.export_profile]) errors.push(`invalid export_profile '${m.export_profile}'`);
|
|
@@ -206,71 +255,110 @@ export function validateBundle(bundle, opts = {}) {
|
|
|
206
255
|
errors.push(`file_count ${m.file_count} ≠ actual ${files.length}`);
|
|
207
256
|
}
|
|
208
257
|
const seenPaths = new Set();
|
|
258
|
+
const bundleDocuments = new Map();
|
|
259
|
+
let fileShapeValid = true;
|
|
209
260
|
for (const file of files) {
|
|
261
|
+
if (!file || typeof file !== 'object' || Array.isArray(file)) {
|
|
262
|
+
errors.push('bundle file entries must be objects');
|
|
263
|
+
fileShapeValid = false;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
210
266
|
if (!isSafeBundlePath(file.path)) {
|
|
211
267
|
errors.push(`file '${file.path || '(missing)'}' has an unsafe bundle path`);
|
|
268
|
+
fileShapeValid = false;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (typeof file.content !== 'string') {
|
|
272
|
+
errors.push(`file '${file.path}' content must be a string`);
|
|
273
|
+
fileShapeValid = false;
|
|
212
274
|
continue;
|
|
213
275
|
}
|
|
214
276
|
if (seenPaths.has(file.path)) errors.push(`duplicate file path '${file.path}'`);
|
|
215
277
|
seenPaths.add(file.path);
|
|
278
|
+
try { bundleDocuments.set(file.path, { ...file, ...parseDocument(file.content) }); }
|
|
279
|
+
catch (error) {
|
|
280
|
+
errors.push(`file '${file.path}' could not be parsed: ${error.message}`);
|
|
281
|
+
fileShapeValid = false;
|
|
282
|
+
}
|
|
216
283
|
}
|
|
217
284
|
// Provenance: recompute the content hash and reject on mismatch (§16.3).
|
|
218
285
|
if (m.provenance) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
286
|
+
if (typeof m.provenance !== 'object' || Array.isArray(m.provenance)) {
|
|
287
|
+
errors.push('manifest.provenance must be an object');
|
|
288
|
+
} else {
|
|
289
|
+
for (const f of core.bundle.provenance_required_fields) {
|
|
290
|
+
if (m.provenance[f] === undefined) errors.push(`provenance missing '${f}'`);
|
|
291
|
+
}
|
|
292
|
+
if (fileShapeValid) {
|
|
293
|
+
const recomputed = contentHash(files);
|
|
294
|
+
if (m.provenance.content_hash && m.provenance.content_hash !== recomputed) {
|
|
295
|
+
errors.push(`content_hash mismatch: manifest ${m.provenance.content_hash} ≠ recomputed ${recomputed}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
225
298
|
}
|
|
226
299
|
}
|
|
227
300
|
// Portability conformance: no file may exceed what the profile permits (§5.5).
|
|
228
301
|
const includes = PROFILE_INCLUDES[m.export_profile];
|
|
229
302
|
if (includes) {
|
|
230
|
-
for (const
|
|
231
|
-
|
|
232
|
-
const base = path.basename(file.path || '');
|
|
303
|
+
for (const document of bundleDocuments.values()) {
|
|
304
|
+
const base = path.basename(document.path);
|
|
233
305
|
if (base === 'index.md' || base === 'log.md') continue;
|
|
234
|
-
const { data } =
|
|
306
|
+
const { data } = document;
|
|
235
307
|
const def = types.get(data.type);
|
|
236
308
|
if (!def) {
|
|
237
|
-
errors.push(`file '${
|
|
309
|
+
errors.push(`file '${document.path}' declares unknown type '${data.type || '(missing)'}'`);
|
|
238
310
|
continue;
|
|
239
311
|
}
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
for (const f of requiredFields) {
|
|
247
|
-
if (data[f] === undefined || data[f] === null || data[f] === '' || (Array.isArray(data[f]) && data[f].length === 0)) {
|
|
248
|
-
errors.push(`file '${file.path}' missing required frontmatter field '${f}'`);
|
|
249
|
-
}
|
|
312
|
+
for (const issue of validateDataConstraints(
|
|
313
|
+
def,
|
|
314
|
+
data,
|
|
315
|
+
core.universal_frontmatter?.required || ['type']
|
|
316
|
+
)) {
|
|
317
|
+
errors.push(`file '${document.path}' ${issue.field}: ${issue.issue}`);
|
|
250
318
|
}
|
|
251
319
|
const klass = resolvePortability(types.get(data.type), data);
|
|
252
320
|
if (!includes.has(klass)) {
|
|
253
|
-
errors.push(`file '${
|
|
321
|
+
errors.push(`file '${document.path}' is ${klass}, excluded by profile '${m.export_profile}'`);
|
|
254
322
|
}
|
|
255
323
|
if ((m.export_profile === 'sale' || m.export_profile === 'template') && klass === 'resource_bound') {
|
|
256
324
|
const binds = def.resource?.binds || [];
|
|
257
325
|
if (!binds.length) {
|
|
258
|
-
errors.push(`file '${
|
|
326
|
+
errors.push(`file '${document.path}' is resource_bound but registry type '${data.type}' declares no resource.binds`);
|
|
259
327
|
}
|
|
260
328
|
for (const field of binds) {
|
|
261
329
|
if (data[field] !== 'REQUIREMENT') {
|
|
262
|
-
errors.push(`file '${
|
|
330
|
+
errors.push(`file '${document.path}' leaks resource_bound field '${field}' in ${m.export_profile} bundle`);
|
|
263
331
|
}
|
|
264
332
|
}
|
|
265
333
|
}
|
|
266
334
|
}
|
|
267
335
|
}
|
|
336
|
+
for (const document of bundleDocuments.values()) {
|
|
337
|
+
const definition = types.get(document.data.type);
|
|
338
|
+
const issues = validateReferenceConstraints(
|
|
339
|
+
definition,
|
|
340
|
+
document.data,
|
|
341
|
+
(referencePath) => bundleDocuments.get(referencePath) || null,
|
|
342
|
+
types
|
|
343
|
+
);
|
|
344
|
+
for (const issue of issues) {
|
|
345
|
+
errors.push(`file '${document.path}' ${issue.field}: ${issue.issue}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
268
348
|
// Provisioning step + parameter vocabularies (§17.2 / §16.5).
|
|
269
|
-
for (const s of
|
|
349
|
+
for (const s of provisioning) {
|
|
350
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) {
|
|
351
|
+
errors.push('provisioning entries must be objects');
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
270
354
|
if (!STEP_SYSTEMS.has(s.system)) errors.push(`provisioning step '${s.id}' has invalid system '${s.system}'`);
|
|
271
355
|
if (!STEP_MODES.has(s.mode)) errors.push(`provisioning step '${s.id}' has invalid mode '${s.mode}'`);
|
|
272
356
|
}
|
|
273
|
-
for (const p of
|
|
357
|
+
for (const p of parameters) {
|
|
358
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) {
|
|
359
|
+
errors.push('parameter entries must be objects');
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
274
362
|
for (const f of core.bundle.parameter_fields.required) {
|
|
275
363
|
if (p[f] === undefined) errors.push(`parameter '${p.key || '?'}' missing '${f}'`);
|
|
276
364
|
}
|
|
@@ -342,9 +430,57 @@ export function provisionBundle(bundle, opts = {}) {
|
|
|
342
430
|
* (§6.3). Idempotent: each envelope's idempotency_key makes a re-run a no-op.
|
|
343
431
|
*/
|
|
344
432
|
export function importBundle(plan, vaultRoot, engine) {
|
|
433
|
+
const dryRunFlags = new Set(plan.map((envelope) => !!envelope.dry_run));
|
|
434
|
+
if (dryRunFlags.size > 1) {
|
|
435
|
+
return {
|
|
436
|
+
ok: false,
|
|
437
|
+
committed: 0,
|
|
438
|
+
wouldCommit: 0,
|
|
439
|
+
results: [{
|
|
440
|
+
success: false,
|
|
441
|
+
validation: { valid: false, errors: ['Import plan may not mix dry_run and commit envelopes.'] },
|
|
442
|
+
}],
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const plannedDocuments = new Map();
|
|
446
|
+
for (const envelope of plan) {
|
|
447
|
+
if (envelope.type !== 'operation' || typeof envelope.content !== 'string') continue;
|
|
448
|
+
plannedDocuments.set(envelope.path, {
|
|
449
|
+
path: envelope.path,
|
|
450
|
+
content: envelope.content,
|
|
451
|
+
...parseDocument(envelope.content),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
const operationOptions = {
|
|
455
|
+
referenceResolver: (referencePath) => plannedDocuments.get(referencePath),
|
|
456
|
+
};
|
|
457
|
+
// Two-phase import: validate the complete plan with in-plan references first.
|
|
458
|
+
// A single invalid document prevents every commit, eliminating partial bundle
|
|
459
|
+
// installation and allowing dry-run to validate cross-file constraints.
|
|
460
|
+
const preflight = plan.map((envelope) => {
|
|
461
|
+
const preflightKey = 'preflight-' + crypto.createHash('sha256')
|
|
462
|
+
.update(`${envelope.workspace_id}:${envelope.idempotency_key}:${envelope.path}`)
|
|
463
|
+
.digest('hex')
|
|
464
|
+
.slice(0, 32);
|
|
465
|
+
return engine.processOperation(
|
|
466
|
+
{ ...envelope, idempotency_key: preflightKey, dry_run: true },
|
|
467
|
+
vaultRoot,
|
|
468
|
+
operationOptions
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
if (!preflight.every((result) => result.success)) {
|
|
472
|
+
return { ok: false, committed: 0, wouldCommit: 0, results: preflight };
|
|
473
|
+
}
|
|
474
|
+
if (dryRunFlags.has(true)) {
|
|
475
|
+
return {
|
|
476
|
+
ok: true,
|
|
477
|
+
committed: 0,
|
|
478
|
+
wouldCommit: preflight.filter((result) => !result.replay).length,
|
|
479
|
+
results: preflight,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
345
482
|
const results = [];
|
|
346
|
-
for (const env of plan) results.push(engine.processOperation(env, vaultRoot));
|
|
483
|
+
for (const env of plan) results.push(engine.processOperation(env, vaultRoot, operationOptions));
|
|
347
484
|
const committed = results.filter((r) => r.success && !r.replay && !r.dry_run).length;
|
|
348
|
-
|
|
349
|
-
return { ok: results.every((r) => r.success), committed, wouldCommit, results };
|
|
485
|
+
return { ok: results.every((r) => r.success), committed, wouldCommit: 0, results };
|
|
350
486
|
}
|
package/src/engine.mjs
CHANGED
|
@@ -15,7 +15,11 @@ import fs from 'node:fs';
|
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import crypto from 'node:crypto';
|
|
17
17
|
import { parseDocument, serializeDocument } from './frontmatter.mjs';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
loadRegistries,
|
|
20
|
+
isAppendType,
|
|
21
|
+
validateReferenceConstraints,
|
|
22
|
+
} from './registry.mjs';
|
|
19
23
|
|
|
20
24
|
const ENVELOPE_TYPES = ['operation', 'patch', 'event', 'delete'];
|
|
21
25
|
const ENVELOPE_REQUIRED = {
|
|
@@ -150,7 +154,7 @@ export function createEngine({ registryDir, leaseStore } = {}) {
|
|
|
150
154
|
return { field, value };
|
|
151
155
|
}
|
|
152
156
|
|
|
153
|
-
function validateContent(resolvedType, data, filePath) {
|
|
157
|
+
function validateContent(resolvedType, data, filePath, vaultRoot, referenceResolver) {
|
|
154
158
|
const def = types.get(resolvedType);
|
|
155
159
|
const errors = [];
|
|
156
160
|
const repair = [];
|
|
@@ -193,6 +197,43 @@ export function createEngine({ registryDir, leaseStore } = {}) {
|
|
|
193
197
|
}
|
|
194
198
|
}
|
|
195
199
|
}
|
|
200
|
+
// patterns: portable string constraints declared by the registry. These
|
|
201
|
+
// are anchored by each pattern author and compiled during registry load.
|
|
202
|
+
for (const [field, pattern] of Object.entries(def.patterns || {})) {
|
|
203
|
+
if (isEmpty(data[field])) continue;
|
|
204
|
+
const values = Array.isArray(data[field]) ? data[field] : [data[field]];
|
|
205
|
+
const expression = new RegExp(pattern, 'u');
|
|
206
|
+
for (const value of values) {
|
|
207
|
+
if (typeof value !== 'string' || !expression.test(value)) {
|
|
208
|
+
errors.push(`Invalid format for field '${field}' on type '${resolvedType}'.`);
|
|
209
|
+
repair.push({ field, issue: `Must match registry pattern '${pattern}'.` });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (vaultRoot && def.references) {
|
|
214
|
+
const referenceIssues = validateReferenceConstraints(
|
|
215
|
+
def,
|
|
216
|
+
data,
|
|
217
|
+
(referencePath) => {
|
|
218
|
+
if (referenceResolver) {
|
|
219
|
+
const planned = referenceResolver(referencePath);
|
|
220
|
+
if (planned !== undefined && planned !== null) return planned;
|
|
221
|
+
}
|
|
222
|
+
const target = resolveContainedPath(vaultRoot, referencePath);
|
|
223
|
+
if (!fs.existsSync(target)) return null;
|
|
224
|
+
const stat = fs.lstatSync(target);
|
|
225
|
+
if (stat.isSymbolicLink()) throw new Error('symlinked references are not allowed');
|
|
226
|
+
if (!stat.isFile()) throw new Error('reference target is not a file');
|
|
227
|
+
const content = fs.readFileSync(target, 'utf8');
|
|
228
|
+
return { path: referencePath, content, data: parseDocument(content).data };
|
|
229
|
+
},
|
|
230
|
+
types
|
|
231
|
+
);
|
|
232
|
+
for (const issue of referenceIssues) {
|
|
233
|
+
errors.push(`${issue.field}: ${issue.issue}`);
|
|
234
|
+
repair.push(issue);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
196
237
|
return { errors, repair };
|
|
197
238
|
}
|
|
198
239
|
|
|
@@ -297,7 +338,13 @@ export function createEngine({ registryDir, leaseStore } = {}) {
|
|
|
297
338
|
const { data } = parseDocument(envelope.content);
|
|
298
339
|
resolvedType = data.type || null;
|
|
299
340
|
if (!resolvedType) { errors.push(`Missing required frontmatter field 'type' in ${envelope.path}.`); repair.push({ field: 'type', issue: 'Frontmatter must declare a type.' }); }
|
|
300
|
-
else ({ errors, repair } = validateContent(
|
|
341
|
+
else ({ errors, repair } = validateContent(
|
|
342
|
+
resolvedType,
|
|
343
|
+
data,
|
|
344
|
+
envelope.path,
|
|
345
|
+
vaultRoot,
|
|
346
|
+
options.referenceResolver
|
|
347
|
+
));
|
|
301
348
|
}
|
|
302
349
|
if (!errors.length && fs.existsSync(abs) && !envelope.path.endsWith('/index.md')) {
|
|
303
350
|
const { data: existingData } = parseDocument(fs.readFileSync(abs, 'utf8'));
|
|
@@ -305,6 +352,13 @@ export function createEngine({ registryDir, leaseStore } = {}) {
|
|
|
305
352
|
errors.push(`Type rewrite refused for ${envelope.path}: existing type '${existingData.type}' cannot be replaced by '${resolvedType}'.`);
|
|
306
353
|
repair.push({ field: 'type', issue: 'Use a migration controlled by a privileged host; operation writes may not change an existing file type.' });
|
|
307
354
|
}
|
|
355
|
+
const { data: nextData } = parseDocument(envelope.content);
|
|
356
|
+
for (const field of types.get(resolvedType)?.immutable_fields || []) {
|
|
357
|
+
if (stableStringify(existingData[field]) !== stableStringify(nextData[field])) {
|
|
358
|
+
errors.push(`Operation may not change immutable field '${field}' for ${envelope.path}.`);
|
|
359
|
+
repair.push({ field, issue: 'Use an explicit migration rather than replacing an immutable identity field.' });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
308
362
|
}
|
|
309
363
|
// append-type rewrite guard
|
|
310
364
|
if (!errors.length && isAppendType(types.get(resolvedType)) && fs.existsSync(abs)) {
|
|
@@ -326,13 +380,33 @@ export function createEngine({ registryDir, leaseStore } = {}) {
|
|
|
326
380
|
const merged = { ...data };
|
|
327
381
|
for (const [k, v] of Object.entries(envelope.patches)) if (k !== '__body__') merged[k] = v;
|
|
328
382
|
resolvedType = merged.type || null;
|
|
383
|
+
for (const field of types.get(resolvedType)?.immutable_fields || []) {
|
|
384
|
+
if (Object.prototype.hasOwnProperty.call(envelope.patches, field) &&
|
|
385
|
+
stableStringify(data[field]) !== stableStringify(merged[field])) {
|
|
386
|
+
errors.push(`Patch may not change immutable field '${field}' for ${envelope.path}.`);
|
|
387
|
+
repair.push({ field, issue: 'Use an explicit migration rather than patching an immutable identity field.' });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
329
390
|
const append = isAppendType(types.get(resolvedType));
|
|
330
391
|
let newBody = body;
|
|
331
392
|
if (envelope.patches.__body__ !== undefined) {
|
|
332
393
|
if (append) newBody = body.replace(/\s+$/, '') + '\n' + envelope.patches.__body__;
|
|
333
394
|
else newBody = envelope.patches.__body__;
|
|
334
395
|
}
|
|
335
|
-
|
|
396
|
+
if (resolvedType) {
|
|
397
|
+
const contentValidation = validateContent(
|
|
398
|
+
resolvedType,
|
|
399
|
+
merged,
|
|
400
|
+
envelope.path,
|
|
401
|
+
vaultRoot,
|
|
402
|
+
options.referenceResolver
|
|
403
|
+
);
|
|
404
|
+
errors.push(...contentValidation.errors);
|
|
405
|
+
repair.push(...contentValidation.repair);
|
|
406
|
+
} else {
|
|
407
|
+
errors.push("Missing required field 'type'.");
|
|
408
|
+
repair.push({ field: 'type', issue: 'No type after merge.' });
|
|
409
|
+
}
|
|
336
410
|
mergedForCommit = { data: merged, body: newBody };
|
|
337
411
|
}
|
|
338
412
|
} else if (envelope.type === 'event') {
|