structured-context 0.10.0 → 0.12.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.
Files changed (107) hide show
  1. package/README.md +85 -25
  2. package/dist/api.d.ts +25 -5
  3. package/dist/api.js +22 -2
  4. package/dist/commands/diagram.d.ts +1 -0
  5. package/dist/commands/diagram.js +7 -1
  6. package/dist/commands/dump.d.ts +15 -2
  7. package/dist/commands/dump.js +18 -4
  8. package/dist/commands/miro-sync.d.ts +3 -0
  9. package/dist/commands/miro-sync.js +8 -0
  10. package/dist/commands/render.d.ts +4 -0
  11. package/dist/commands/render.js +64 -9
  12. package/dist/commands/schemas.d.ts +2 -2
  13. package/dist/commands/schemas.js +117 -24
  14. package/dist/commands/show.d.ts +1 -0
  15. package/dist/commands/show.js +7 -1
  16. package/dist/commands/spaces.d.ts +3 -1
  17. package/dist/commands/spaces.js +11 -4
  18. package/dist/commands/validate-file.js +20 -5
  19. package/dist/commands/validate.js +54 -21
  20. package/dist/config.d.ts +26 -13
  21. package/dist/config.js +104 -31
  22. package/dist/filter/augment-nodes.d.ts +3 -1
  23. package/dist/filter/augment-nodes.js +12 -7
  24. package/dist/filter/filter-nodes.js +1 -1
  25. package/dist/index.js +51 -35
  26. package/dist/load-space-graph.d.ts +42 -0
  27. package/dist/load-space-graph.js +53 -0
  28. package/dist/plugins/index.js +3 -1
  29. package/dist/plugins/loader.js +4 -4
  30. package/dist/plugins/markdown/extract-content-links.d.ts +23 -0
  31. package/dist/plugins/markdown/extract-content-links.js +123 -0
  32. package/dist/plugins/markdown/index.js +5 -5
  33. package/dist/plugins/markdown/parse-embedded.d.ts +6 -0
  34. package/dist/plugins/markdown/parse-embedded.js +50 -7
  35. package/dist/plugins/markdown/read-space.js +41 -5
  36. package/dist/plugins/markdown/render-directory.d.ts +11 -0
  37. package/dist/plugins/markdown/render-directory.js +130 -0
  38. package/dist/plugins/mermaid/index.d.ts +2 -0
  39. package/dist/plugins/mermaid/index.js +14 -0
  40. package/dist/plugins/{markdown → mermaid}/render-mermaid.js +17 -15
  41. package/dist/{integrations → plugins}/miro/cache.d.ts +1 -1
  42. package/dist/plugins/miro/index.d.ts +2 -0
  43. package/dist/plugins/miro/index.js +27 -0
  44. package/dist/{integrations → plugins}/miro/layout.d.ts +2 -2
  45. package/dist/{integrations → plugins}/miro/layout.js +3 -16
  46. package/dist/{integrations → plugins}/miro/styles.d.ts +2 -2
  47. package/dist/plugins/miro/sync.d.ts +7 -0
  48. package/dist/{integrations → plugins}/miro/sync.js +22 -39
  49. package/dist/plugins/util.d.ts +48 -1
  50. package/dist/plugins/util.js +143 -0
  51. package/dist/read/resolve-graph-edges.d.ts +1 -1
  52. package/dist/read/resolve-graph-edges.js +78 -27
  53. package/dist/read/wikilink-utils.d.ts +17 -0
  54. package/dist/read/wikilink-utils.js +40 -0
  55. package/dist/render/render.d.ts +4 -1
  56. package/dist/render/render.js +20 -16
  57. package/dist/schema/evaluate-rule.js +1 -0
  58. package/dist/schema/meta-schemas.d.ts +13 -0
  59. package/dist/schema/meta-schemas.js +50 -0
  60. package/dist/schema/metadata-contract.d.ts +343 -187
  61. package/dist/schema/metadata-contract.js +68 -30
  62. package/dist/schema/schema.d.ts +17 -9
  63. package/dist/schema/schema.js +283 -200
  64. package/dist/schema/validate-graph.d.ts +2 -1
  65. package/dist/schema/validate-graph.js +40 -23
  66. package/dist/schema/validate-rules.d.ts +5 -1
  67. package/dist/schema/validate-rules.js +20 -1
  68. package/dist/schemas/_knowledge_wiki.json +21 -21
  69. package/dist/schemas/_ost_strict.json +2 -2
  70. package/dist/schemas/_sctx_base.json +2 -2
  71. package/dist/schemas/_strategy_general.json +38 -38
  72. package/dist/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  73. package/dist/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
  74. package/dist/schemas/knowledge_wiki.json +10 -9
  75. package/dist/schemas/strategy_general.json +15 -14
  76. package/dist/schemas/strict_ost.json +17 -16
  77. package/dist/space-context.d.ts +27 -0
  78. package/dist/space-context.js +46 -0
  79. package/dist/space-graph.d.ts +9 -7
  80. package/dist/space-graph.js +7 -5
  81. package/dist/testing.d.ts +33 -0
  82. package/dist/testing.js +72 -0
  83. package/dist/types.d.ts +101 -8
  84. package/dist/validate.d.ts +15 -3
  85. package/dist/validate.js +55 -20
  86. package/docs/concepts.md +37 -4
  87. package/docs/config.md +10 -0
  88. package/docs/rules.md +49 -22
  89. package/docs/schemas.md +110 -35
  90. package/package.json +13 -8
  91. package/schemas/_knowledge_wiki.json +21 -21
  92. package/schemas/_ost_strict.json +2 -2
  93. package/schemas/_sctx_base.json +2 -2
  94. package/schemas/_strategy_general.json +38 -38
  95. package/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  96. package/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
  97. package/schemas/knowledge_wiki.json +10 -9
  98. package/schemas/strategy_general.json +15 -14
  99. package/schemas/strict_ost.json +17 -16
  100. package/dist/integrations/miro/sync.d.ts +0 -8
  101. package/dist/schemas/generated/_structured_context_schema_meta.json +0 -191
  102. package/schemas/generated/_structured_context_schema_meta.json +0 -191
  103. /package/dist/plugins/{markdown → mermaid}/render-mermaid.d.ts +0 -0
  104. /package/dist/{integrations → plugins}/miro/cache.js +0 -0
  105. /package/dist/{integrations → plugins}/miro/client.d.ts +0 -0
  106. /package/dist/{integrations → plugins}/miro/client.js +0 -0
  107. /package/dist/{integrations → plugins}/miro/styles.js +0 -0
