@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/registry.mjs
CHANGED
|
@@ -10,12 +10,104 @@
|
|
|
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,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
|
+
}
|
|
19
111
|
|
|
20
112
|
/**
|
|
21
113
|
* Load the core registry and merge extension registries.
|
|
@@ -25,27 +117,59 @@ const APPEND_TYPES = new Set(['conversation', 'run']);
|
|
|
25
117
|
export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
|
|
26
118
|
const corePath = path.join(registryDir, 'core.json');
|
|
27
119
|
const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
|
|
120
|
+
const portabilityClasses = Object.keys(core.portability?.classes || {});
|
|
121
|
+
if (!portabilityClasses.length) throw new Error('core.json: portability.classes must not be empty.');
|
|
28
122
|
const extensions = new Set();
|
|
29
123
|
|
|
30
124
|
const types = new Map();
|
|
31
125
|
for (const [name, def] of Object.entries(core.document_primitives || {})) {
|
|
126
|
+
validatePrimitiveDefinition(name, def, 'core.json', portabilityClasses);
|
|
32
127
|
types.set(name, { ...def, type: name, registry: 'core' });
|
|
33
128
|
}
|
|
34
129
|
|
|
35
130
|
const extDir = path.join(registryDir, 'extensions');
|
|
36
131
|
if (fs.existsSync(extDir)) {
|
|
37
|
-
for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json'))) {
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
132
|
+
for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json')).sort()) {
|
|
133
|
+
const extPath = path.join(extDir, file);
|
|
134
|
+
if (fs.lstatSync(extPath).isSymbolicLink()) {
|
|
135
|
+
throw new Error(`extensions/${file}: symlinked registries are not allowed.`);
|
|
136
|
+
}
|
|
137
|
+
const ext = JSON.parse(fs.readFileSync(extPath, 'utf8'));
|
|
138
|
+
if (!SAFE_REGISTRY_NAME.test(ext.registry || '')) {
|
|
139
|
+
throw new Error(`extensions/${file}: registry must be a safe identifier.`);
|
|
140
|
+
}
|
|
141
|
+
if (ext.registry === 'core' || extensions.has(ext.registry)) {
|
|
142
|
+
throw new Error(`extensions/${file}: duplicate or reserved registry name '${ext.registry}'.`);
|
|
143
|
+
}
|
|
144
|
+
if (ext.extends !== undefined && ext.extends !== 'core') {
|
|
145
|
+
throw new Error(`extensions/${file}: extends must be 'core'.`);
|
|
146
|
+
}
|
|
147
|
+
extensions.add(ext.registry);
|
|
40
148
|
for (const [name, def] of Object.entries(ext.document_primitives || {})) {
|
|
41
|
-
|
|
42
|
-
types.
|
|
149
|
+
validatePrimitiveDefinition(name, def, `extensions/${file}`, portabilityClasses);
|
|
150
|
+
if (types.has(name)) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`extensions/${file}: primitive '${name}' collides with registry '${types.get(name).registry}'.`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
types.set(name, { ...def, type: name, registry: ext.registry });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const def of types.values()) {
|
|
161
|
+
for (const [field, constraint] of Object.entries(def.references || {})) {
|
|
162
|
+
for (const target of [...(constraint.allowed_types || []), ...(constraint.disallowed_types || [])]) {
|
|
163
|
+
if (!SAFE_TYPE_NAME.test(target) || !types.has(target)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`registry '${def.registry}' primitive '${def.type}' reference '${field}' names unknown type '${target}'.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
43
168
|
}
|
|
44
169
|
}
|
|
45
170
|
}
|
|
46
171
|
|
|
47
172
|
const contractTypes = new Set(Object.keys(core.contract_primitives || {}));
|
|
48
|
-
const portabilityClasses = Object.keys(core.portability?.classes || {});
|
|
49
173
|
return { types, contractTypes, portabilityClasses, core, extensions };
|
|
50
174
|
}
|
|
51
175
|
|
|
@@ -67,3 +191,119 @@ export function resolvePortability(typeDef, frontmatter) {
|
|
|
67
191
|
if (override && order[override] != null && order[override] > (order[base] ?? 0)) return override;
|
|
68
192
|
return base;
|
|
69
193
|
}
|
|
194
|
+
|
|
195
|
+
/** Is a registry-declared document reference a safe vault-relative path? */
|
|
196
|
+
export function isSafeDocumentPath(value) {
|
|
197
|
+
if (typeof value !== 'string' || !value || value.includes('\0') || value.includes('\\')) return false;
|
|
198
|
+
if (value.startsWith('/') || path.isAbsolute(value)) return false;
|
|
199
|
+
const parts = value.split('/');
|
|
200
|
+
return !parts.some((part) => !part || part === '.' || part === '..');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Stable hash used to bind derived/translated documents to an exact source. */
|
|
204
|
+
export function documentHash(content) {
|
|
205
|
+
return `sha256:${crypto.createHash('sha256').update(String(content)).digest('hex')}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isEmpty(value) {
|
|
209
|
+
return value === undefined || value === null || value === '' ||
|
|
210
|
+
(Array.isArray(value) && value.length === 0);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Validate the registry's field-level schema independently of any storage
|
|
215
|
+
* operation. Used by the engine, bundle transport, and semantic projections.
|
|
216
|
+
*/
|
|
217
|
+
export function validateDataConstraints(typeDef, data, universalRequired = []) {
|
|
218
|
+
const issues = [];
|
|
219
|
+
const required = new Set([...universalRequired, ...(typeDef?.required_fields || [])]);
|
|
220
|
+
for (const field of required) {
|
|
221
|
+
if (isEmpty(data[field])) issues.push({ field, issue: `Missing required field '${field}'.` });
|
|
222
|
+
}
|
|
223
|
+
for (const [condition, fields] of Object.entries(typeDef?.required_when || {})) {
|
|
224
|
+
const match = condition.match(/^([A-Za-z_][A-Za-z0-9_-]*)==(.+)$/);
|
|
225
|
+
if (!match) continue;
|
|
226
|
+
const expected = /^-?\d+$/.test(match[2]) ? Number(match[2]) : match[2];
|
|
227
|
+
if (data[match[1]] !== expected) continue;
|
|
228
|
+
for (const field of fields) {
|
|
229
|
+
if (isEmpty(data[field])) issues.push({ field, issue: `Required when ${condition}.` });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const [field, allowed] of Object.entries(typeDef?.enums || {})) {
|
|
233
|
+
if (isEmpty(data[field])) continue;
|
|
234
|
+
const values = Array.isArray(data[field]) ? data[field] : [data[field]];
|
|
235
|
+
for (const value of values) {
|
|
236
|
+
if (!allowed.includes(value)) {
|
|
237
|
+
issues.push({ field, issue: `Invalid value '${value}'; must be one of: ${allowed.join(', ')}.` });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const [field, source] of Object.entries(typeDef?.patterns || {})) {
|
|
242
|
+
if (isEmpty(data[field])) continue;
|
|
243
|
+
const expression = new RegExp(source, 'u');
|
|
244
|
+
const values = Array.isArray(data[field]) ? data[field] : [data[field]];
|
|
245
|
+
for (const value of values) {
|
|
246
|
+
if (typeof value !== 'string' || !expression.test(value)) {
|
|
247
|
+
issues.push({ field, issue: `Value must match registry pattern '${source}'.` });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return issues;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Validate registry-declared document references using a caller-provided
|
|
256
|
+
* resolver. This keeps path, type, portability, and hash checks shared by the
|
|
257
|
+
* Operation Contract, bundles, and semantic/localization projections.
|
|
258
|
+
*/
|
|
259
|
+
export function validateReferenceConstraints(typeDef, data, resolveReference, types) {
|
|
260
|
+
const issues = [];
|
|
261
|
+
for (const [field, constraint] of Object.entries(typeDef?.references || {})) {
|
|
262
|
+
const referencePath = data[field];
|
|
263
|
+
if (referencePath === undefined || referencePath === null || referencePath === '') continue;
|
|
264
|
+
if (!isSafeDocumentPath(referencePath)) {
|
|
265
|
+
issues.push({ field, issue: `Reference path '${referencePath}' is not a safe vault-relative path.` });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
let referenced = null;
|
|
269
|
+
try { referenced = resolveReference(referencePath); }
|
|
270
|
+
catch (error) {
|
|
271
|
+
issues.push({ field, issue: `Reference '${referencePath}' could not be resolved: ${error.message}` });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (!referenced) {
|
|
275
|
+
if (constraint.must_exist !== false) {
|
|
276
|
+
issues.push({ field, issue: `Referenced document '${referencePath}' does not exist.` });
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const referencedType = referenced.data?.type;
|
|
281
|
+
if (!types.has(referencedType)) {
|
|
282
|
+
issues.push({ field, issue: `Referenced document declares unknown type '${referencedType || '(missing)'}'.` });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (constraint.allowed_types && !constraint.allowed_types.includes(referencedType)) {
|
|
286
|
+
issues.push({ field, issue: `Referenced type '${referencedType}' is not allowed.` });
|
|
287
|
+
}
|
|
288
|
+
if (constraint.disallowed_types?.includes(referencedType)) {
|
|
289
|
+
issues.push({ field, issue: `Referenced type '${referencedType}' is forbidden.` });
|
|
290
|
+
}
|
|
291
|
+
const referencedPortability = resolvePortability(types.get(referencedType), referenced.data);
|
|
292
|
+
if (constraint.allowed_portability && !constraint.allowed_portability.includes(referencedPortability)) {
|
|
293
|
+
issues.push({
|
|
294
|
+
field,
|
|
295
|
+
issue: `Referenced document is '${referencedPortability}'; allowed portability: ${constraint.allowed_portability.join(', ')}.`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (constraint.hash_field) {
|
|
299
|
+
const expected = documentHash(referenced.content);
|
|
300
|
+
if (data[constraint.hash_field] !== expected) {
|
|
301
|
+
issues.push({
|
|
302
|
+
field: constraint.hash_field,
|
|
303
|
+
issue: `Source hash mismatch for '${referencePath}'; expected '${expected}'.`,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return issues;
|
|
309
|
+
}
|
package/src/semantic.mjs
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic semantic projection and non-destructive localization helpers
|
|
3
|
+
* (spec §11.9). No database, network, embedding provider, or YAML dependency.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { parseDocument, serializeDocument } from './frontmatter.mjs';
|
|
9
|
+
import {
|
|
10
|
+
documentHash,
|
|
11
|
+
isSafeDocumentPath,
|
|
12
|
+
loadRegistries,
|
|
13
|
+
resolvePortability,
|
|
14
|
+
validateDataConstraints,
|
|
15
|
+
validateReferenceConstraints,
|
|
16
|
+
} from './registry.mjs';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_LOCALE_PATTERN = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u;
|
|
19
|
+
const TRANSLATABLE_FIELDS = new Set(['title', 'description', 'body']);
|
|
20
|
+
const APPROVED_STATUSES = new Set(['approved']);
|
|
21
|
+
|
|
22
|
+
function asStrings(value) {
|
|
23
|
+
if (Array.isArray(value)) return value.filter((item) => typeof item === 'string' && item);
|
|
24
|
+
return typeof value === 'string' && value ? [value] : [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeLocale(value, pattern = DEFAULT_LOCALE_PATTERN) {
|
|
28
|
+
if (typeof value !== 'string' || !pattern.test(value)) {
|
|
29
|
+
throw new Error(`Invalid locale '${value}'. Use a BCP47-style tag such as en, es-MX, or zh-Hant.`);
|
|
30
|
+
}
|
|
31
|
+
return value.split('-').map((part, index) => {
|
|
32
|
+
if (index === 0) return part.toLowerCase();
|
|
33
|
+
if (part.length === 2 || /^\d{3}$/.test(part)) return part.toUpperCase();
|
|
34
|
+
if (part.length === 4) return part[0].toUpperCase() + part.slice(1).toLowerCase();
|
|
35
|
+
return part.toLowerCase();
|
|
36
|
+
}).join('-');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function walkDocuments(vaultRoot) {
|
|
40
|
+
const root = path.resolve(vaultRoot);
|
|
41
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
42
|
+
throw new Error(`Vault directory does not exist: ${root}`);
|
|
43
|
+
}
|
|
44
|
+
const documents = [];
|
|
45
|
+
const stack = [root];
|
|
46
|
+
while (stack.length) {
|
|
47
|
+
const dir = stack.pop();
|
|
48
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
49
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
52
|
+
const absolute = path.join(dir, entry.name);
|
|
53
|
+
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
|
54
|
+
if (entry.isSymbolicLink()) {
|
|
55
|
+
throw new Error(`Refusing symlinked vault entry: ${relative}`);
|
|
56
|
+
}
|
|
57
|
+
if (entry.isDirectory()) stack.push(absolute);
|
|
58
|
+
else if (entry.isFile() && entry.name.endsWith('.md') &&
|
|
59
|
+
entry.name !== 'index.md' && entry.name !== 'log.md') {
|
|
60
|
+
if (!isSafeDocumentPath(relative)) throw new Error(`Unsafe vault path: ${relative}`);
|
|
61
|
+
const content = fs.readFileSync(absolute, 'utf8');
|
|
62
|
+
const parsed = parseDocument(content);
|
|
63
|
+
if (!parsed.data.type) continue;
|
|
64
|
+
documents.push({ path: relative, absolute, content, ...parsed });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return documents.sort((a, b) => a.path.localeCompare(b.path));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function documentMap(documents) {
|
|
72
|
+
return new Map(documents.map((document) => [document.path, document]));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validateReferences(document, documents, types) {
|
|
76
|
+
const definition = types.get(document.data.type);
|
|
77
|
+
return validateReferenceConstraints(
|
|
78
|
+
definition,
|
|
79
|
+
document.data,
|
|
80
|
+
(referencePath) => documents.get(referencePath) || null,
|
|
81
|
+
types
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function translationMap(documents, locale, types, { includeDrafts = false } = {}) {
|
|
86
|
+
const wanted = normalizeLocale(locale);
|
|
87
|
+
const bySource = new Map();
|
|
88
|
+
for (const document of documents.values()) {
|
|
89
|
+
if (document.data.type !== 'translation') continue;
|
|
90
|
+
const normalized = normalizeLocale(document.data.locale);
|
|
91
|
+
if (normalized !== wanted) continue;
|
|
92
|
+
if (!includeDrafts && !APPROVED_STATUSES.has(document.data.status)) continue;
|
|
93
|
+
const key = document.data.source_path;
|
|
94
|
+
if (bySource.has(key)) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Duplicate translation overlays for '${key}' and locale '${wanted}': ` +
|
|
97
|
+
`${bySource.get(key).path}, ${document.path}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const issues = validateReferences(document, documents, types);
|
|
101
|
+
if (issues.length) {
|
|
102
|
+
throw new Error(`${document.path}: ${issues.map((issue) => `${issue.field}: ${issue.issue}`).join('; ')}`);
|
|
103
|
+
}
|
|
104
|
+
bySource.set(key, document);
|
|
105
|
+
}
|
|
106
|
+
return bySource;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function applyTranslationOverlay(sourceContent, translationContent, options = {}) {
|
|
110
|
+
const { types, core } = loadRegistries(options.registryDir);
|
|
111
|
+
const source = parseDocument(sourceContent);
|
|
112
|
+
const overlay = parseDocument(translationContent);
|
|
113
|
+
if (overlay.data.type !== 'translation') throw new Error('Overlay must declare type: translation.');
|
|
114
|
+
if (source.data.type === 'translation') throw new Error('Translations may not target another translation.');
|
|
115
|
+
const sourcePath = options.sourcePath || overlay.data.source_path;
|
|
116
|
+
if (!isSafeDocumentPath(sourcePath) || overlay.data.source_path !== sourcePath) {
|
|
117
|
+
throw new Error(`Translation source_path '${overlay.data.source_path}' does not match '${sourcePath}'.`);
|
|
118
|
+
}
|
|
119
|
+
if (resolvePortability(types.get(source.data.type), source.data) !== 'structural') {
|
|
120
|
+
throw new Error('Translation overlays may target structural documents only.');
|
|
121
|
+
}
|
|
122
|
+
const expectedHash = documentHash(sourceContent);
|
|
123
|
+
if (overlay.data.source_hash !== expectedHash) {
|
|
124
|
+
throw new Error(`Stale translation for '${sourcePath}'; expected source_hash '${expectedHash}'.`);
|
|
125
|
+
}
|
|
126
|
+
const localePattern = new RegExp(core.semantic?.locale_pattern || DEFAULT_LOCALE_PATTERN.source, 'u');
|
|
127
|
+
const locale = normalizeLocale(overlay.data.locale, localePattern);
|
|
128
|
+
const translatedFields = [...new Set(asStrings(overlay.data.translated_fields))];
|
|
129
|
+
if (!translatedFields.length) throw new Error('translated_fields must not be empty.');
|
|
130
|
+
for (const field of translatedFields) {
|
|
131
|
+
if (!TRANSLATABLE_FIELDS.has(field)) {
|
|
132
|
+
throw new Error(`Field '${field}' is a symbolic control field and may not be translated.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const data = { ...source.data };
|
|
137
|
+
let body = source.body;
|
|
138
|
+
if (translatedFields.includes('title')) {
|
|
139
|
+
if (!overlay.data.translated_title) throw new Error('translated_title is required when translating title.');
|
|
140
|
+
data.title = overlay.data.translated_title;
|
|
141
|
+
}
|
|
142
|
+
if (translatedFields.includes('description')) {
|
|
143
|
+
if (!overlay.data.translated_description) {
|
|
144
|
+
throw new Error('translated_description is required when translating description.');
|
|
145
|
+
}
|
|
146
|
+
data.description = overlay.data.translated_description;
|
|
147
|
+
}
|
|
148
|
+
if (translatedFields.includes('body')) {
|
|
149
|
+
if (!overlay.body.trim()) throw new Error('Translation body must not be empty when translating body.');
|
|
150
|
+
body = overlay.body;
|
|
151
|
+
}
|
|
152
|
+
data.locale = locale;
|
|
153
|
+
data.translation_of = sourcePath;
|
|
154
|
+
data.translation_source_hash = expectedHash;
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
content: serializeDocument(data, body),
|
|
158
|
+
data,
|
|
159
|
+
body,
|
|
160
|
+
locale,
|
|
161
|
+
status: overlay.data.status,
|
|
162
|
+
translated_fields: translatedFields,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function tokenize(value) {
|
|
167
|
+
const normalized = String(value || '').normalize('NFKC').toLocaleLowerCase();
|
|
168
|
+
return [...new Set(normalized.match(/[\p{L}\p{N}]+/gu) || [])].sort();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function extractEdges(documentPath, data, body) {
|
|
172
|
+
const edges = [];
|
|
173
|
+
for (const match of body.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
174
|
+
edges.push({ source: documentPath, relation: 'mentions', target: match[1].trim() });
|
|
175
|
+
}
|
|
176
|
+
for (const match of body.matchAll(/\[[^\]]*\]\((\/[^)]+\.md)\)/g)) {
|
|
177
|
+
edges.push({ source: documentPath, relation: 'links_to', target: match[1].slice(1) });
|
|
178
|
+
}
|
|
179
|
+
for (const relation of Array.isArray(data.relations) ? data.relations : []) {
|
|
180
|
+
if (relation && typeof relation === 'object' && relation.target) {
|
|
181
|
+
edges.push({
|
|
182
|
+
source: documentPath,
|
|
183
|
+
relation: String(relation.relation || relation.type || 'related_to'),
|
|
184
|
+
target: String(relation.target),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return edges;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function canonicalHash(value) {
|
|
192
|
+
return `sha256:${crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function localizedDocuments(vaultRoot, options = {}) {
|
|
196
|
+
const { types, core } = loadRegistries(options.registryDir);
|
|
197
|
+
const all = walkDocuments(vaultRoot);
|
|
198
|
+
const documents = documentMap(all);
|
|
199
|
+
for (const document of all) {
|
|
200
|
+
const definition = types.get(document.data.type);
|
|
201
|
+
if (!definition) throw new Error(`${document.path}: unknown type '${document.data.type}'.`);
|
|
202
|
+
const issues = [
|
|
203
|
+
...validateDataConstraints(
|
|
204
|
+
definition,
|
|
205
|
+
document.data,
|
|
206
|
+
core.universal_frontmatter?.required || ['type']
|
|
207
|
+
),
|
|
208
|
+
...(document.data.type === 'translation' ? [] : validateReferences(document, documents, types)),
|
|
209
|
+
];
|
|
210
|
+
if (issues.length) {
|
|
211
|
+
throw new Error(`${document.path}: ${issues.map((issue) => `${issue.field}: ${issue.issue}`).join('; ')}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const overlays = options.locale
|
|
215
|
+
? translationMap(documents, options.locale, types, options)
|
|
216
|
+
: new Map();
|
|
217
|
+
const output = [];
|
|
218
|
+
let excluded = 0;
|
|
219
|
+
|
|
220
|
+
for (const source of all) {
|
|
221
|
+
if (source.data.type === 'translation') continue;
|
|
222
|
+
const portability = resolvePortability(types.get(source.data.type), source.data);
|
|
223
|
+
if (!options.includePrivate && portability !== 'structural') {
|
|
224
|
+
excluded++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const overlay = overlays.get(source.path);
|
|
228
|
+
const localized = overlay
|
|
229
|
+
? applyTranslationOverlay(source.content, overlay.content, {
|
|
230
|
+
sourcePath: source.path,
|
|
231
|
+
registryDir: options.registryDir,
|
|
232
|
+
})
|
|
233
|
+
: { content: source.content, data: source.data, body: source.body, locale: source.data.locale || null };
|
|
234
|
+
output.push({
|
|
235
|
+
path: source.path,
|
|
236
|
+
source,
|
|
237
|
+
overlay: overlay || null,
|
|
238
|
+
portability,
|
|
239
|
+
...localized,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return { documents: output, excluded };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function buildSemanticIndex(vaultRoot, options = {}) {
|
|
246
|
+
const locale = options.locale ? normalizeLocale(options.locale) : null;
|
|
247
|
+
const localized = localizedDocuments(vaultRoot, { ...options, locale });
|
|
248
|
+
const records = [];
|
|
249
|
+
const edges = [];
|
|
250
|
+
|
|
251
|
+
for (const document of localized.documents) {
|
|
252
|
+
const data = document.data;
|
|
253
|
+
const identity = data.semantic_id || data.resource || `${data.type}:${document.path}`;
|
|
254
|
+
const labels = [data.title, data.description, data.name, data.slug, ...asStrings(data.aliases)];
|
|
255
|
+
const tags = asStrings(data.tags);
|
|
256
|
+
const record = {
|
|
257
|
+
semantic_id: identity,
|
|
258
|
+
path: document.path,
|
|
259
|
+
type: data.type,
|
|
260
|
+
portability: document.portability,
|
|
261
|
+
locale: document.locale || null,
|
|
262
|
+
title: data.title || null,
|
|
263
|
+
description: data.description || null,
|
|
264
|
+
resource: data.resource || null,
|
|
265
|
+
aliases: asStrings(data.aliases),
|
|
266
|
+
tags,
|
|
267
|
+
content_hash: documentHash(document.content),
|
|
268
|
+
translation: document.overlay ? {
|
|
269
|
+
path: document.overlay.path,
|
|
270
|
+
status: document.overlay.data.status,
|
|
271
|
+
source_hash: document.overlay.data.source_hash,
|
|
272
|
+
} : null,
|
|
273
|
+
search: {
|
|
274
|
+
identity: tokenize([identity, data.resource, data.type, document.path].filter(Boolean).join(' ')),
|
|
275
|
+
labels: tokenize(labels.filter(Boolean).join(' ')),
|
|
276
|
+
tags: tokenize(tags.join(' ')),
|
|
277
|
+
text: tokenize(`${data.description || ''}\n${document.body || ''}`),
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
records.push(record);
|
|
281
|
+
edges.push(...extractEdges(document.path, data, document.body));
|
|
282
|
+
}
|
|
283
|
+
records.sort((a, b) => a.path.localeCompare(b.path));
|
|
284
|
+
edges.sort((a, b) =>
|
|
285
|
+
a.source.localeCompare(b.source) || a.relation.localeCompare(b.relation) || a.target.localeCompare(b.target)
|
|
286
|
+
);
|
|
287
|
+
const payload = {
|
|
288
|
+
version: 1,
|
|
289
|
+
locale,
|
|
290
|
+
include_private: !!options.includePrivate,
|
|
291
|
+
excluded_documents: localized.excluded,
|
|
292
|
+
documents: records,
|
|
293
|
+
edges,
|
|
294
|
+
};
|
|
295
|
+
return { ...payload, index_hash: canonicalHash(payload) };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function searchSemanticIndex(index, query, options = {}) {
|
|
299
|
+
const queryTokens = tokenize(query);
|
|
300
|
+
if (!queryTokens.length) return [];
|
|
301
|
+
const allowedTypes = options.types ? new Set(options.types) : null;
|
|
302
|
+
const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 10;
|
|
303
|
+
const results = [];
|
|
304
|
+
for (const document of index.documents || []) {
|
|
305
|
+
if (allowedTypes && !allowedTypes.has(document.type)) continue;
|
|
306
|
+
const buckets = [
|
|
307
|
+
[document.search?.identity || [], 12],
|
|
308
|
+
[document.search?.labels || [], 7],
|
|
309
|
+
[document.search?.tags || [], 5],
|
|
310
|
+
[document.search?.text || [], 1],
|
|
311
|
+
];
|
|
312
|
+
let score = 0;
|
|
313
|
+
const matched = new Set();
|
|
314
|
+
for (const token of queryTokens) {
|
|
315
|
+
for (const [values, weight] of buckets) {
|
|
316
|
+
if (values.includes(token)) {
|
|
317
|
+
score += weight;
|
|
318
|
+
matched.add(token);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (score > 0) results.push({ score, matched: [...matched].sort(), document });
|
|
324
|
+
}
|
|
325
|
+
return results
|
|
326
|
+
.sort((a, b) => b.score - a.score || a.document.path.localeCompare(b.document.path))
|
|
327
|
+
.slice(0, limit);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function atomicWrite(target, content) {
|
|
331
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
332
|
+
const temp = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomUUID()}.tmp`);
|
|
333
|
+
fs.writeFileSync(temp, content);
|
|
334
|
+
fs.renameSync(temp, target);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function resolvePhysicalTarget(value) {
|
|
338
|
+
const requested = path.resolve(value);
|
|
339
|
+
let cursor = requested;
|
|
340
|
+
const suffix = [];
|
|
341
|
+
while (!fs.existsSync(cursor)) {
|
|
342
|
+
suffix.unshift(path.basename(cursor));
|
|
343
|
+
const parent = path.dirname(cursor);
|
|
344
|
+
if (parent === cursor) break;
|
|
345
|
+
cursor = parent;
|
|
346
|
+
}
|
|
347
|
+
const physical = fs.realpathSync(cursor);
|
|
348
|
+
return path.join(physical, ...suffix);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function materializeLocale(vaultRoot, locale, outputDir, options = {}) {
|
|
352
|
+
const sourceRoot = fs.realpathSync(path.resolve(vaultRoot));
|
|
353
|
+
const targetRoot = resolvePhysicalTarget(outputDir);
|
|
354
|
+
const normalizedLocale = normalizeLocale(locale);
|
|
355
|
+
if (targetRoot === sourceRoot || targetRoot.startsWith(sourceRoot + path.sep)) {
|
|
356
|
+
throw new Error('Localization output must be outside the source vault.');
|
|
357
|
+
}
|
|
358
|
+
if (fs.existsSync(targetRoot)) {
|
|
359
|
+
const targetStat = fs.lstatSync(targetRoot);
|
|
360
|
+
if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
|
|
361
|
+
throw new Error(`Localization output must be a real directory: ${targetRoot}`);
|
|
362
|
+
}
|
|
363
|
+
if (fs.readdirSync(targetRoot).length > 0) {
|
|
364
|
+
throw new Error(`Localization output directory must be empty: ${targetRoot}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const localized = localizedDocuments(sourceRoot, { ...options, locale: normalizedLocale });
|
|
368
|
+
const parent = path.dirname(targetRoot);
|
|
369
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
370
|
+
const stageRoot = path.join(parent, `.${path.basename(targetRoot)}.${crypto.randomUUID()}.stage`);
|
|
371
|
+
const inventory = [];
|
|
372
|
+
let targetWasEmpty = fs.existsSync(targetRoot);
|
|
373
|
+
try {
|
|
374
|
+
fs.mkdirSync(stageRoot);
|
|
375
|
+
for (const document of localized.documents) {
|
|
376
|
+
if (!isSafeDocumentPath(document.path)) throw new Error(`Unsafe projection path: ${document.path}`);
|
|
377
|
+
const target = path.resolve(stageRoot, document.path);
|
|
378
|
+
if (!target.startsWith(stageRoot + path.sep)) throw new Error(`Projection path escaped output: ${document.path}`);
|
|
379
|
+
atomicWrite(target, document.content);
|
|
380
|
+
inventory.push({ path: document.path, content_hash: documentHash(document.content) });
|
|
381
|
+
}
|
|
382
|
+
inventory.sort((a, b) => a.path.localeCompare(b.path));
|
|
383
|
+
const manifest = {
|
|
384
|
+
type: 'ssss_localization_projection',
|
|
385
|
+
version: 1,
|
|
386
|
+
locale: normalizedLocale,
|
|
387
|
+
include_private: !!options.includePrivate,
|
|
388
|
+
document_count: inventory.length,
|
|
389
|
+
excluded_documents: localized.excluded,
|
|
390
|
+
source_hash: canonicalHash(inventory),
|
|
391
|
+
documents: inventory,
|
|
392
|
+
};
|
|
393
|
+
atomicWrite(path.join(stageRoot, '.ssss-projection.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
394
|
+
if (targetWasEmpty) fs.rmdirSync(targetRoot);
|
|
395
|
+
fs.renameSync(stageRoot, targetRoot);
|
|
396
|
+
return manifest;
|
|
397
|
+
} catch (error) {
|
|
398
|
+
fs.rmSync(stageRoot, { recursive: true, force: true });
|
|
399
|
+
if (targetWasEmpty && !fs.existsSync(targetRoot)) fs.mkdirSync(targetRoot);
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
}
|