@gregiteen/ssss-cli 0.7.1 → 0.9.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 +19 -4
- package/VERSION +1 -1
- package/conformance/README.md +11 -7
- package/conformance/fixtures.json +24 -2
- package/conformance/reference-bundle.ucw.json +2 -2
- package/docs/help/bundle.md +6 -2
- package/docs/help/import.md +2 -0
- package/docs/help/overview.md +8 -4
- package/docs/help/semantic.md +38 -0
- package/docs/ssss-spec.md +67 -7
- package/package.json +27 -3
- package/registry/core.json +36 -3
- package/scripts/audit-registry-field-usage.mjs +10 -1
- package/scripts/build-reference-bundle.mjs +4 -1
- package/scripts/cmd-adapter.mjs +29 -0
- package/scripts/cmd-export.mjs +3 -3
- package/scripts/cmd-import.mjs +1 -1
- package/scripts/cmd-migrate.mjs +26 -0
- package/scripts/cmd-new.mjs +5 -5
- package/scripts/cmd-primitive.mjs +48 -0
- package/scripts/cmd-registry.mjs +25 -0
- package/scripts/cmd-semantic.mjs +86 -0
- package/scripts/conformance-09.mjs +199 -0
- package/scripts/conformance.mjs +320 -28
- package/scripts/ssss.mjs +10 -0
- package/skills/ssss/SKILL.md +221 -0
- package/skills/ssss/evals/evals.json +37 -0
- package/skills/ssss/references/contract-reference.md +95 -0
- package/skills/ssss/scripts/validate-schema.mjs +215 -0
- package/skills/ssss/subagents/audit-ssss-contract.md +22 -0
- package/src/authorization.mjs +61 -0
- package/src/bundle.mjs +171 -35
- package/src/engine.mjs +101 -429
- package/src/events.mjs +90 -0
- package/src/guard.mjs +32 -0
- package/src/http.mjs +26 -0
- package/src/idempotency.mjs +55 -0
- package/src/kernel.mjs +260 -0
- package/src/leases.mjs +125 -0
- package/src/primitive.mjs +227 -0
- package/src/projections.mjs +62 -0
- package/src/registry.mjs +408 -17
- package/src/semantic.mjs +210 -0
- package/src/ui.mjs +100 -0
- package/src/validator.mjs +87 -0
- package/src/vfs.mjs +203 -0
- package/types/kernel.d.ts +48 -0
package/src/registry.mjs
CHANGED
|
@@ -10,12 +10,292 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
13
14
|
import { fileURLToPath } from 'node:url';
|
|
14
15
|
|
|
15
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
17
|
const DEFAULT_REGISTRY_DIR = path.resolve(__dirname, '..', 'registry');
|
|
17
18
|
|
|
18
19
|
const APPEND_TYPES = new Set(['conversation', 'run']);
|
|
20
|
+
const SAFE_REGISTRY_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
21
|
+
const SAFE_TYPE_NAME = /^(?:[a-z][a-z0-9_-]{0,63}:)?[a-z][a-z0-9_-]{0,127}$/;
|
|
22
|
+
const SAFE_FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
23
|
+
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertRecord(value, label) {
|
|
29
|
+
if (value === undefined) return;
|
|
30
|
+
if (!isRecord(value)) throw new Error(`${label} must be an object.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertStringArray(value, label) {
|
|
34
|
+
if (value === undefined) return;
|
|
35
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item)) {
|
|
36
|
+
throw new Error(`${label} must be an array of non-empty strings.`);
|
|
37
|
+
}
|
|
38
|
+
if (new Set(value).size !== value.length) throw new Error(`${label} must not contain duplicates.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validatePrimitiveDefinition(name, def, source, portabilityClasses) {
|
|
42
|
+
if (!SAFE_TYPE_NAME.test(name)) throw new Error(`${source}: unsafe primitive name '${name}'.`);
|
|
43
|
+
if (!def || typeof def !== 'object' || Array.isArray(def)) {
|
|
44
|
+
throw new Error(`${source}: primitive '${name}' must be an object.`);
|
|
45
|
+
}
|
|
46
|
+
if (typeof def.append_only !== 'boolean') {
|
|
47
|
+
throw new Error(`${source}: primitive '${name}' must declare append_only as a boolean.`);
|
|
48
|
+
}
|
|
49
|
+
if (!portabilityClasses.includes(def.portability)) {
|
|
50
|
+
throw new Error(`${source}: primitive '${name}' has invalid portability '${def.portability}'.`);
|
|
51
|
+
}
|
|
52
|
+
assertStringArray(def.required_fields, `${source}: primitive '${name}' required_fields`);
|
|
53
|
+
assertStringArray(def.optional_fields, `${source}: primitive '${name}' optional_fields`);
|
|
54
|
+
assertStringArray(def.immutable_fields, `${source}: primitive '${name}' immutable_fields`);
|
|
55
|
+
|
|
56
|
+
assertRecord(def.required_when, `${source}: primitive '${name}' required_when`);
|
|
57
|
+
for (const [condition, fields] of Object.entries(def.required_when || {})) {
|
|
58
|
+
if (!/^[A-Za-z_][A-Za-z0-9_-]*==.+$/.test(condition)) {
|
|
59
|
+
throw new Error(`${source}: primitive '${name}' has invalid required_when condition '${condition}'.`);
|
|
60
|
+
}
|
|
61
|
+
assertStringArray(fields, `${source}: primitive '${name}' required_when '${condition}'`);
|
|
62
|
+
}
|
|
63
|
+
assertRecord(def.patterns, `${source}: primitive '${name}' patterns`);
|
|
64
|
+
for (const [field, pattern] of Object.entries(def.patterns || {})) {
|
|
65
|
+
if (!SAFE_FIELD_NAME.test(field)) {
|
|
66
|
+
throw new Error(`${source}: primitive '${name}' has unsafe pattern field '${field}'.`);
|
|
67
|
+
}
|
|
68
|
+
if (typeof pattern !== 'string') {
|
|
69
|
+
throw new Error(`${source}: primitive '${name}' pattern for '${field}' must be a string.`);
|
|
70
|
+
}
|
|
71
|
+
try { new RegExp(pattern, 'u'); }
|
|
72
|
+
catch (error) {
|
|
73
|
+
throw new Error(`${source}: primitive '${name}' has invalid pattern for '${field}': ${error.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
assertRecord(def.enums, `${source}: primitive '${name}' enums`);
|
|
77
|
+
for (const [field, values] of Object.entries(def.enums || {})) {
|
|
78
|
+
if (!SAFE_FIELD_NAME.test(field)) {
|
|
79
|
+
throw new Error(`${source}: primitive '${name}' has unsafe enum field '${field}'.`);
|
|
80
|
+
}
|
|
81
|
+
assertStringArray(values, `${source}: primitive '${name}' enum '${field}'`);
|
|
82
|
+
}
|
|
83
|
+
assertRecord(def.references, `${source}: primitive '${name}' references`);
|
|
84
|
+
for (const [field, constraint] of Object.entries(def.references || {})) {
|
|
85
|
+
if (!SAFE_FIELD_NAME.test(field)) {
|
|
86
|
+
throw new Error(`${source}: primitive '${name}' has unsafe reference field '${field}'.`);
|
|
87
|
+
}
|
|
88
|
+
if (!constraint || typeof constraint !== 'object' || Array.isArray(constraint)) {
|
|
89
|
+
throw new Error(`${source}: primitive '${name}' reference '${field}' must be an object.`);
|
|
90
|
+
}
|
|
91
|
+
assertStringArray(constraint.allowed_types, `${source}: primitive '${name}' reference '${field}' allowed_types`);
|
|
92
|
+
assertStringArray(constraint.disallowed_types, `${source}: primitive '${name}' reference '${field}' disallowed_types`);
|
|
93
|
+
assertStringArray(constraint.allowed_portability, `${source}: primitive '${name}' reference '${field}' allowed_portability`);
|
|
94
|
+
if (constraint.allowed_portability?.some((value) => !portabilityClasses.includes(value))) {
|
|
95
|
+
throw new Error(`${source}: primitive '${name}' reference '${field}' has an invalid allowed_portability value.`);
|
|
96
|
+
}
|
|
97
|
+
if (constraint.must_exist !== undefined && typeof constraint.must_exist !== 'boolean') {
|
|
98
|
+
throw new Error(`${source}: primitive '${name}' reference '${field}' must_exist must be a boolean.`);
|
|
99
|
+
}
|
|
100
|
+
const overlap = (constraint.allowed_types || []).filter((value) =>
|
|
101
|
+
constraint.disallowed_types?.includes(value));
|
|
102
|
+
if (overlap.length) {
|
|
103
|
+
throw new Error(`${source}: primitive '${name}' reference '${field}' both allows and forbids: ${overlap.join(', ')}.`);
|
|
104
|
+
}
|
|
105
|
+
if (constraint.hash_field !== undefined &&
|
|
106
|
+
(typeof constraint.hash_field !== 'string' || !SAFE_FIELD_NAME.test(constraint.hash_field))) {
|
|
107
|
+
throw new Error(`${source}: primitive '${name}' reference '${field}' hash_field must be a non-empty string.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Return true when a primitive identifier is a safe local or qualified name. */
|
|
113
|
+
export function isPrimitiveTypeName(value) {
|
|
114
|
+
return typeof value === 'string' && SAFE_TYPE_NAME.test(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeExtensionInput(item, index) {
|
|
118
|
+
if (item?.document_primitives) return { source: item.source || `extension[${index}]`, value: item };
|
|
119
|
+
if (item?.registry?.document_primitives) {
|
|
120
|
+
return { source: item.source || `extension[${index}]`, value: item.registry };
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`extension[${index}] must be an extension registry object.`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function semverSatisfies(version, range) {
|
|
126
|
+
if (range === '*' || range === undefined) return true;
|
|
127
|
+
if (version === range) return true;
|
|
128
|
+
const current = String(version || '').match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
129
|
+
const wanted = String(range).match(/^([~^])(\d+)\.(\d+)\.(\d+)$/);
|
|
130
|
+
if (!current || !wanted) return false;
|
|
131
|
+
const [, operator, major, minor, patch] = wanted;
|
|
132
|
+
const [cm, cn, cp] = current.slice(1).map(Number);
|
|
133
|
+
const [wm, wn, wp] = [major, minor, patch].map(Number);
|
|
134
|
+
if (operator === '^') return cm === wm && (cn > wn || (cn === wn && cp >= wp));
|
|
135
|
+
return cm === wm && cn === wn && cp >= wp;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Compose the package core with extension/repository/workspace registry objects.
|
|
140
|
+
* This is the runtime composition path; hosts do not copy core.json into their repos.
|
|
141
|
+
*/
|
|
142
|
+
export function composeRegistries({ core, extensions: extensionInputs = [] } = {}) {
|
|
143
|
+
if (!isRecord(core)) throw new Error('core registry must be an object.');
|
|
144
|
+
const portabilityClasses = Object.keys(core.portability?.classes || {});
|
|
145
|
+
if (!portabilityClasses.length) throw new Error('core registry: portability.classes must not be empty.');
|
|
146
|
+
|
|
147
|
+
const extensions = new Set();
|
|
148
|
+
const extensionVersions = new Map();
|
|
149
|
+
const extensionRequirements = [];
|
|
150
|
+
const types = new Map();
|
|
151
|
+
const aliases = new Map();
|
|
152
|
+
|
|
153
|
+
for (const [name, def] of Object.entries(core.document_primitives || {})) {
|
|
154
|
+
validatePrimitiveDefinition(name, def, 'core registry', portabilityClasses);
|
|
155
|
+
const value = { ...def, type: name, qualified_type: `ssss:${name}`, registry: 'core' };
|
|
156
|
+
types.set(name, value);
|
|
157
|
+
aliases.set(`ssss:${name}`, name);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const [index, item] of extensionInputs.entries()) {
|
|
161
|
+
const { source, value: ext } = normalizeExtensionInput(item, index);
|
|
162
|
+
if (!SAFE_REGISTRY_NAME.test(ext.registry || '')) {
|
|
163
|
+
throw new Error(`${source}: registry must be a safe identifier.`);
|
|
164
|
+
}
|
|
165
|
+
if (ext.registry === 'core' || ext.registry === 'ssss' || extensions.has(ext.registry)) {
|
|
166
|
+
throw new Error(`${source}: duplicate or reserved registry name '${ext.registry}'.`);
|
|
167
|
+
}
|
|
168
|
+
if (ext.extends !== undefined && ext.extends !== 'core' && ext.extends !== 'ssss') {
|
|
169
|
+
throw new Error(`${source}: extends must be 'core' or 'ssss'.`);
|
|
170
|
+
}
|
|
171
|
+
if (ext.version !== undefined &&
|
|
172
|
+
(typeof ext.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(ext.version))) {
|
|
173
|
+
throw new Error(`${source}: version must be a semantic version string.`);
|
|
174
|
+
}
|
|
175
|
+
assertRecord(ext.requires, `${source}: requires`);
|
|
176
|
+
extensions.add(ext.registry);
|
|
177
|
+
extensionVersions.set(ext.registry, ext.version || '0.0.0');
|
|
178
|
+
extensionRequirements.push({ source, registry: ext.registry, requires: ext.requires || {} });
|
|
179
|
+
|
|
180
|
+
for (const [name, def] of Object.entries(ext.document_primitives || {})) {
|
|
181
|
+
validatePrimitiveDefinition(name, def, source, portabilityClasses);
|
|
182
|
+
const qualified = name.includes(':') ? name : `${ext.registry}:${name}`;
|
|
183
|
+
if (name.includes(':') && !name.startsWith(`${ext.registry}:`)) {
|
|
184
|
+
throw new Error(`${source}: primitive '${name}' must use registry namespace '${ext.registry}'.`);
|
|
185
|
+
}
|
|
186
|
+
if (types.has(name) || aliases.has(name) || types.has(qualified) || aliases.has(qualified)) {
|
|
187
|
+
const existing = types.get(name) || types.get(aliases.get(name)) ||
|
|
188
|
+
types.get(qualified) || types.get(aliases.get(qualified));
|
|
189
|
+
throw new Error(
|
|
190
|
+
`${source}: primitive '${name}' collides with registry '${existing?.registry || 'unknown'}'.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const value = { ...def, type: name, qualified_type: qualified, registry: ext.registry };
|
|
194
|
+
types.set(name, value);
|
|
195
|
+
if (qualified !== name) aliases.set(qualified, name);
|
|
196
|
+
for (const alias of def.aliases || []) {
|
|
197
|
+
if (!isPrimitiveTypeName(alias)) throw new Error(`${source}: primitive '${name}' has unsafe alias '${alias}'.`);
|
|
198
|
+
if (types.has(alias) || aliases.has(alias)) throw new Error(`${source}: primitive alias '${alias}' collides with an existing primitive.`);
|
|
199
|
+
aliases.set(alias, name);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const requirement of extensionRequirements) {
|
|
205
|
+
for (const [dependency, range] of Object.entries(requirement.requires)) {
|
|
206
|
+
if (!extensionVersions.has(dependency)) throw new Error(`${requirement.source}: missing required extension '${dependency}'.`);
|
|
207
|
+
if (typeof range !== 'string' || !semverSatisfies(extensionVersions.get(dependency), range)) {
|
|
208
|
+
throw new Error(`${requirement.source}: extension '${dependency}' version '${extensionVersions.get(dependency)}' does not satisfy '${range}'.`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const registrySet = {
|
|
214
|
+
types,
|
|
215
|
+
aliases,
|
|
216
|
+
contractTypes: new Set(Object.keys(core.contract_primitives || {})),
|
|
217
|
+
portabilityClasses,
|
|
218
|
+
core,
|
|
219
|
+
extensions,
|
|
220
|
+
extensionVersions,
|
|
221
|
+
};
|
|
222
|
+
// Backward-compatible Map surface used by the existing engine/bundle APIs.
|
|
223
|
+
// The alias table lets shared validators resolve qualified 0.9 identifiers.
|
|
224
|
+
types.aliases = aliases;
|
|
225
|
+
|
|
226
|
+
for (const def of types.values()) {
|
|
227
|
+
for (const [field, constraint] of Object.entries(def.references || {})) {
|
|
228
|
+
for (const target of [...(constraint.allowed_types || []), ...(constraint.disallowed_types || [])]) {
|
|
229
|
+
if (!isPrimitiveTypeName(target) || !resolvePrimitiveDefinition(registrySet, target)) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`registry '${def.registry}' primitive '${def.type}' reference '${field}' names unknown type '${target}'.`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return registrySet;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Compose named ownership layers without giving lower scopes override semantics. */
|
|
242
|
+
export function composeRegistryLayers({ core, installed = [], repository = [], workspace = [], user = [], policyFloors = {} } = {}) {
|
|
243
|
+
const extensions = [
|
|
244
|
+
...installed.map((registry) => ({ source: 'installed', registry })),
|
|
245
|
+
...repository.map((registry) => ({ source: 'repository', registry })),
|
|
246
|
+
...workspace.map((registry) => ({ source: 'workspace', registry })),
|
|
247
|
+
...user.map((registry) => ({ source: 'user', registry })),
|
|
248
|
+
];
|
|
249
|
+
const registrySet = composeRegistries({ core, extensions });
|
|
250
|
+
for (const [type, actions] of Object.entries(policyFloors)) {
|
|
251
|
+
const definition = resolvePrimitiveDefinition(registrySet, type);
|
|
252
|
+
if (!definition) throw new Error(`Policy floor names unknown primitive '${type}'.`);
|
|
253
|
+
for (const [action, required] of Object.entries(actions || {})) {
|
|
254
|
+
assertStringArray(required, `policy floor '${type}.${action}'`);
|
|
255
|
+
const declared = new Set(definition.capabilities?.[action] || []);
|
|
256
|
+
const missing = required.filter((capability) => !declared.has(capability));
|
|
257
|
+
if (missing.length) throw new Error(`Primitive '${type}' weakens policy floor for '${action}': ${missing.join(', ')}.`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return registrySet;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function stableObject(value) {
|
|
264
|
+
if (Array.isArray(value)) return value.map(stableObject);
|
|
265
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableObject(value[key])]));
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function createRegistryLock(registrySet) {
|
|
270
|
+
const primitives = [...registrySet.types.values()]
|
|
271
|
+
.map((definition) => ({ ...definition }))
|
|
272
|
+
.sort((a, b) => a.qualified_type.localeCompare(b.qualified_type));
|
|
273
|
+
const payload = {
|
|
274
|
+
version: 1,
|
|
275
|
+
core_spec_version: registrySet.core.spec_version,
|
|
276
|
+
extensions: Object.fromEntries([...registrySet.extensionVersions.entries()].sort()),
|
|
277
|
+
primitives,
|
|
278
|
+
};
|
|
279
|
+
return { ...payload, integrity: `sha256:${crypto.createHash('sha256').update(JSON.stringify(stableObject(payload))).digest('hex')}` };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function verifyRegistryLock(registrySet, lock) {
|
|
283
|
+
const expected = createRegistryLock(registrySet);
|
|
284
|
+
return {
|
|
285
|
+
valid: !!lock && lock.integrity === expected.integrity,
|
|
286
|
+
expected_integrity: expected.integrity,
|
|
287
|
+
actual_integrity: lock?.integrity || null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Resolve local, qualified, or explicit migration-alias primitive identifiers. */
|
|
292
|
+
export function resolvePrimitiveDefinition(registrySet, type) {
|
|
293
|
+
if (!registrySet || typeof type !== 'string') return null;
|
|
294
|
+
const direct = registrySet.types?.get(type);
|
|
295
|
+
if (direct) return direct;
|
|
296
|
+
const local = registrySet.aliases?.get(type);
|
|
297
|
+
return local ? registrySet.types.get(local) || null : null;
|
|
298
|
+
}
|
|
19
299
|
|
|
20
300
|
/**
|
|
21
301
|
* Load the core registry and merge extension registries.
|
|
@@ -25,28 +305,21 @@ const APPEND_TYPES = new Set(['conversation', 'run']);
|
|
|
25
305
|
export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
|
|
26
306
|
const corePath = path.join(registryDir, 'core.json');
|
|
27
307
|
const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const types = new Map();
|
|
31
|
-
for (const [name, def] of Object.entries(core.document_primitives || {})) {
|
|
32
|
-
types.set(name, { ...def, type: name, registry: 'core' });
|
|
33
|
-
}
|
|
34
|
-
|
|
308
|
+
const extensionInputs = [];
|
|
35
309
|
const extDir = path.join(registryDir, 'extensions');
|
|
36
310
|
if (fs.existsSync(extDir)) {
|
|
37
|
-
for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json'))) {
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
if (types.has(name) && types.get(name).registry === 'core') continue; // core wins; extensions MUST NOT redefine
|
|
42
|
-
types.set(name, { ...def, type: name, registry: ext.registry || file });
|
|
311
|
+
for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json')).sort()) {
|
|
312
|
+
const extPath = path.join(extDir, file);
|
|
313
|
+
if (fs.lstatSync(extPath).isSymbolicLink()) {
|
|
314
|
+
throw new Error(`extensions/${file}: symlinked registries are not allowed.`);
|
|
43
315
|
}
|
|
316
|
+
extensionInputs.push({
|
|
317
|
+
source: `extensions/${file}`,
|
|
318
|
+
registry: JSON.parse(fs.readFileSync(extPath, 'utf8')),
|
|
319
|
+
});
|
|
44
320
|
}
|
|
45
321
|
}
|
|
46
|
-
|
|
47
|
-
const contractTypes = new Set(Object.keys(core.contract_primitives || {}));
|
|
48
|
-
const portabilityClasses = Object.keys(core.portability?.classes || {});
|
|
49
|
-
return { types, contractTypes, portabilityClasses, core, extensions };
|
|
322
|
+
return composeRegistries({ core, extensions: extensionInputs });
|
|
50
323
|
}
|
|
51
324
|
|
|
52
325
|
/** Is this document type append-only (events appended, never rewritten)? */
|
|
@@ -67,3 +340,121 @@ export function resolvePortability(typeDef, frontmatter) {
|
|
|
67
340
|
if (override && order[override] != null && order[override] > (order[base] ?? 0)) return override;
|
|
68
341
|
return base;
|
|
69
342
|
}
|
|
343
|
+
|
|
344
|
+
/** Is a registry-declared document reference a safe vault-relative path? */
|
|
345
|
+
export function isSafeDocumentPath(value) {
|
|
346
|
+
if (typeof value !== 'string' || !value || value.includes('\0') || value.includes('\\')) return false;
|
|
347
|
+
if (value.startsWith('/') || path.isAbsolute(value)) return false;
|
|
348
|
+
const parts = value.split('/');
|
|
349
|
+
return !parts.some((part) => !part || part === '.' || part === '..');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Stable hash used to bind derived/translated documents to an exact source. */
|
|
353
|
+
export function documentHash(content) {
|
|
354
|
+
return `sha256:${crypto.createHash('sha256').update(String(content)).digest('hex')}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isEmpty(value) {
|
|
358
|
+
return value === undefined || value === null || value === '' ||
|
|
359
|
+
(Array.isArray(value) && value.length === 0);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Validate the registry's field-level schema independently of any storage
|
|
364
|
+
* operation. Used by the engine, bundle transport, and semantic projections.
|
|
365
|
+
*/
|
|
366
|
+
export function validateDataConstraints(typeDef, data, universalRequired = []) {
|
|
367
|
+
const issues = [];
|
|
368
|
+
const required = new Set([...universalRequired, ...(typeDef?.required_fields || [])]);
|
|
369
|
+
for (const field of required) {
|
|
370
|
+
if (isEmpty(data[field])) issues.push({ field, issue: `Missing required field '${field}'.` });
|
|
371
|
+
}
|
|
372
|
+
for (const [condition, fields] of Object.entries(typeDef?.required_when || {})) {
|
|
373
|
+
const match = condition.match(/^([A-Za-z_][A-Za-z0-9_-]*)==(.+)$/);
|
|
374
|
+
if (!match) continue;
|
|
375
|
+
const expected = /^-?\d+$/.test(match[2]) ? Number(match[2]) : match[2];
|
|
376
|
+
if (data[match[1]] !== expected) continue;
|
|
377
|
+
for (const field of fields) {
|
|
378
|
+
if (isEmpty(data[field])) issues.push({ field, issue: `Required when ${condition}.` });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
for (const [field, allowed] of Object.entries(typeDef?.enums || {})) {
|
|
382
|
+
if (isEmpty(data[field])) continue;
|
|
383
|
+
const values = Array.isArray(data[field]) ? data[field] : [data[field]];
|
|
384
|
+
for (const value of values) {
|
|
385
|
+
if (!allowed.includes(value)) {
|
|
386
|
+
issues.push({ field, issue: `Invalid value '${value}'; must be one of: ${allowed.join(', ')}.` });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
for (const [field, source] of Object.entries(typeDef?.patterns || {})) {
|
|
391
|
+
if (isEmpty(data[field])) continue;
|
|
392
|
+
const expression = new RegExp(source, 'u');
|
|
393
|
+
const values = Array.isArray(data[field]) ? data[field] : [data[field]];
|
|
394
|
+
for (const value of values) {
|
|
395
|
+
if (typeof value !== 'string' || !expression.test(value)) {
|
|
396
|
+
issues.push({ field, issue: `Value must match registry pattern '${source}'.` });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return issues;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Validate registry-declared document references using a caller-provided
|
|
405
|
+
* resolver. This keeps path, type, portability, and hash checks shared by the
|
|
406
|
+
* Operation Contract, bundles, and semantic/localization projections.
|
|
407
|
+
*/
|
|
408
|
+
export function validateReferenceConstraints(typeDef, data, resolveReference, types) {
|
|
409
|
+
const issues = [];
|
|
410
|
+
const lookupType = (name) => types.get(name) ||
|
|
411
|
+
(types.aliases?.get(name) ? types.get(types.aliases.get(name)) : null);
|
|
412
|
+
for (const [field, constraint] of Object.entries(typeDef?.references || {})) {
|
|
413
|
+
const referencePath = data[field];
|
|
414
|
+
if (referencePath === undefined || referencePath === null || referencePath === '') continue;
|
|
415
|
+
if (!isSafeDocumentPath(referencePath)) {
|
|
416
|
+
issues.push({ field, issue: `Reference path '${referencePath}' is not a safe vault-relative path.` });
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
let referenced = null;
|
|
420
|
+
try { referenced = resolveReference(referencePath); }
|
|
421
|
+
catch (error) {
|
|
422
|
+
issues.push({ field, issue: `Reference '${referencePath}' could not be resolved: ${error.message}` });
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (!referenced) {
|
|
426
|
+
if (constraint.must_exist !== false) {
|
|
427
|
+
issues.push({ field, issue: `Referenced document '${referencePath}' does not exist.` });
|
|
428
|
+
}
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const referencedType = referenced.data?.type;
|
|
432
|
+
if (!lookupType(referencedType)) {
|
|
433
|
+
issues.push({ field, issue: `Referenced document declares unknown type '${referencedType || '(missing)'}'.` });
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (constraint.allowed_types && !constraint.allowed_types.includes(referencedType)) {
|
|
437
|
+
issues.push({ field, issue: `Referenced type '${referencedType}' is not allowed.` });
|
|
438
|
+
}
|
|
439
|
+
if (constraint.disallowed_types?.includes(referencedType)) {
|
|
440
|
+
issues.push({ field, issue: `Referenced type '${referencedType}' is forbidden.` });
|
|
441
|
+
}
|
|
442
|
+
const referencedPortability = resolvePortability(lookupType(referencedType), referenced.data);
|
|
443
|
+
if (constraint.allowed_portability && !constraint.allowed_portability.includes(referencedPortability)) {
|
|
444
|
+
issues.push({
|
|
445
|
+
field,
|
|
446
|
+
issue: `Referenced document is '${referencedPortability}'; allowed portability: ${constraint.allowed_portability.join(', ')}.`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
if (constraint.hash_field) {
|
|
450
|
+
const expected = documentHash(referenced.content);
|
|
451
|
+
if (data[constraint.hash_field] !== expected) {
|
|
452
|
+
issues.push({
|
|
453
|
+
field: constraint.hash_field,
|
|
454
|
+
issue: `Source hash mismatch for '${referencePath}'; expected '${expected}'.`,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return issues;
|
|
460
|
+
}
|
package/src/semantic.mjs
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic projection, multilingual embedding, retrieval, and runtime rendering.
|
|
3
|
+
* Canonical documents remain authored once; translations are never stored here.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { parseDocument } from './frontmatter.mjs';
|
|
9
|
+
import { createValidator } from './validator.mjs';
|
|
10
|
+
import { isSafeDocumentPath, resolvePortability, resolvePrimitiveDefinition, documentHash } from './registry.mjs';
|
|
11
|
+
|
|
12
|
+
const LANGUAGE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u;
|
|
13
|
+
const RENDER_FIELDS = new Set(['title', 'description', 'body']);
|
|
14
|
+
|
|
15
|
+
function asStrings(value) {
|
|
16
|
+
if (Array.isArray(value)) return value.filter((item) => typeof item === 'string' && item);
|
|
17
|
+
return typeof value === 'string' && value ? [value] : [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeLanguage(value) {
|
|
21
|
+
if (typeof value !== 'string' || !LANGUAGE_RE.test(value)) throw new Error(`Invalid language tag '${value}'.`);
|
|
22
|
+
return value.split('-').map((part, index) => {
|
|
23
|
+
if (index === 0) return part.toLowerCase();
|
|
24
|
+
if (part.length === 2 || /^\d{3}$/.test(part)) return part.toUpperCase();
|
|
25
|
+
if (part.length === 4) return part[0].toUpperCase() + part.slice(1).toLowerCase();
|
|
26
|
+
return part.toLowerCase();
|
|
27
|
+
}).join('-');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function tokenize(value) {
|
|
31
|
+
return [...new Set(String(value || '').normalize('NFKC').toLocaleLowerCase().match(/[\p{L}\p{N}]+/gu) || [])].sort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function walkDocuments(vaultRoot) {
|
|
35
|
+
const root = fs.realpathSync(path.resolve(vaultRoot));
|
|
36
|
+
const documents = [];
|
|
37
|
+
const stack = [root];
|
|
38
|
+
while (stack.length) {
|
|
39
|
+
const dir = stack.pop();
|
|
40
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
41
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
42
|
+
const absolute = path.join(dir, entry.name);
|
|
43
|
+
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
|
44
|
+
if (entry.isSymbolicLink()) throw new Error(`Refusing symlinked vault entry: ${relative}`);
|
|
45
|
+
if (entry.isDirectory()) stack.push(absolute);
|
|
46
|
+
else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'index.md' && entry.name !== 'log.md') {
|
|
47
|
+
if (!isSafeDocumentPath(relative)) throw new Error(`Unsafe vault path: ${relative}`);
|
|
48
|
+
const content = fs.readFileSync(absolute, 'utf8');
|
|
49
|
+
const parsed = parseDocument(content);
|
|
50
|
+
if (parsed.data.type) documents.push({ path: relative, content, ...parsed });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return documents.sort((a, b) => a.path.localeCompare(b.path));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function extractEdges(documentPath, data, body) {
|
|
58
|
+
const edges = [];
|
|
59
|
+
for (const match of body.matchAll(/\[\[([^\]]+)\]\]/g)) edges.push({ source: documentPath, relation: 'mentions', target: match[1].trim() });
|
|
60
|
+
for (const match of body.matchAll(/\[[^\]]*\]\((\/[^)]+\.md)\)/g)) edges.push({ source: documentPath, relation: 'links_to', target: match[1].slice(1) });
|
|
61
|
+
for (const relation of Array.isArray(data.relations) ? data.relations : []) {
|
|
62
|
+
if (relation && typeof relation === 'object' && relation.target) {
|
|
63
|
+
edges.push({ source: documentPath, relation: String(relation.relation || relation.type || 'related_to'), target: String(relation.target) });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return edges;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function canonicalHash(value) {
|
|
70
|
+
return `sha256:${crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function semanticText(record) {
|
|
74
|
+
return [record.title, record.description, record.surface?.body, record.aliases?.join(' '), record.tags?.join(' ')].filter(Boolean).join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildSemanticIndex(vaultRoot, options = {}) {
|
|
78
|
+
const validator = options.validator || createValidator(options);
|
|
79
|
+
const records = [];
|
|
80
|
+
const edges = [];
|
|
81
|
+
let excluded = 0;
|
|
82
|
+
for (const document of walkDocuments(vaultRoot)) {
|
|
83
|
+
const validation = validator.validateDocument(document.content);
|
|
84
|
+
if (!validation.valid) throw new Error(`${document.path}: ${validation.errors.join('; ')}`);
|
|
85
|
+
const definition = resolvePrimitiveDefinition(validator.registry, validation.declared_type);
|
|
86
|
+
const portability = resolvePortability(definition, document.data);
|
|
87
|
+
if (!options.includePrivate && portability !== 'structural') { excluded++; continue; }
|
|
88
|
+
const identity = document.data.semantic_id || document.data.resource || `${definition.qualified_type}:${document.path}`;
|
|
89
|
+
const labels = [document.data.title, document.data.description, document.data.name, document.data.slug, ...asStrings(document.data.aliases)];
|
|
90
|
+
const tags = asStrings(document.data.tags);
|
|
91
|
+
const record = {
|
|
92
|
+
semantic_id: identity,
|
|
93
|
+
path: document.path,
|
|
94
|
+
type: definition.qualified_type,
|
|
95
|
+
primitive_version: definition.primitive_version || 1,
|
|
96
|
+
portability,
|
|
97
|
+
authored_language: document.data.language || document.data.locale || null,
|
|
98
|
+
title: document.data.title || null,
|
|
99
|
+
description: document.data.description || null,
|
|
100
|
+
resource: document.data.resource || null,
|
|
101
|
+
aliases: asStrings(document.data.aliases),
|
|
102
|
+
tags,
|
|
103
|
+
relations: Array.isArray(document.data.relations) ? document.data.relations : [],
|
|
104
|
+
content_hash: documentHash(document.content),
|
|
105
|
+
surface: { title: document.data.title || null, description: document.data.description || null, body: document.body || '' },
|
|
106
|
+
search: {
|
|
107
|
+
identity: tokenize([identity, document.data.resource, definition.qualified_type, document.path].filter(Boolean).join(' ')),
|
|
108
|
+
labels: tokenize(labels.filter(Boolean).join(' ')),
|
|
109
|
+
tags: tokenize(tags.join(' ')),
|
|
110
|
+
text: tokenize(`${document.data.description || ''}\n${document.body || ''}`),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
records.push(record);
|
|
114
|
+
edges.push(...extractEdges(document.path, document.data, document.body));
|
|
115
|
+
}
|
|
116
|
+
edges.sort((a, b) => a.source.localeCompare(b.source) || a.relation.localeCompare(b.relation) || a.target.localeCompare(b.target));
|
|
117
|
+
const payload = { version: 2, include_private: !!options.includePrivate, excluded_documents: excluded, embedding: null, documents: records, edges };
|
|
118
|
+
return { ...payload, index_hash: canonicalHash(payload) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function validateVectors(vectors, expectedLength) {
|
|
122
|
+
if (!Array.isArray(vectors) || vectors.length !== expectedLength) throw new Error(`Embedding adapter returned ${vectors?.length || 0} vectors; expected ${expectedLength}.`);
|
|
123
|
+
const dimension = vectors[0]?.length;
|
|
124
|
+
if (!Number.isInteger(dimension) || dimension < 1) throw new Error('Embedding vectors must be non-empty arrays.');
|
|
125
|
+
for (const vector of vectors) {
|
|
126
|
+
if (!Array.isArray(vector) || vector.length !== dimension || vector.some((value) => typeof value !== 'number' || !Number.isFinite(value))) {
|
|
127
|
+
throw new Error('Embedding vectors must have one finite numeric dimension.');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return dimension;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function enrichSemanticIndex(index, { embed, model }) {
|
|
134
|
+
if (typeof embed !== 'function') throw new Error('A multilingual embed(texts, context) adapter is required.');
|
|
135
|
+
if (typeof model !== 'string' || !model) throw new Error('Embedding model identity is required.');
|
|
136
|
+
const vectors = await embed(index.documents.map(semanticText), { model, purpose: 'document' });
|
|
137
|
+
const dimension = validateVectors(vectors, index.documents.length);
|
|
138
|
+
const documents = index.documents.map((record, indexNumber) => ({ ...record, embedding: vectors[indexNumber] }));
|
|
139
|
+
const payload = { ...index, embedding: { model, dimension, multilingual: true }, documents };
|
|
140
|
+
delete payload.index_hash;
|
|
141
|
+
return { ...payload, index_hash: canonicalHash(payload) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function cosine(a, b) {
|
|
145
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return 0;
|
|
146
|
+
let dot = 0; let aa = 0; let bb = 0;
|
|
147
|
+
for (let index = 0; index < a.length; index++) { dot += a[index] * b[index]; aa += a[index] ** 2; bb += b[index] ** 2; }
|
|
148
|
+
return aa && bb ? dot / (Math.sqrt(aa) * Math.sqrt(bb)) : 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function searchSemanticIndex(index, query, options = {}) {
|
|
152
|
+
const tokens = tokenize(query);
|
|
153
|
+
if (!tokens.length) return [];
|
|
154
|
+
const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 10;
|
|
155
|
+
const allowedTypes = options.types ? new Set(options.types) : null;
|
|
156
|
+
const results = [];
|
|
157
|
+
for (const document of index.documents || []) {
|
|
158
|
+
if (allowedTypes && !allowedTypes.has(document.type)) continue;
|
|
159
|
+
const buckets = [[document.search?.identity || [], 12], [document.search?.labels || [], 7], [document.search?.tags || [], 5], [document.search?.text || [], 1]];
|
|
160
|
+
let lexical = 0;
|
|
161
|
+
const matched = new Set();
|
|
162
|
+
for (const token of tokens) for (const [values, weight] of buckets) if (values.includes(token)) { lexical += weight; matched.add(token); break; }
|
|
163
|
+
const semantic = options.queryVector && document.embedding ? cosine(options.queryVector, document.embedding) : 0;
|
|
164
|
+
if (lexical > 0 || semantic > 0) {
|
|
165
|
+
results.push({ score: lexical + Math.max(0, semantic) * 20, evidence: { lexical, semantic, matched: [...matched].sort() }, document });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return results.sort((a, b) => b.score - a.score || a.document.path.localeCompare(b.document.path)).slice(0, limit);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function searchSemanticIndexWithAdapter(index, query, { embed, model = index.embedding?.model, ...options }) {
|
|
172
|
+
if (!index.embedding) throw new Error('Semantic index has no embeddings; enrich it first.');
|
|
173
|
+
if (model !== index.embedding.model) throw new Error(`Embedding model mismatch: index uses '${index.embedding.model}', query requested '${model}'.`);
|
|
174
|
+
const vectors = await embed([query], { model, purpose: 'query' });
|
|
175
|
+
validateVectors(vectors, 1);
|
|
176
|
+
if (vectors[0].length !== index.embedding.dimension) throw new Error('Query embedding dimension does not match the index.');
|
|
177
|
+
return searchSemanticIndex(index, query, { ...options, queryVector: vectors[0] });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function prepareRenderRequest(record, options = {}) {
|
|
181
|
+
const language = normalizeLanguage(options.language);
|
|
182
|
+
return {
|
|
183
|
+
target_language: language,
|
|
184
|
+
instruction: 'Render only the natural-language presentation fields in target_language. Never alter invariant controls or invent authority.',
|
|
185
|
+
invariant: {
|
|
186
|
+
semantic_id: record.semantic_id,
|
|
187
|
+
path: record.path,
|
|
188
|
+
type: record.type,
|
|
189
|
+
primitive_version: record.primitive_version,
|
|
190
|
+
resource: record.resource,
|
|
191
|
+
portability: record.portability,
|
|
192
|
+
tags: record.tags,
|
|
193
|
+
relations: record.relations,
|
|
194
|
+
content_hash: record.content_hash,
|
|
195
|
+
available_actions: options.availableActions || [],
|
|
196
|
+
},
|
|
197
|
+
surface: { ...record.surface },
|
|
198
|
+
context: options.context || null,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function renderSemanticRecord(record, { language, render, ...options }) {
|
|
203
|
+
if (typeof render !== 'function') throw new Error('An LLM render(request) adapter is required.');
|
|
204
|
+
const request = prepareRenderRequest(record, { ...options, language });
|
|
205
|
+
const rendered = await render(request);
|
|
206
|
+
if (!rendered || typeof rendered !== 'object' || Array.isArray(rendered)) throw new Error('Render adapter must return an object.');
|
|
207
|
+
for (const key of Object.keys(rendered)) if (!RENDER_FIELDS.has(key)) throw new Error(`Render adapter returned forbidden field '${key}'.`);
|
|
208
|
+
for (const [key, value] of Object.entries(rendered)) if (typeof value !== 'string') throw new Error(`Rendered field '${key}' must be a string.`);
|
|
209
|
+
return { record, presentation: { language: request.target_language, ...rendered }, invariant: request.invariant };
|
|
210
|
+
}
|