@gregiteen/ssss-cli 0.7.0 → 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 +63 -4
- package/README.md +29 -13
- package/VERSION +1 -1
- package/conformance/README.md +17 -11
- package/conformance/fixtures.json +68 -2
- package/conformance/reference-bundle.ucw.json +39 -5
- package/docs/help/bundle.md +6 -2
- package/docs/help/import.md +6 -2
- package/docs/help/leases.md +31 -0
- package/docs/help/localization.md +43 -0
- package/docs/help/overview.md +9 -5
- package/docs/help/portability.md +11 -3
- package/docs/help/runtime.md +3 -3
- package/docs/help/scaffold.md +2 -2
- 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 +25 -2
- package/scripts/cmd-export.mjs +3 -3
- package/scripts/cmd-import.mjs +5 -1
- package/scripts/cmd-localize.mjs +44 -0
- package/scripts/cmd-new.mjs +11 -11
- package/scripts/cmd-semantic.mjs +88 -0
- package/scripts/conformance.mjs +574 -2
- package/scripts/ssss.mjs +6 -2
- 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 +266 -41
- package/src/engine.mjs +78 -4
- package/src/frontmatter.mjs +141 -38
- package/src/registry.mjs +249 -7
- package/src/semantic.mjs +402 -0
package/src/bundle.mjs
CHANGED
|
@@ -16,8 +16,15 @@
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import crypto from 'node:crypto';
|
|
19
|
-
import {
|
|
20
|
-
|
|
19
|
+
import {
|
|
20
|
+
loadRegistries,
|
|
21
|
+
resolvePortability,
|
|
22
|
+
validateDataConstraints,
|
|
23
|
+
validateReferenceConstraints,
|
|
24
|
+
} from './registry.mjs';
|
|
25
|
+
import { parseDocument, serializeDocument } from './frontmatter.mjs';
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_EXPORTER = '@gregiteen/ssss-cli';
|
|
21
28
|
|
|
22
29
|
const PROFILE_INCLUDES = {
|
|
23
30
|
backup: new Set(['structural', 'tenant_private', 'resource_bound']),
|
|
@@ -27,6 +34,13 @@ const PROFILE_INCLUDES = {
|
|
|
27
34
|
const STEP_SYSTEMS = new Set(['workspace', 'branding', 'email', 'phone', 'domains', 'accounts', 'deployments', 'marketplace', 'tabs', 'automation']);
|
|
28
35
|
const STEP_MODES = new Set(['existing', 'provision', 'install', 'generate', 'configure']);
|
|
29
36
|
const PARAM_SOURCES = new Set(['user', 'llm', 'graph', 'system']);
|
|
37
|
+
const RESOURCE_SYSTEM_BY_KIND = {
|
|
38
|
+
api_connection: 'automation',
|
|
39
|
+
domain: 'domains',
|
|
40
|
+
email: 'email',
|
|
41
|
+
mailbox: 'email',
|
|
42
|
+
phone: 'phone',
|
|
43
|
+
};
|
|
30
44
|
|
|
31
45
|
function isSafeBundlePath(p) {
|
|
32
46
|
if (typeof p !== 'string' || !p || p.includes('\0')) return false;
|
|
@@ -41,6 +55,62 @@ function canonicalFiles(files) {
|
|
|
41
55
|
return sorted.map((f) => ({ path: f.path, content: f.content }));
|
|
42
56
|
}
|
|
43
57
|
|
|
58
|
+
function requirementParameterKey(type, field) {
|
|
59
|
+
return `${type}_${field}`.replace(/[^A-Za-z0-9_]+/g, '_');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resourceFieldParameterAliases(type, field) {
|
|
63
|
+
const aliases = [requirementParameterKey(type, field), field];
|
|
64
|
+
if (type === 'domain' && field === 'domain_name') aliases.push('domain');
|
|
65
|
+
if (type === 'phone_number' && field === 'phone_number') aliases.push('phone');
|
|
66
|
+
return aliases;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ensureRequirementDeclarations(manifest, typeDef, resourceFields) {
|
|
70
|
+
const type = typeDef.type;
|
|
71
|
+
const resource = typeDef.resource || {};
|
|
72
|
+
const existingParams = new Set((manifest.parameters || []).map((p) => p.key));
|
|
73
|
+
for (const field of resourceFields) {
|
|
74
|
+
if (resourceFieldParameterAliases(type, field).some((key) => existingParams.has(key))) continue;
|
|
75
|
+
const key = requirementParameterKey(type, field);
|
|
76
|
+
if (!existingParams.has(key)) {
|
|
77
|
+
manifest.parameters.push({
|
|
78
|
+
key,
|
|
79
|
+
label: `${resource.kind || type} ${field}`.replace(/_/g, ' '),
|
|
80
|
+
type: 'string',
|
|
81
|
+
scope: 'workspace',
|
|
82
|
+
source: 'user',
|
|
83
|
+
required: false,
|
|
84
|
+
description: `Satisfies the ${type} resource requirement field '${field}'.`,
|
|
85
|
+
});
|
|
86
|
+
existingParams.add(key);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const stepId = `bind-${type}`.replace(/[^A-Za-z0-9_-]+/g, '-');
|
|
91
|
+
if (!(manifest.provisioning || []).some((s) => s.id === stepId)) {
|
|
92
|
+
manifest.provisioning.push({
|
|
93
|
+
id: stepId,
|
|
94
|
+
label: `Bind ${resource.kind || type}`,
|
|
95
|
+
system: RESOURCE_SYSTEM_BY_KIND[resource.kind] || 'accounts',
|
|
96
|
+
mode: 'provision',
|
|
97
|
+
required: true,
|
|
98
|
+
notes: resource.provision_relation || 'resource_bound requirement',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function reduceResourceBoundFile(file, typeDef, profile) {
|
|
104
|
+
const binds = typeDef?.resource?.binds;
|
|
105
|
+
if (!Array.isArray(binds) || binds.length === 0) {
|
|
106
|
+
throw new Error(`Cannot export resource_bound '${file.path}' in ${profile}: registry type '${typeDef?.type || '(unknown)'}' does not declare resource.binds.`);
|
|
107
|
+
}
|
|
108
|
+
const { data, body } = parseDocument(file.content);
|
|
109
|
+
const reduced = { ...data, x_portability: 'resource_bound' };
|
|
110
|
+
for (const field of binds) reduced[field] = 'REQUIREMENT';
|
|
111
|
+
return { path: file.path, content: serializeDocument(reduced, body), resourceFields: binds };
|
|
112
|
+
}
|
|
113
|
+
|
|
44
114
|
/** sha256 over the canonical file serialization (§16.3). */
|
|
45
115
|
export function contentHash(files) {
|
|
46
116
|
const pre = JSON.stringify(canonicalFiles(files));
|
|
@@ -70,23 +140,59 @@ function walkVault(vaultRoot) {
|
|
|
70
140
|
*/
|
|
71
141
|
export function exportBundle(vaultRoot, opts = {}) {
|
|
72
142
|
const { profile = 'backup', registryDir, name = 'untitled', description = '', version = '0.1.0',
|
|
73
|
-
exporter =
|
|
143
|
+
exporter = DEFAULT_EXPORTER, requiredExtensions = [], parameters = [], provisioning = [],
|
|
74
144
|
sourceWorkspaceId, exportedAt = '1970-01-01T00:00:00.000Z' } = opts;
|
|
75
145
|
const includes = PROFILE_INCLUDES[profile];
|
|
76
146
|
if (!includes) throw new Error(`Unknown export profile '${profile}'. Use backup | template | sale.`);
|
|
77
147
|
|
|
78
|
-
const { types } = loadRegistries(registryDir);
|
|
148
|
+
const { types, core } = loadRegistries(registryDir);
|
|
79
149
|
const dropped = [];
|
|
80
150
|
const kept = [];
|
|
81
151
|
const inventory = {};
|
|
152
|
+
const manifestParameters = [...parameters];
|
|
153
|
+
const manifestProvisioning = [...provisioning];
|
|
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
|
+
}));
|
|
160
|
+
|
|
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
|
+
}
|
|
82
182
|
|
|
83
|
-
for (const file of
|
|
183
|
+
for (const file of vaultFiles) {
|
|
84
184
|
const { data } = parseDocument(file.content);
|
|
85
185
|
const type = data.type;
|
|
86
186
|
const typeDef = types.get(type);
|
|
87
187
|
const klass = resolvePortability(typeDef, data);
|
|
88
188
|
if (!includes.has(klass)) { dropped.push({ path: file.path, type, portability: klass }); continue; }
|
|
89
|
-
|
|
189
|
+
if ((profile === 'sale' || profile === 'template') && klass === 'resource_bound') {
|
|
190
|
+
const reduced = reduceResourceBoundFile(file, typeDef, profile);
|
|
191
|
+
kept.push({ path: reduced.path, content: reduced.content });
|
|
192
|
+
ensureRequirementDeclarations(manifestDraft, typeDef, reduced.resourceFields);
|
|
193
|
+
} else {
|
|
194
|
+
kept.push(file);
|
|
195
|
+
}
|
|
90
196
|
if (type) inventory[type] = (inventory[type] || 0) + 1;
|
|
91
197
|
}
|
|
92
198
|
|
|
@@ -94,12 +200,12 @@ export function exportBundle(vaultRoot, opts = {}) {
|
|
|
94
200
|
const manifest = {
|
|
95
201
|
name, description, version,
|
|
96
202
|
exported_at: exportedAt,
|
|
97
|
-
ssss_core_version:
|
|
203
|
+
ssss_core_version: core.spec_version,
|
|
98
204
|
required_extensions: requiredExtensions,
|
|
99
205
|
export_profile: profile,
|
|
100
206
|
primitive_inventory: inventory,
|
|
101
|
-
provisioning,
|
|
102
|
-
parameters,
|
|
207
|
+
provisioning: manifestProvisioning,
|
|
208
|
+
parameters: manifestParameters,
|
|
103
209
|
file_count: files.length,
|
|
104
210
|
provenance: { content_hash: contentHash(files), exporter },
|
|
105
211
|
};
|
|
@@ -114,75 +220,145 @@ export function exportBundle(vaultRoot, opts = {}) {
|
|
|
114
220
|
*/
|
|
115
221
|
export function validateBundle(bundle, opts = {}) {
|
|
116
222
|
const errors = [];
|
|
117
|
-
const { types, core } = loadRegistries(opts.registryDir);
|
|
223
|
+
const { types, core, extensions } = loadRegistries(opts.registryDir);
|
|
118
224
|
const m = bundle && bundle.manifest;
|
|
119
|
-
if (!m
|
|
225
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) {
|
|
226
|
+
return { valid: false, errors: ['bundle has no object manifest'] };
|
|
227
|
+
}
|
|
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 : [];
|
|
120
232
|
|
|
121
233
|
for (const f of core.bundle.manifest_required_fields) {
|
|
122
234
|
if (m[f] === undefined || m[f] === null) errors.push(`manifest missing required field '${f}'`);
|
|
123
235
|
}
|
|
236
|
+
if (!Array.isArray(bundle.files)) errors.push('bundle.files must be an array');
|
|
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
|
+
}
|
|
248
|
+
if (!extensions.has(ext)) errors.push(`required extension '${ext}' is not loaded`);
|
|
249
|
+
}
|
|
124
250
|
if (!PROFILE_INCLUDES[m.export_profile]) errors.push(`invalid export_profile '${m.export_profile}'`);
|
|
125
251
|
if (m.ssss_core_version && m.ssss_core_version !== core.spec_version) {
|
|
126
252
|
errors.push(`bundle targets ssss_core_version ${m.ssss_core_version}; host is ${core.spec_version}`);
|
|
127
253
|
}
|
|
128
|
-
if (
|
|
129
|
-
errors.push(`file_count ${m.file_count} ≠ actual ${
|
|
254
|
+
if (m.file_count !== files.length) {
|
|
255
|
+
errors.push(`file_count ${m.file_count} ≠ actual ${files.length}`);
|
|
130
256
|
}
|
|
131
257
|
const seenPaths = new Set();
|
|
132
|
-
|
|
258
|
+
const bundleDocuments = new Map();
|
|
259
|
+
let fileShapeValid = true;
|
|
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
|
+
}
|
|
133
266
|
if (!isSafeBundlePath(file.path)) {
|
|
134
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;
|
|
135
274
|
continue;
|
|
136
275
|
}
|
|
137
276
|
if (seenPaths.has(file.path)) errors.push(`duplicate file path '${file.path}'`);
|
|
138
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
|
+
}
|
|
139
283
|
}
|
|
140
284
|
// Provenance: recompute the content hash and reject on mismatch (§16.3).
|
|
141
285
|
if (m.provenance) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
}
|
|
148
298
|
}
|
|
149
299
|
}
|
|
150
300
|
// Portability conformance: no file may exceed what the profile permits (§5.5).
|
|
151
301
|
const includes = PROFILE_INCLUDES[m.export_profile];
|
|
152
302
|
if (includes) {
|
|
153
|
-
for (const
|
|
154
|
-
|
|
155
|
-
const base = path.basename(file.path || '');
|
|
303
|
+
for (const document of bundleDocuments.values()) {
|
|
304
|
+
const base = path.basename(document.path);
|
|
156
305
|
if (base === 'index.md' || base === 'log.md') continue;
|
|
157
|
-
const { data } =
|
|
306
|
+
const { data } = document;
|
|
158
307
|
const def = types.get(data.type);
|
|
159
308
|
if (!def) {
|
|
160
|
-
errors.push(`file '${
|
|
309
|
+
errors.push(`file '${document.path}' declares unknown type '${data.type || '(missing)'}'`);
|
|
161
310
|
continue;
|
|
162
311
|
}
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
for (const f of requiredFields) {
|
|
170
|
-
if (data[f] === undefined || data[f] === null || data[f] === '' || (Array.isArray(data[f]) && data[f].length === 0)) {
|
|
171
|
-
errors.push(`file '${file.path}' missing required frontmatter field '${f}'`);
|
|
172
|
-
}
|
|
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}`);
|
|
173
318
|
}
|
|
174
319
|
const klass = resolvePortability(types.get(data.type), data);
|
|
175
320
|
if (!includes.has(klass)) {
|
|
176
|
-
errors.push(`file '${
|
|
321
|
+
errors.push(`file '${document.path}' is ${klass}, excluded by profile '${m.export_profile}'`);
|
|
322
|
+
}
|
|
323
|
+
if ((m.export_profile === 'sale' || m.export_profile === 'template') && klass === 'resource_bound') {
|
|
324
|
+
const binds = def.resource?.binds || [];
|
|
325
|
+
if (!binds.length) {
|
|
326
|
+
errors.push(`file '${document.path}' is resource_bound but registry type '${data.type}' declares no resource.binds`);
|
|
327
|
+
}
|
|
328
|
+
for (const field of binds) {
|
|
329
|
+
if (data[field] !== 'REQUIREMENT') {
|
|
330
|
+
errors.push(`file '${document.path}' leaks resource_bound field '${field}' in ${m.export_profile} bundle`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
177
333
|
}
|
|
178
334
|
}
|
|
179
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
|
+
}
|
|
180
348
|
// Provisioning step + parameter vocabularies (§17.2 / §16.5).
|
|
181
|
-
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
|
+
}
|
|
182
354
|
if (!STEP_SYSTEMS.has(s.system)) errors.push(`provisioning step '${s.id}' has invalid system '${s.system}'`);
|
|
183
355
|
if (!STEP_MODES.has(s.mode)) errors.push(`provisioning step '${s.id}' has invalid mode '${s.mode}'`);
|
|
184
356
|
}
|
|
185
|
-
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
|
+
}
|
|
186
362
|
for (const f of core.bundle.parameter_fields.required) {
|
|
187
363
|
if (p[f] === undefined) errors.push(`parameter '${p.key || '?'}' missing '${f}'`);
|
|
188
364
|
}
|
|
@@ -254,8 +430,57 @@ export function provisionBundle(bundle, opts = {}) {
|
|
|
254
430
|
* (§6.3). Idempotent: each envelope's idempotency_key makes a re-run a no-op.
|
|
255
431
|
*/
|
|
256
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
|
+
}
|
|
257
482
|
const results = [];
|
|
258
|
-
for (const env of plan) results.push(engine.processOperation(env, vaultRoot));
|
|
259
|
-
const committed = results.filter((r) => r.success && !r.replay).length;
|
|
260
|
-
return { ok: results.every((r) => r.success), committed, results };
|
|
483
|
+
for (const env of plan) results.push(engine.processOperation(env, vaultRoot, operationOptions));
|
|
484
|
+
const committed = results.filter((r) => r.success && !r.replay && !r.dry_run).length;
|
|
485
|
+
return { ok: results.every((r) => r.success), committed, wouldCommit: 0, results };
|
|
261
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') {
|