@@ -1,16 +1,108 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
- import { basename, dirname, join, resolve } from 'node:path';
2
+ import { basename, dirname, extname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { isDeepStrictEqual } from 'node:util';
5
- import Ajv, {} from 'ajv';
4
+ import Ajv2020 from 'ajv/dist/2020';
6
5
  import JSON5 from 'json5';
7
- import { DIALECT_META_SCHEMA, METADATA_SCHEMA, SCHEMA_META_ID, } from './metadata-contract';
6
+ import { SCHEMA_URI_SCHEME } from '../constants';
7
+ import { shortenPluginName } from '../plugins/util';
8
+ import { loadMetaSchemas } from './meta-schemas';
9
+ import { METADATA_SCHEMA, } from './metadata-contract';
8
10
  import { isObject, mergeVariantProperties, resolveJsonPointer } from './schema-refs';
9
- const packageDir = dirname(fileURLToPath(import.meta.url));
10
- export const bundledSchemasDir = join(packageDir, '..', '..', 'schemas');
11
- const validateMetadataContract = new Ajv().compile(METADATA_SCHEMA);
11
+ const packageDir = import.meta.url ? dirname(fileURLToPath(import.meta.url)) : '';
12
+ export let bundledSchemasDir = packageDir ? join(packageDir, '..', '..', 'schemas') : '';
13
+ /** Override the bundled schemas directory (e.g. when running in a bundled context). */
14
+ export function setBundledSchemasDir(dir) {
15
+ bundledSchemasDir = dir;
16
+ }
17
+ const validateMetadataContract = new Ajv2020().compile(METADATA_SCHEMA);
18
+ /** File stem of each schema read from disk — the default name of the hierarchy it declares. */
19
+ const schemaFileStems = new WeakMap();
12
20
  export function readRawSchema(schemaPath) {
13
- return JSON5.parse(readFileSync(resolve(schemaPath), 'utf-8'));
21
+ const schema = JSON5.parse(readFileSync(resolve(schemaPath), 'utf-8'));
22
+ rewriteLegacyCoreRefs(schema, schemaPath);
23
+ schemaFileStems.set(schema, basename(schemaPath, extname(schemaPath)));
24
+ return schema;
25
+ }
26
+ /**
27
+ * Schema `$id` namespaces. Every `$id` uses the `sctx://` scheme, and its shape says where the schema comes from:
28
+ * - `sctx://core/<name>` — bundled with structured-context
29
+ * - `sctx://<plugin>/<name>` — contributed by a plugin, prefixed with the plugin's short name
30
+ * - `sctx://<name>` — local to a space's schema directory (a single segment, no path)
31
+ */
32
+ const SCTX_SCHEME = `${SCHEMA_URI_SCHEME}://`;
33
+ export const CORE_SCHEMA_PREFIX = `${SCTX_SCHEME}core/`;
34
+ /**
35
+ * Bundled schema `$id`s from before the `sctx://core/` namespace. References to them keep resolving
36
+ * (with a deprecation warning) until 1.0, and local schemas may not claim them.
37
+ */
38
+ const LEGACY_CORE_IDS = new Set([
39
+ '_sctx_base',
40
+ '_ost_strict',
41
+ '_strategy_general',
42
+ '_knowledge_wiki',
43
+ 'strict_ost',
44
+ 'strategy_general',
45
+ 'knowledge_wiki',
46
+ ].map((name) => `${SCTX_SCHEME}${name}`));
47
+ const warnedLegacyIds = new Set();
48
+ function upgradeLegacyCoreRef(ref, schemaPath) {
49
+ const hashIndex = ref.indexOf('#');
50
+ const id = hashIndex === -1 ? ref : ref.slice(0, hashIndex);
51
+ if (!LEGACY_CORE_IDS.has(id))
52
+ return ref;
53
+ const upgraded = `${CORE_SCHEMA_PREFIX}${id.slice(SCTX_SCHEME.length)}`;
54
+ if (!warnedLegacyIds.has(id)) {
55
+ warnedLegacyIds.add(id);
56
+ console.warn(`Warning: "${id}" is deprecated and will stop resolving in 1.0; use "${upgraded}" instead (referenced in ${schemaPath}).`);
57
+ }
58
+ return hashIndex === -1 ? upgraded : `${upgraded}${ref.slice(hashIndex)}`;
59
+ }
60
+ /** Rewrite references to legacy bundled `$id`s (in `$ref` and `$metadata.imports`) to their `sctx://core/` form. */
61
+ function rewriteLegacyCoreRefs(node, schemaPath) {
62
+ if (Array.isArray(node)) {
63
+ for (const item of node)
64
+ rewriteLegacyCoreRefs(item, schemaPath);
65
+ return;
66
+ }
67
+ if (!isObject(node))
68
+ return;
69
+ if (typeof node.$ref === 'string')
70
+ node.$ref = upgradeLegacyCoreRef(node.$ref, schemaPath);
71
+ if (isObject(node.$metadata) && Array.isArray(node.$metadata.imports)) {
72
+ node.$metadata.imports = node.$metadata.imports.map((entry) => typeof entry === 'string' ? upgradeLegacyCoreRef(entry, schemaPath) : entry);
73
+ }
74
+ for (const value of Object.values(node))
75
+ rewriteLegacyCoreRefs(value, schemaPath);
76
+ }
77
+ /** Throw if `id` doesn't belong to the namespace of the source it was loaded from. */
78
+ function assertIdNamespace(id, source) {
79
+ if (source.kind === 'core') {
80
+ if (!id.startsWith(CORE_SCHEMA_PREFIX)) {
81
+ throw new Error(`Bundled schema $id "${id}" must start with "${CORE_SCHEMA_PREFIX}".`);
82
+ }
83
+ return;
84
+ }
85
+ const where = source.kind === 'plugin' ? `schema in plugin "${source.pluginName}"` : `local schema in "${source.dir}"`;
86
+ const expected = source.kind === 'plugin' ? `"${SCTX_SCHEME}${source.shortName}/<name>"` : `"${SCTX_SCHEME}<name>"`;
87
+ if (!id.startsWith(SCTX_SCHEME)) {
88
+ throw new Error(`Schema namespace violation: ${where} uses $id "${id}". Use the form ${expected}.`);
89
+ }
90
+ const segments = id.slice(SCTX_SCHEME.length).split('/');
91
+ if (source.kind === 'plugin') {
92
+ if (source.shortName === 'core') {
93
+ throw new Error(`Schema namespace violation: plugin "${source.pluginName}" can't contribute schemas; "core" is reserved for bundled schemas.`);
94
+ }
95
+ if (segments.length < 2 || segments[0] !== source.shortName) {
96
+ throw new Error(`Schema namespace violation: ${where} uses $id "${id}". Use the form ${expected}.`);
97
+ }
98
+ return;
99
+ }
100
+ if (segments.length !== 1) {
101
+ throw new Error(`Schema namespace violation: ${where} uses $id "${id}". Local schema $ids are a single name, ${expected}; "${SCTX_SCHEME}<prefix>/..." is reserved for bundled ("core") and plugin schemas.`);
102
+ }
103
+ if (LEGACY_CORE_IDS.has(id)) {
104
+ throw new Error(`Schema namespace violation: ${where} uses $id "${id}", which is reserved as an alias for "${CORE_SCHEMA_PREFIX}${segments[0]}" until 1.0. Choose another name.`);
105
+ }
14
106
  }
15
107
  /**
16
108
  * Build a registry of all schemas in the given directory, keyed by $id.
@@ -34,35 +126,65 @@ function buildSchemaRegistry(dir, targetFile) {
34
126
  return schemaRefRegistry;
35
127
  }
36
128
  /**
37
- * Build the full two-layer registry for a schema path:
129
+ * Build the full multi-layer registry for a schema path:
38
130
  * - Layer 1: bundled schemas/ dir (partials only, as fallback)
39
- * - Layer 2: schema's own dir (partials + target file) — overrides layer 1
40
- * Does not throw on $id collision; layer 2 silently wins.
131
+ * - Layer 2: plugin schema dirs (partials + target file if schemaPath is in this plugin's schemasDir)
132
+ * - Layer 3: schema's own dir (partials + target file)
133
+ * Throws if a lower-precedence schema collides with a reserved $id or violates namespace rules.
41
134
  */
42
- export function buildFullRegistry(schemaPath) {
135
+ export function buildFullRegistry(schemaPath, pluginSchemas) {
43
136
  const absPath = resolve(schemaPath);
44
137
  const targetFile = basename(absPath);
45
138
  const targetDir = dirname(absPath);
46
139
  const schemaRefRegistry = new Map();
47
140
  // Layer 1: bundled schemas/ dir (partials only)
48
141
  for (const [id, schema] of buildSchemaRegistry(bundledSchemasDir)) {
142
+ assertIdNamespace(id, { kind: 'core' });
49
143
  schemaRefRegistry.set(id, schema);
50
144
  }
51
- // Layer 2: schema's own dir (partials + target file)
52
- if (targetDir !== bundledSchemasDir) {
53
- const bundledIds = new Set(schemaRefRegistry.keys());
54
- bundledIds.add(SCHEMA_META_ID);
145
+ const bundledIds = new Set(schemaRefRegistry.keys());
146
+ for (const id of loadMetaSchemas(bundledSchemasDir).keys())
147
+ bundledIds.add(id);
148
+ // Layer 2: plugin schema dirs
149
+ const pluginIds = new Map(); // id -> pluginName
150
+ if (pluginSchemas) {
151
+ for (const { schemasDir, pluginName } of pluginSchemas) {
152
+ if (!existsSync(schemasDir))
153
+ continue;
154
+ const isTargetDir = resolve(schemasDir) === targetDir;
155
+ const shortName = shortenPluginName(pluginName);
156
+ for (const [id, schema] of buildSchemaRegistry(schemasDir, isTargetDir ? targetFile : undefined)) {
157
+ if (bundledIds.has(id)) {
158
+ throw new Error(`Schema collision: schema in plugin "${pluginName}" (${schemasDir}) uses $id "${id}" which is reserved by a default schema. Please use a unique $id for plugin schemas.`);
159
+ }
160
+ const existingPlugin = pluginIds.get(id);
161
+ if (existingPlugin) {
162
+ throw new Error(`Schema collision: schema in plugin "${pluginName}" (${schemasDir}) uses $id "${id}" which is already defined by plugin "${existingPlugin}".`);
163
+ }
164
+ assertIdNamespace(id, { kind: 'plugin', pluginName, shortName });
165
+ schemaRefRegistry.set(id, schema);
166
+ pluginIds.set(id, pluginName);
167
+ }
168
+ }
169
+ }
170
+ // Layer 3: schema's own dir (partials + target file)
171
+ const isPluginDir = pluginSchemas?.some((p) => resolve(p.schemasDir) === targetDir);
172
+ if (targetDir !== bundledSchemasDir && !isPluginDir) {
173
+ const reservedIds = new Set(schemaRefRegistry.keys());
174
+ for (const id of loadMetaSchemas(bundledSchemasDir).keys())
175
+ reservedIds.add(id);
55
176
  for (const [id, schema] of buildSchemaRegistry(targetDir, targetFile)) {
56
- if (bundledIds.has(id)) {
57
- throw new Error(`Schema collision: partial schema in ${targetDir} uses $id "${id}" which is reserved by a default schema. Please use a unique $id for local partials.`);
177
+ if (reservedIds.has(id)) {
178
+ throw new Error(`Schema collision: local schema in ${targetDir} uses $id "${id}", which is already defined by a bundled or plugin schema. Choose another name.`);
58
179
  }
180
+ assertIdNamespace(id, { kind: 'local', dir: targetDir });
59
181
  schemaRefRegistry.set(id, schema);
60
182
  }
61
183
  }
62
184
  return schemaRefRegistry;
63
185
  }
64
186
  function compileValidator(targetSchema, schemaRefRegistry) {
65
- const ajv = new Ajv();
187
+ const ajv = new Ajv2020();
66
188
  ajv.addFormat('path', (value) => value.length > 0 && !value.includes('\0'));
67
189
  ajv.addFormat('date', (value) => /^\d{4}-\d{2}-\d{2}$/.test(value));
68
190
  ajv.addFormat('wikilink', (value) => /^\[\[.+\]\]$/.test(value));
@@ -73,37 +195,39 @@ function compileValidator(targetSchema, schemaRefRegistry) {
73
195
  valid: true,
74
196
  errors: false,
75
197
  });
76
- const metaSchema = DIALECT_META_SCHEMA;
77
- ajv.addSchema(metaSchema, SCHEMA_META_ID);
198
+ const metaSchemas = loadMetaSchemas(bundledSchemasDir);
199
+ for (const schema of metaSchemas.values())
200
+ ajv.addSchema(schema);
78
201
  // Register all except target schema (AJV compiles targetSchema explicitly)
79
202
  for (const [id, schema] of schemaRefRegistry) {
80
- if (id === targetSchema.$id || id === SCHEMA_META_ID)
203
+ if (id === targetSchema.$id || metaSchemas.has(id))
81
204
  continue;
82
205
  ajv.addSchema(schema);
83
206
  }
84
207
  return ajv.compile(targetSchema);
85
208
  }
86
- export function createValidator(schemaPath) {
87
- return compileValidator(readRawSchema(schemaPath), buildFullRegistry(schemaPath));
209
+ export function createValidator(schemaPath, pluginSchemas) {
210
+ return compileValidator(readRawSchema(schemaPath), buildFullRegistry(schemaPath, pluginSchemas));
88
211
  }
89
212
  export function resolveNodeType(type, typeAliases) {
90
213
  return typeAliases?.[type] ?? type;
91
214
  }
92
- const RULE_CATEGORIES = new Set(['validation', 'coherence', 'workflow', 'best-practice']);
93
- const RULE_ALLOWED_KEYS = new Set(['id', 'category', 'description', 'check', 'type', 'scope', 'override']);
94
215
  function readTopLevelMetadata(schema) {
95
216
  const metadata = schema.$metadata;
96
217
  if (!isObject(metadata))
97
218
  return undefined;
219
+ const schemaId = typeof schema.$id === 'string' ? schema.$id : '(unknown schema)';
220
+ if (isObject(metadata.hierarchy) && '$ref' in metadata.hierarchy && Object.keys(metadata.hierarchy).length > 1) {
221
+ throw new Error(`Invalid $metadata in schema "${schemaId}": hierarchy both declares levels and selects a hierarchy with "$ref". Use one or the other.`);
222
+ }
98
223
  if (!validateMetadataContract(metadata)) {
99
- const schemaId = typeof schema.$id === 'string' ? schema.$id : '(unknown schema)';
100
224
  const errors = validateMetadataContract.errors?.map((e) => `${e.instancePath || '(root)'} ${e.message}`).join('; ') ??
101
225
  'unknown error';
102
226
  throw new Error(`Invalid $metadata in schema "${schemaId}": ${errors}`);
103
227
  }
104
228
  return metadata;
105
229
  }
106
- function resolveRefTargetForRule(ref, currentRootSchema, schemaRefRegistry) {
230
+ function resolveMetadataRef(ref, currentRootSchema, schemaRefRegistry) {
107
231
  if (ref.startsWith('#')) {
108
232
  const pointer = ref.slice(1);
109
233
  const rootId = typeof currentRootSchema.$id === 'string' ? currentRootSchema.$id : '(root schema)';
@@ -126,180 +250,140 @@ function resolveRefTargetForRule(ref, currentRootSchema, schemaRefRegistry) {
126
250
  refKey: `${baseId}#${pointer}`,
127
251
  };
128
252
  }
129
- function collectExternalRefIdsInOrder(schema) {
130
- const refs = [];
131
- const seen = new Set();
132
- const walk = (value) => {
133
- if (!isObject(value))
134
- return;
135
- for (const [key, child] of Object.entries(value)) {
136
- if (key === '$ref' && typeof child === 'string' && !child.startsWith('#')) {
137
- const schemaId = child.split('#', 1)[0] ?? child;
138
- if (!seen.has(schemaId)) {
139
- seen.add(schemaId);
140
- refs.push(schemaId);
141
- }
142
- continue;
143
- }
144
- walk(child);
145
- }
146
- };
147
- walk(schema);
148
- return refs;
149
- }
253
+ /**
254
+ * Collect metadata providers in merge order: each schema's `$metadata.imports` depth-first, in
255
+ * order, then the schema's own metadata. Each schema contributes at most once. `$ref` does not
256
+ * import metadata.
257
+ */
150
258
  function collectMetadataProviders(rootSchema, schemaRefRegistry) {
151
259
  const providers = [];
152
- const visitedSchemaIds = new Set();
153
- const walk = (schema) => {
154
- const refs = collectExternalRefIdsInOrder(schema);
155
- for (const schemaId of refs) {
156
- if (visitedSchemaIds.has(schemaId))
157
- continue;
158
- visitedSchemaIds.add(schemaId);
159
- const referencedSchema = schemaRefRegistry.get(schemaId);
160
- if (!referencedSchema)
260
+ const rootSchemaId = typeof rootSchema.$id === 'string' ? rootSchema.$id : '(root schema)';
261
+ const visitedSchemaIds = new Set([rootSchemaId]);
262
+ const walk = (schema, schemaId) => {
263
+ const metadata = readTopLevelMetadata(schema);
264
+ if (!metadata)
265
+ return;
266
+ for (const importId of metadata.imports ?? []) {
267
+ if (visitedSchemaIds.has(importId))
161
268
  continue;
162
- walk(referencedSchema);
163
- const metadata = readTopLevelMetadata(referencedSchema);
164
- if (metadata) {
165
- providers.push({ schemaId, schema: referencedSchema, metadata });
269
+ visitedSchemaIds.add(importId);
270
+ const importedSchema = schemaRefRegistry.get(importId);
271
+ if (!importedSchema) {
272
+ throw new Error(`Cannot resolve import "${importId}" in schema "${schemaId}".`);
166
273
  }
274
+ walk(importedSchema, importId);
167
275
  }
276
+ providers.push({ schemaId, schema, metadata });
168
277
  };
169
- walk(rootSchema);
170
- const rootMetadata = readTopLevelMetadata(rootSchema);
171
- if (rootMetadata) {
172
- const rootSchemaId = typeof rootSchema.$id === 'string' ? rootSchema.$id : '(root schema)';
173
- providers.push({ schemaId: rootSchemaId, schema: rootSchema, metadata: rootMetadata });
174
- }
278
+ walk(rootSchema, rootSchemaId);
175
279
  return providers;
176
280
  }
177
- function isRuleRefEntry(value) {
281
+ function isRefEntry(value) {
178
282
  if (!isObject(value))
179
283
  return false;
180
284
  return typeof value.$ref === 'string' && value.$ref.length > 0 && Object.keys(value).length === 1;
181
285
  }
182
- function isMetadataRule(value) {
183
- if (!isObject(value))
184
- return false;
185
- const record = value;
186
- if (typeof record.id !== 'string' || record.id.length === 0)
187
- return false;
188
- if (typeof record.category !== 'string' || !RULE_CATEGORIES.has(record.category))
189
- return false;
190
- if (typeof record.description !== 'string' || record.description.length === 0)
191
- return false;
192
- if (typeof record.check !== 'string' || record.check.length === 0)
193
- return false;
194
- if ('type' in record && (typeof record.type !== 'string' || record.type.length === 0))
195
- return false;
196
- if ('scope' in record && record.scope !== 'global')
197
- return false;
198
- if ('override' in record && typeof record.override !== 'boolean')
199
- return false;
200
- for (const key of Object.keys(record)) {
201
- if (!RULE_ALLOWED_KEYS.has(key))
202
- return false;
203
- }
204
- return true;
205
- }
206
- function resolveRuleEntries(ruleEntry, provider, schemaRefRegistry, stack) {
207
- if (isMetadataRule(ruleEntry)) {
208
- return [ruleEntry];
209
- }
210
- if (!isRuleRefEntry(ruleEntry)) {
211
- throw new Error(`Invalid rule entry in metadata from "${provider.schemaId}".`);
212
- }
213
- const target = resolveRefTargetForRule(ruleEntry.$ref, provider.schema, schemaRefRegistry);
214
- if (stack.has(target.refKey)) {
215
- throw new Error(`Cyclic rule import detected while loading metadata from "${provider.schemaId}": ${[...stack, target.refKey].join(' -> ')}`);
216
- }
217
- stack.add(target.refKey);
218
- try {
219
- const value = target.value;
220
- const resolveArray = (arr) => {
221
- const resolvedRules = [];
222
- for (const child of arr) {
223
- if (!isObject(child)) {
224
- throw new Error(`Invalid rule import target for "${ruleEntry.$ref}" from "${provider.schemaId}". Rule sets must contain objects.`);
225
- }
226
- resolvedRules.push(...resolveRuleEntries(child, { ...provider, schema: target.rootSchema }, schemaRefRegistry, stack));
227
- }
228
- return resolvedRules;
229
- };
230
- if (Array.isArray(value)) {
231
- return resolveArray(value);
232
- }
233
- if (isObject(value) && 'rules' in value) {
234
- const nestedRules = value.rules;
235
- if (!Array.isArray(nestedRules)) {
236
- throw new Error(`Invalid rule import target for "${ruleEntry.$ref}" from "${provider.schemaId}". "rules" must be an array.`);
237
- }
238
- return resolveArray(nestedRules);
239
- }
240
- if (isObject(value)) {
241
- return resolveRuleEntries(value, { ...provider, schema: target.rootSchema }, schemaRefRegistry, stack);
242
- }
243
- throw new Error(`Invalid rule import target for "${ruleEntry.$ref}" from "${provider.schemaId}". Expected a rule object or rule set.`);
244
- }
245
- finally {
246
- stack.delete(target.refKey);
286
+ /**
287
+ * The single metadata merge rule: a duplicate key is an error unless the later entry sets
288
+ * `override: true`, in which case it replaces the earlier one (keeping its position).
289
+ */
290
+ function mergeEntry(merged, key, label, value, override, providerId) {
291
+ const existing = merged.get(key);
292
+ if (existing && override !== true) {
293
+ throw new Error(`Duplicate ${label} found in "${existing.providerId}" and "${providerId}". Set "override": true on the later one to replace it.`);
247
294
  }
295
+ merged.set(key, { providerId, value });
248
296
  }
249
- function normalizeRule(rule) {
250
- const { override, ...normalized } = rule;
251
- return normalized;
252
- }
253
- function areRulesEquivalent(left, right) {
254
- return isDeepStrictEqual(normalizeRule(left), normalizeRule(right));
297
+ function values(merged) {
298
+ return [...merged.values()].map(({ value }) => value);
255
299
  }
256
300
  function extractMetadata(schema, schemaRefRegistry) {
257
301
  const metadataProviders = collectMetadataProviders(schema, schemaRefRegistry);
258
- let mergedHierarchy;
259
- const mergedAliases = {};
302
+ // Hierarchies are merged by name; the declaring object is kept so a root `hierarchy: { $ref }`
303
+ // selection can be matched to it.
304
+ const mergedHierarchies = new Map();
305
+ let rootHierarchy;
306
+ let mainSelection;
307
+ const mergedAliases = new Map();
260
308
  const mergedRules = new Map();
261
- const mergedRelationships = [];
309
+ const mergedRelationships = new Map();
262
310
  for (const provider of metadataProviders) {
263
- if (provider.metadata.hierarchy) {
264
- // Later providers (including root schema) override earlier ones (e.g. partials).
265
- // This allows partials to define a default hierarchy that composing schemas can override.
266
- mergedHierarchy = provider.metadata.hierarchy;
311
+ const { schemaId, metadata } = provider;
312
+ const declaredHierarchy = metadata.hierarchy;
313
+ const isRoot = provider.schema === schema;
314
+ if (isRefEntry(declaredHierarchy)) {
315
+ // A hierarchy selection only applies when its schema is the root; imported schemas contribute
316
+ // their declared hierarchies, not their selections.
317
+ if (isRoot)
318
+ mainSelection = declaredHierarchy;
267
319
  }
268
- if (provider.metadata.aliases) {
269
- Object.assign(mergedAliases, provider.metadata.aliases);
320
+ else if (declaredHierarchy) {
321
+ const hierarchy = normalizeHierarchy(declaredHierarchy, provider);
322
+ const label = `hierarchy "${hierarchy.name}"`;
323
+ mergeEntry(mergedHierarchies, hierarchy.name, label, { declared: declaredHierarchy, hierarchy }, declaredHierarchy.override, schemaId);
324
+ if (isRoot)
325
+ rootHierarchy = declaredHierarchy;
270
326
  }
271
- if (provider.metadata.relationships) {
272
- mergedRelationships.push(...provider.metadata.relationships);
327
+ for (const [alias, target] of Object.entries(metadata.aliases ?? {})) {
328
+ const [type, override] = typeof target === 'string' ? [target, false] : [target.type, target.override];
329
+ mergeEntry(mergedAliases, alias, `alias "${alias}"`, type, override, schemaId);
273
330
  }
274
- if (provider.metadata.rules) {
275
- for (const entry of provider.metadata.rules) {
276
- const resolvedRules = resolveRuleEntries(entry, provider, schemaRefRegistry, new Set());
277
- for (const incomingRule of resolvedRules) {
278
- const existingRule = mergedRules.get(incomingRule.id);
279
- if (!existingRule) {
280
- mergedRules.set(incomingRule.id, { providerId: provider.schemaId, rule: incomingRule });
281
- continue;
282
- }
283
- if (incomingRule.override === true) {
284
- mergedRules.set(incomingRule.id, { providerId: provider.schemaId, rule: incomingRule });
285
- continue;
286
- }
287
- if (!areRulesEquivalent(existingRule.rule, incomingRule)) {
288
- throw new Error(`Conflicting rule "${incomingRule.id}" found in "${existingRule.providerId}" and "${provider.schemaId}". Set "override": true on the later rule to replace it.`);
289
- }
290
- }
291
- }
331
+ for (const rel of metadata.relationships ?? []) {
332
+ const field = rel.field ?? 'parent';
333
+ const label = `relationship "${rel.parent} → ${rel.type}" (field "${field}")`;
334
+ mergeEntry(mergedRelationships, `${rel.parent}\0${rel.type}\0${field}`, label, rel, rel.override, schemaId);
335
+ }
336
+ for (const rule of metadata.rules ?? []) {
337
+ mergeEntry(mergedRules, rule.id, `rule "${rule.id}"`, rule, rule.override, schemaId);
292
338
  }
293
339
  }
340
+ const merged = values(mergedHierarchies);
341
+ const allHierarchies = merged.map(({ hierarchy }) => hierarchy);
342
+ validateHierarchyTypes(allHierarchies);
343
+ const findDeclared = (declared) => merged.find((entry) => entry.declared === declared)?.hierarchy;
344
+ let mainHierarchy = rootHierarchy ? findDeclared(rootHierarchy) : undefined;
345
+ if (mainSelection) {
346
+ const target = resolveMetadataRef(mainSelection.$ref, schema, schemaRefRegistry);
347
+ mainHierarchy = findDeclared(target.value);
348
+ if (!mainHierarchy) {
349
+ throw new Error(`Hierarchy $ref "${mainSelection.$ref}" does not point to a hierarchy declared by an imported schema. Expected a ref like "<schema $id>#/$metadata/hierarchy".`);
350
+ }
351
+ }
352
+ else if (!mainHierarchy && allHierarchies.length === 1) {
353
+ mainHierarchy = allHierarchies[0];
354
+ }
355
+ else if (!mainHierarchy && allHierarchies.length > 1) {
356
+ const rootSchemaId = typeof schema.$id === 'string' ? schema.$id : '(root schema)';
357
+ throw new Error(`Schema "${rootSchemaId}" imports multiple hierarchies (${allHierarchies.map((h) => `"${h.name}"`).join(', ')}) but selects none as its main hierarchy. Declare one, or select one with "hierarchy": { "$ref": "<schema $id>#/$metadata/hierarchy" }.`);
358
+ }
359
+ const typeAliases = Object.fromEntries([...mergedAliases].map(([alias, { value }]) => [alias, value]));
360
+ const rules = values(mergedRules).map(({ override, ...rule }) => rule);
361
+ const relationships = values(mergedRelationships);
294
362
  // Collect valid type names from the schema's oneOf list for validation
295
363
  const validTypeNames = extractSchemaTypeNames(schema, schemaRefRegistry);
296
- // Validate and filter metadata references against valid type names
297
- validateMetadataReferences({ mergedHierarchy, mergedAliases, mergedRules, mergedRelationships }, validTypeNames);
364
+ // Validate metadata references against valid type names
365
+ validateMetadataReferences({ allHierarchies, typeAliases, rules }, validTypeNames);
298
366
  // Filter relationships to only include those where both parent and child types are valid
299
- const filteredRelationships = mergedRelationships.filter((rel) => {
367
+ const filteredRelationships = relationships.filter((rel) => {
300
368
  return validTypeNames.has(rel.parent) && validTypeNames.has(rel.type);
301
369
  });
302
- const levels = mergedHierarchy?.levels.map((entry) => {
370
+ return {
371
+ hierarchy: mainHierarchy,
372
+ hierarchies: allHierarchies.length > 0 ? allHierarchies : undefined,
373
+ typeAliases: Object.keys(typeAliases).length > 0 ? typeAliases : undefined,
374
+ rules: rules.length > 0 ? rules : undefined,
375
+ relationships: filteredRelationships.length > 0
376
+ ? filteredRelationships.map(({ override, ...rel }) => ({
377
+ ...rel,
378
+ field: rel.field ?? 'parent',
379
+ fieldOn: rel.fieldOn === 'parent' ? 'parent' : 'child',
380
+ multiple: rel.multiple ?? false,
381
+ }))
382
+ : undefined,
383
+ };
384
+ }
385
+ function normalizeHierarchy(declared, provider) {
386
+ const levels = declared.levels.map((entry) => {
303
387
  if (typeof entry === 'string') {
304
388
  return { type: entry, field: 'parent', fieldOn: 'child', multiple: false, selfRef: false };
305
389
  }
@@ -318,24 +402,24 @@ function extractMetadata(schema, schemaRefRegistry) {
318
402
  };
319
403
  });
320
404
  return {
321
- hierarchy: levels !== undefined
322
- ? {
323
- levels,
324
- allowSkipLevels: mergedHierarchy?.allowSkipLevels,
325
- }
326
- : undefined,
327
- typeAliases: Object.keys(mergedAliases).length > 0 ? mergedAliases : undefined,
328
- rules: mergedRules.size > 0 ? [...mergedRules.values()].map(({ rule }) => normalizeRule(rule)) : undefined,
329
- relationships: filteredRelationships.length > 0
330
- ? filteredRelationships.map((rel) => ({
331
- ...rel,
332
- field: rel.field ?? 'parent',
333
- fieldOn: rel.fieldOn === 'parent' ? 'parent' : 'child',
334
- multiple: rel.multiple ?? false,
335
- }))
336
- : undefined,
405
+ name: declared.name ?? schemaFileStems.get(provider.schema) ?? provider.schemaId,
406
+ levels,
407
+ allowSkipLevels: declared.allowSkipLevels,
337
408
  };
338
409
  }
410
+ /** Each type may belong to at most one hierarchy. */
411
+ function validateHierarchyTypes(hierarchies) {
412
+ const typeOwners = new Map();
413
+ for (const hierarchy of hierarchies) {
414
+ for (const { type } of hierarchy.levels) {
415
+ const owner = typeOwners.get(type);
416
+ if (owner !== undefined && owner !== hierarchy.name) {
417
+ throw new Error(`Type "${type}" belongs to both hierarchy "${owner}" and hierarchy "${hierarchy.name}". Each type may belong to at most one hierarchy.`);
418
+ }
419
+ typeOwners.set(type, hierarchy.name);
420
+ }
421
+ }
422
+ }
339
423
  function validateMetadataReferences(metadata, validTypes) {
340
424
  // Skip validation if no types are defined (e.g., schemas without oneOf or placeholder schemas)
341
425
  if (validTypes.size === 0) {
@@ -343,23 +427,22 @@ function validateMetadataReferences(metadata, validTypes) {
343
427
  }
344
428
  const errors = [];
345
429
  // Validate hierarchy level types
346
- if (metadata.mergedHierarchy?.levels) {
347
- for (const level of metadata.mergedHierarchy.levels) {
348
- const typeName = typeof level === 'string' ? level : level.type;
349
- if (!validTypes.has(typeName)) {
350
- errors.push(`Hierarchy level "${typeName}" is not a valid type in the schema's oneOf list`);
430
+ for (const hierarchy of metadata.allHierarchies) {
431
+ for (const { type } of hierarchy.levels) {
432
+ if (!validTypes.has(type)) {
433
+ errors.push(`Hierarchy "${hierarchy.name}" level "${type}" is not a valid type in the schema's oneOf list`);
351
434
  }
352
435
  }
353
436
  }
354
437
  // Validate alias targets (the values)
355
- for (const [alias, target] of Object.entries(metadata.mergedAliases)) {
438
+ for (const [alias, target] of Object.entries(metadata.typeAliases)) {
356
439
  if (!validTypes.has(target)) {
357
440
  errors.push(`Alias "${alias}" → "${target}" references type "${target}" which is not in the schema's oneOf list`);
358
441
  }
359
442
  }
360
443
  // Note: relationships are filtered (not validated) - see extractMetadata
361
444
  // Validate rule types
362
- for (const { rule } of metadata.mergedRules.values()) {
445
+ for (const rule of metadata.rules) {
363
446
  if (rule.type && !validTypes.has(rule.type)) {
364
447
  errors.push(`Rule "${rule.id}" has type "${rule.type}" which is not in the schema's oneOf list`);
365
448
  }
@@ -397,12 +480,12 @@ export function extractEntityInfo(schema, schemaRefRegistry) {
397
480
  export function extractSchemaTypeNames(schema, schemaRefRegistry) {
398
481
  return new Set(extractEntityInfo(schema, schemaRefRegistry).map((e) => e.type));
399
482
  }
400
- export function loadMetadata(schemaPath) {
401
- return extractMetadata(readRawSchema(schemaPath), buildFullRegistry(schemaPath));
483
+ export function loadMetadata(schemaPath, pluginSchemas) {
484
+ return extractMetadata(readRawSchema(schemaPath), buildFullRegistry(schemaPath, pluginSchemas));
402
485
  }
403
- export function loadSchema(schemaPath) {
486
+ export function loadSchema(schemaPath, pluginSchemas) {
404
487
  const rawSchema = readRawSchema(schemaPath);
405
- const schemaRefRegistry = buildFullRegistry(schemaPath);
488
+ const schemaRefRegistry = buildFullRegistry(schemaPath, pluginSchemas);
406
489
  const schema = {
407
490
  ...rawSchema,
408
491
  metadata: extractMetadata(rawSchema, schemaRefRegistry),
@@ -3,6 +3,7 @@ export interface GraphValidationResult {
3
3
  violations: GraphViolation[];
4
4
  refErrors: Array<{
5
5
  file: string;
6
+ line?: number;
6
7
  parent: string;
7
8
  error: string;
8
9
  }>;
@@ -15,7 +16,7 @@ export declare function validateGraph(nodes: SpaceNode[], metadata: SchemaMetada
15
16
  /**
16
17
  * Validate that resolved parents follow hierarchy level rules and relationship type constraints.
17
18
  *
18
- * For hierarchy edges (fieldOn:'child', source:'hierarchy'): validates level ordering.
19
+ * For hierarchy edges (fieldOn:'child', source:'hierarchy'): validates level ordering within the edge's hierarchy.
19
20
  * For relationship edges (fieldOn:'child', source:'relationship'): validates parent type.
20
21
  * For fieldOn:'parent' edges: validates child type, violation attributed to the field-owner node.
21
22
  *