@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.
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * SSSS files are Markdown with a leading `---`-fenced YAML frontmatter block
5
5
  * (spec §4.1). The canonical engine avoids a YAML dependency: it parses the
6
- * subset SSSS actually uses — top-level scalars, inline `[a, b]` arrays, block
7
- * `- item` arrays, and block scalars — which is sufficient to resolve a file's
8
- * `type` and validate field presence against the registry (§5, §9).
6
+ * subset SSSS actually uses — scalars, inline `[a, b]` arrays, block `- item`
7
+ * arrays, nested maps, and arrays of shallow maps — which is sufficient to
8
+ * resolve a file's `type`, validate registry fields (§5, §9), and round-trip
9
+ * operation patches without dropping nested frontmatter.
9
10
  */
10
11
 
11
12
  /** Split a document into its raw frontmatter block and body. */
@@ -42,43 +43,107 @@ function coerce(raw) {
42
43
  return v;
43
44
  }
44
45
 
45
- /**
46
- * Parse a frontmatter block (the inner YAML, no fences) into an object.
47
- * Returns top-level keys only; nested maps are returned as `{}` placeholders
48
- * (presence is what the registry validator needs).
49
- */
50
- export function parseFrontmatter(fm) {
46
+ function countIndent(line) {
47
+ const m = line.match(/^ */);
48
+ return m ? m[0].length : 0;
49
+ }
50
+
51
+ function splitKeyValue(text) {
52
+ const m = text.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
53
+ return m ? { key: m[1], rest: m[2] } : null;
54
+ }
55
+
56
+ function nextSignificant(lines, start) {
57
+ for (let i = start; i < lines.length; i++) {
58
+ if (lines[i].trim() && !lines[i].trim().startsWith('#')) return i;
59
+ }
60
+ return -1;
61
+ }
62
+
63
+ function parseMap(lines, start, indent) {
51
64
  const data = {};
52
- if (!fm) return data;
53
- const lines = fm.split('\n');
54
- for (let i = 0; i < lines.length; i++) {
65
+ let i = start;
66
+ while (i < lines.length) {
55
67
  const line = lines[i];
56
- if (!line.trim() || line.trim().startsWith('#')) continue;
57
- if (/^\s/.test(line)) continue; // sub-entry of a block handled below
58
- const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
59
- if (!m) continue;
60
- const key = m[1];
61
- const rest = m[2];
62
- if (rest === '') {
63
- // Could open a block list or a nested map. Peek ahead.
64
- const items = [];
65
- let isList = false;
66
- let j = i + 1;
67
- for (; j < lines.length; j++) {
68
- const sub = lines[j];
69
- if (!sub.trim()) continue;
70
- if (!/^\s/.test(sub)) break; // dedent → block ended
71
- const li = sub.match(/^\s*-\s*(.*)$/);
72
- if (li) { isList = true; items.push(coerce(li[1])); }
73
- else break; // nested map; we only need presence
68
+ if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
69
+ const currentIndent = countIndent(line);
70
+ if (currentIndent < indent) break;
71
+ if (currentIndent > indent) { i++; continue; }
72
+ const kv = splitKeyValue(line.slice(indent));
73
+ if (!kv) { i++; continue; }
74
+ if (kv.rest !== '') {
75
+ data[kv.key] = coerce(kv.rest);
76
+ i++;
77
+ continue;
78
+ }
79
+
80
+ const next = nextSignificant(lines, i + 1);
81
+ if (next < 0 || countIndent(lines[next]) <= indent) {
82
+ data[kv.key] = {};
83
+ i++;
84
+ continue;
85
+ }
86
+ const childIndent = countIndent(lines[next]);
87
+ if (lines[next].slice(childIndent).startsWith('- ')) {
88
+ const parsed = parseList(lines, next, childIndent);
89
+ data[kv.key] = parsed.value;
90
+ i = parsed.next;
91
+ } else {
92
+ const parsed = parseMap(lines, next, childIndent);
93
+ data[kv.key] = parsed.value;
94
+ i = parsed.next;
95
+ }
96
+ }
97
+ return { value: data, next: i };
98
+ }
99
+
100
+ function parseList(lines, start, indent) {
101
+ const items = [];
102
+ let i = start;
103
+ while (i < lines.length) {
104
+ const line = lines[i];
105
+ if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
106
+ const currentIndent = countIndent(line);
107
+ if (currentIndent < indent) break;
108
+ if (currentIndent > indent) { i++; continue; }
109
+ const item = line.slice(indent).match(/^-\s*(.*)$/);
110
+ if (!item) break;
111
+ const raw = item[1];
112
+ const kv = splitKeyValue(raw);
113
+ if (kv) {
114
+ const obj = { [kv.key]: kv.rest === '' ? {} : coerce(kv.rest) };
115
+ i++;
116
+ const next = nextSignificant(lines, i);
117
+ if (next >= 0 && countIndent(lines[next]) > indent) {
118
+ const parsed = parseMap(lines, next, countIndent(lines[next]));
119
+ Object.assign(obj, parsed.value);
120
+ i = parsed.next;
121
+ }
122
+ items.push(obj);
123
+ } else if (raw === '') {
124
+ const next = nextSignificant(lines, i + 1);
125
+ if (next >= 0 && countIndent(lines[next]) > indent) {
126
+ const parsed = parseMap(lines, next, countIndent(lines[next]));
127
+ items.push(parsed.value);
128
+ i = parsed.next;
129
+ } else {
130
+ items.push('');
131
+ i++;
74
132
  }
75
- data[key] = isList ? items : {};
76
- i = isList ? j - 1 : i;
77
133
  } else {
78
- data[key] = coerce(rest);
134
+ items.push(coerce(raw));
135
+ i++;
79
136
  }
80
137
  }
81
- return data;
138
+ return { value: items, next: i };
139
+ }
140
+
141
+ /**
142
+ * Parse a frontmatter block (the inner YAML, no fences) into an object.
143
+ */
144
+ export function parseFrontmatter(fm) {
145
+ if (!fm) return {};
146
+ return parseMap(fm.split('\n'), 0, 0).value;
82
147
  }
83
148
 
84
149
  /** Parse a full document (with fences) into { data, body }. */
@@ -96,13 +161,51 @@ function serializeValue(v) {
96
161
  return s;
97
162
  }
98
163
 
164
+ function serializeEntry(lines, key, value, indent = 0) {
165
+ const pad = ' '.repeat(indent);
166
+ if (Array.isArray(value)) {
167
+ if (!value.length) {
168
+ lines.push(`${pad}${key}: []`);
169
+ return;
170
+ }
171
+ lines.push(`${pad}${key}:`);
172
+ for (const item of value) {
173
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
174
+ const entries = Object.entries(item);
175
+ if (!entries.length) {
176
+ lines.push(`${pad} - {}`);
177
+ continue;
178
+ }
179
+ const [first, ...rest] = entries;
180
+ if (first[1] && typeof first[1] === 'object' && !Array.isArray(first[1])) {
181
+ lines.push(`${pad} - ${first[0]}:`);
182
+ serializeObject(lines, first[1], indent + 6);
183
+ } else {
184
+ lines.push(`${pad} - ${first[0]}: ${serializeValue(first[1])}`);
185
+ }
186
+ for (const [childKey, childValue] of rest) serializeEntry(lines, childKey, childValue, indent + 4);
187
+ } else {
188
+ lines.push(`${pad} - ${serializeValue(item)}`);
189
+ }
190
+ }
191
+ return;
192
+ }
193
+ if (value && typeof value === 'object') {
194
+ lines.push(`${pad}${key}:`);
195
+ serializeObject(lines, value, indent + 2);
196
+ return;
197
+ }
198
+ lines.push(`${pad}${key}: ${serializeValue(value)}`);
199
+ }
200
+
201
+ function serializeObject(lines, obj, indent) {
202
+ for (const [k, v] of Object.entries(obj)) serializeEntry(lines, k, v, indent);
203
+ }
204
+
99
205
  /** Serialize a frontmatter object + body back into an SSSS document. */
100
206
  export function serializeDocument(data, body) {
101
207
  const lines = ['---'];
102
- for (const [k, v] of Object.entries(data)) {
103
- if (v && typeof v === 'object' && !Array.isArray(v)) continue; // skip nested maps we can't round-trip
104
- lines.push(`${k}: ${serializeValue(v)}`);
105
- }
208
+ serializeObject(lines, data, 0);
106
209
  lines.push('---');
107
210
  const b = body == null ? '' : (body.startsWith('\n') ? body : `\n${body}`);
108
211
  return lines.join('\n') + b;
package/src/registry.mjs CHANGED
@@ -10,41 +10,167 @@
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.
22
114
  * @param {string} [registryDir] - directory containing core.json + extensions/
23
- * @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object }}
115
+ * @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
24
116
  */
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.');
122
+ const extensions = new Set();
28
123
 
29
124
  const types = new Map();
30
125
  for (const [name, def] of Object.entries(core.document_primitives || {})) {
126
+ validatePrimitiveDefinition(name, def, 'core.json', portabilityClasses);
31
127
  types.set(name, { ...def, type: name, registry: 'core' });
32
128
  }
33
129
 
34
130
  const extDir = path.join(registryDir, 'extensions');
35
131
  if (fs.existsSync(extDir)) {
36
- for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json'))) {
37
- const ext = JSON.parse(fs.readFileSync(path.join(extDir, file), 'utf8'));
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);
38
148
  for (const [name, def] of Object.entries(ext.document_primitives || {})) {
39
- if (types.has(name) && types.get(name).registry === 'core') continue; // core wins; extensions MUST NOT redefine
40
- types.set(name, { ...def, type: name, registry: ext.registry || file });
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
+ }
41
168
  }
42
169
  }
43
170
  }
44
171
 
45
172
  const contractTypes = new Set(Object.keys(core.contract_primitives || {}));
46
- const portabilityClasses = Object.keys(core.portability?.classes || {});
47
- return { types, contractTypes, portabilityClasses, core };
173
+ return { types, contractTypes, portabilityClasses, core, extensions };
48
174
  }
49
175
 
50
176
  /** Is this document type append-only (events appended, never rewritten)? */
@@ -65,3 +191,119 @@ export function resolvePortability(typeDef, frontmatter) {
65
191
  if (override && order[override] != null && order[override] > (order[base] ?? 0)) return override;
66
192
  return base;
67
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
+ }