structured-context 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +348 -0
- package/dist/commands/diagram.d.ts +5 -0
- package/dist/commands/diagram.js +12 -0
- package/dist/commands/docs.d.ts +1 -0
- package/dist/commands/docs.js +67 -0
- package/dist/commands/dump.d.ts +2 -0
- package/dist/commands/dump.js +6 -0
- package/dist/commands/plugins.d.ts +1 -0
- package/dist/commands/plugins.js +23 -0
- package/dist/commands/render.d.ts +6 -0
- package/dist/commands/render.js +35 -0
- package/dist/commands/schemas.d.ts +6 -0
- package/dist/commands/schemas.js +268 -0
- package/dist/commands/show.d.ts +4 -0
- package/dist/commands/show.js +7 -0
- package/dist/commands/spaces.d.ts +1 -0
- package/dist/commands/spaces.js +36 -0
- package/dist/commands/template-sync.d.ts +3 -0
- package/dist/commands/template-sync.js +13 -0
- package/dist/commands/validate-file.d.ts +28 -0
- package/dist/commands/validate-file.js +133 -0
- package/dist/commands/validate.d.ts +16 -0
- package/dist/commands/validate.js +349 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.js +179 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/filter/augment-nodes.d.ts +23 -0
- package/dist/filter/augment-nodes.js +95 -0
- package/dist/filter/expand-include.d.ts +62 -0
- package/dist/filter/expand-include.js +181 -0
- package/dist/filter/filter-nodes.d.ts +21 -0
- package/dist/filter/filter-nodes.js +73 -0
- package/dist/filter/parse-expression.d.ts +20 -0
- package/dist/filter/parse-expression.js +60 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +161 -0
- package/dist/integrations/miro/cache.d.ts +21 -0
- package/dist/integrations/miro/cache.js +55 -0
- package/dist/integrations/miro/client.d.ts +99 -0
- package/dist/integrations/miro/client.js +118 -0
- package/dist/integrations/miro/layout.d.ts +28 -0
- package/dist/integrations/miro/layout.js +72 -0
- package/dist/integrations/miro/styles.d.ts +11 -0
- package/dist/integrations/miro/styles.js +65 -0
- package/dist/integrations/miro/sync.d.ts +8 -0
- package/dist/integrations/miro/sync.js +347 -0
- package/dist/plugin-api.d.ts +12 -0
- package/dist/plugin-api.js +7 -0
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.js +3 -0
- package/dist/plugins/loader.d.ts +21 -0
- package/dist/plugins/loader.js +104 -0
- package/dist/plugins/markdown/index.d.ts +48 -0
- package/dist/plugins/markdown/index.js +51 -0
- package/dist/plugins/markdown/parse-embedded.d.ts +90 -0
- package/dist/plugins/markdown/parse-embedded.js +663 -0
- package/dist/plugins/markdown/read-space.d.ts +7 -0
- package/dist/plugins/markdown/read-space.js +89 -0
- package/dist/plugins/markdown/render-bullets.d.ts +2 -0
- package/dist/plugins/markdown/render-bullets.js +42 -0
- package/dist/plugins/markdown/render-mermaid.d.ts +2 -0
- package/dist/plugins/markdown/render-mermaid.js +57 -0
- package/dist/plugins/markdown/template-sync.d.ts +16 -0
- package/dist/plugins/markdown/template-sync.js +294 -0
- package/dist/plugins/markdown/util.d.ts +19 -0
- package/dist/plugins/markdown/util.js +80 -0
- package/dist/plugins/util.d.ts +60 -0
- package/dist/plugins/util.js +7 -0
- package/dist/read/read-space.d.ts +2 -0
- package/dist/read/read-space.js +22 -0
- package/dist/read/resolve-graph-edges.d.ts +11 -0
- package/dist/read/resolve-graph-edges.js +201 -0
- package/dist/read/wikilink-utils.d.ts +16 -0
- package/dist/read/wikilink-utils.js +38 -0
- package/dist/render/registry.d.ts +13 -0
- package/dist/render/registry.js +22 -0
- package/dist/render/render.d.ts +4 -0
- package/dist/render/render.js +28 -0
- package/dist/schema/evaluate-rule.d.ts +30 -0
- package/dist/schema/evaluate-rule.js +82 -0
- package/dist/schema/metadata-contract.d.ts +538 -0
- package/dist/schema/metadata-contract.js +115 -0
- package/dist/schema/schema-refs.d.ts +22 -0
- package/dist/schema/schema-refs.js +168 -0
- package/dist/schema/schema.d.ts +27 -0
- package/dist/schema/schema.js +378 -0
- package/dist/schema/validate-graph.d.ts +24 -0
- package/dist/schema/validate-graph.js +141 -0
- package/dist/schema/validate-rules.d.ts +10 -0
- package/dist/schema/validate-rules.js +51 -0
- package/dist/schemas/_ost_strict.json +81 -0
- package/dist/schemas/_sctx_base.json +72 -0
- package/dist/schemas/general.json +261 -0
- package/dist/schemas/generated/_structured_context_schema_meta.json +191 -0
- package/dist/schemas/knowledge_wiki.json +206 -0
- package/dist/schemas/strict_ost.json +97 -0
- package/dist/space-graph.d.ts +28 -0
- package/dist/space-graph.js +82 -0
- package/dist/types.d.ts +145 -0
- package/dist/types.js +0 -0
- package/docs/concepts.md +391 -0
- package/docs/config.md +140 -0
- package/docs/rules.md +120 -0
- package/docs/schemas.md +340 -0
- package/package.json +69 -0
- package/schemas/_ost_strict.json +81 -0
- package/schemas/_sctx_base.json +72 -0
- package/schemas/general.json +261 -0
- package/schemas/generated/_structured_context_schema_meta.json +191 -0
- package/schemas/knowledge_wiki.json +206 -0
- package/schemas/strict_ost.json +97 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
export { decodeJsonPointerToken, isObject, resolveJsonPointer, resolveRefTarget };
|
|
2
|
+
function isObject(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null;
|
|
4
|
+
}
|
|
5
|
+
function asArray(value) {
|
|
6
|
+
return Array.isArray(value) ? value : [];
|
|
7
|
+
}
|
|
8
|
+
function decodeJsonPointerToken(token) {
|
|
9
|
+
return token.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
10
|
+
}
|
|
11
|
+
function resolveJsonPointer(root, pointer, fullRef) {
|
|
12
|
+
if (pointer === '')
|
|
13
|
+
return root;
|
|
14
|
+
if (!pointer.startsWith('/')) {
|
|
15
|
+
throw new Error(`Unsupported $ref pointer "${fullRef}". Expected a JSON pointer (e.g. "#/$defs/name").`);
|
|
16
|
+
}
|
|
17
|
+
let current = root;
|
|
18
|
+
for (const rawToken of pointer.slice(1).split('/')) {
|
|
19
|
+
const token = decodeJsonPointerToken(rawToken);
|
|
20
|
+
if (Array.isArray(current)) {
|
|
21
|
+
const index = Number.parseInt(token, 10);
|
|
22
|
+
if (Number.isNaN(index) || index < 0 || index >= current.length) {
|
|
23
|
+
throw new Error(`Cannot resolve $ref "${fullRef}": array index "${token}" is out of bounds.`);
|
|
24
|
+
}
|
|
25
|
+
current = current[index];
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (!isObject(current) || !(token in current)) {
|
|
29
|
+
throw new Error(`Cannot resolve $ref "${fullRef}": token "${token}" does not exist.`);
|
|
30
|
+
}
|
|
31
|
+
current = current[token];
|
|
32
|
+
}
|
|
33
|
+
if (!isObject(current)) {
|
|
34
|
+
throw new Error(`Cannot resolve $ref "${fullRef}": target is not an object schema.`);
|
|
35
|
+
}
|
|
36
|
+
return current;
|
|
37
|
+
}
|
|
38
|
+
function mergeSchemaObjects(base, overlay) {
|
|
39
|
+
const merged = { ...base, ...overlay };
|
|
40
|
+
const baseProps = isObject(base.properties) ? base.properties : undefined;
|
|
41
|
+
const overlayProps = isObject(overlay.properties)
|
|
42
|
+
? overlay.properties
|
|
43
|
+
: undefined;
|
|
44
|
+
if (baseProps || overlayProps) {
|
|
45
|
+
merged.properties = {
|
|
46
|
+
...(baseProps ?? {}),
|
|
47
|
+
...(overlayProps ?? {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const baseRequired = asArray(base.required);
|
|
51
|
+
const overlayRequired = asArray(overlay.required);
|
|
52
|
+
if (baseRequired.length > 0 || overlayRequired.length > 0) {
|
|
53
|
+
merged.required = [...new Set([...baseRequired, ...overlayRequired])];
|
|
54
|
+
}
|
|
55
|
+
const baseAllOf = asArray(base.allOf);
|
|
56
|
+
const overlayAllOf = asArray(overlay.allOf);
|
|
57
|
+
if (baseAllOf.length > 0 || overlayAllOf.length > 0) {
|
|
58
|
+
merged.allOf = [...baseAllOf, ...overlayAllOf];
|
|
59
|
+
}
|
|
60
|
+
return merged;
|
|
61
|
+
}
|
|
62
|
+
function resolveRefTarget(ref, currentRootSchema, schemaRefRegistry) {
|
|
63
|
+
if (ref.startsWith('#')) {
|
|
64
|
+
const pointer = ref.slice(1);
|
|
65
|
+
const rootId = typeof currentRootSchema.$id === 'string' ? currentRootSchema.$id : '(root)';
|
|
66
|
+
return {
|
|
67
|
+
schema: resolveJsonPointer(currentRootSchema, pointer, ref),
|
|
68
|
+
rootSchema: currentRootSchema,
|
|
69
|
+
refKey: `${rootId}#${pointer}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const hashIndex = ref.indexOf('#');
|
|
73
|
+
const baseId = hashIndex >= 0 ? ref.slice(0, hashIndex) : ref;
|
|
74
|
+
const pointer = hashIndex >= 0 ? ref.slice(hashIndex + 1) : '';
|
|
75
|
+
const externalSchema = schemaRefRegistry.get(baseId);
|
|
76
|
+
if (!externalSchema) {
|
|
77
|
+
throw new Error(`Cannot resolve external $ref: ${ref}`);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
schema: resolveJsonPointer(externalSchema, pointer, ref),
|
|
81
|
+
rootSchema: externalSchema,
|
|
82
|
+
refKey: `${baseId}#${pointer}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function resolveRefWithContext(def, rootSchema, schemaRefRegistry, stack) {
|
|
86
|
+
if (!def)
|
|
87
|
+
return undefined;
|
|
88
|
+
const ref = typeof def.$ref === 'string' ? def.$ref : undefined;
|
|
89
|
+
if (!ref) {
|
|
90
|
+
return { schema: def, rootSchema };
|
|
91
|
+
}
|
|
92
|
+
const target = resolveRefTarget(ref, rootSchema, schemaRefRegistry);
|
|
93
|
+
if (stack.has(target.refKey)) {
|
|
94
|
+
throw new Error(`Cyclic $ref detected: ${[...stack, target.refKey].join(' -> ')}`);
|
|
95
|
+
}
|
|
96
|
+
stack.add(target.refKey);
|
|
97
|
+
const resolvedTarget = resolveRefWithContext(target.schema, target.rootSchema, schemaRefRegistry, stack);
|
|
98
|
+
stack.delete(target.refKey);
|
|
99
|
+
if (!resolvedTarget)
|
|
100
|
+
return undefined;
|
|
101
|
+
const overlay = {};
|
|
102
|
+
let hasOverlay = false;
|
|
103
|
+
for (const [k, v] of Object.entries(def)) {
|
|
104
|
+
if (k !== '$ref') {
|
|
105
|
+
overlay[k] = v;
|
|
106
|
+
hasOverlay = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!hasOverlay) {
|
|
110
|
+
return resolvedTarget;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
schema: mergeSchemaObjects(resolvedTarget.schema, overlay),
|
|
114
|
+
rootSchema: resolvedTarget.rootSchema,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function flattenAllOf(def, rootSchema, schemaRefRegistry, stack, visited = new Set()) {
|
|
118
|
+
const resolved = resolveRefWithContext(def, rootSchema, schemaRefRegistry, stack);
|
|
119
|
+
if (!resolved)
|
|
120
|
+
return [];
|
|
121
|
+
const schemaId = typeof resolved.schema.$id === 'string' ? resolved.schema.$id : undefined;
|
|
122
|
+
if (schemaId && visited.has(schemaId)) {
|
|
123
|
+
// Cycle detected via allOf: this schema is already being processed
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
if (schemaId)
|
|
127
|
+
visited.add(schemaId);
|
|
128
|
+
const parts = [];
|
|
129
|
+
const allOf = asArray(resolved.schema.allOf);
|
|
130
|
+
for (const sub of allOf) {
|
|
131
|
+
parts.push(...flattenAllOf(sub, resolved.rootSchema, schemaRefRegistry, stack, visited));
|
|
132
|
+
}
|
|
133
|
+
if (schemaId)
|
|
134
|
+
visited.delete(schemaId);
|
|
135
|
+
const own = { ...resolved.schema };
|
|
136
|
+
delete own.allOf;
|
|
137
|
+
parts.push({ schema: own, rootSchema: resolved.rootSchema });
|
|
138
|
+
return parts;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Resolve a schema definition, following cross-file and internal refs transitively.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveRef(propDef, schema, schemaRefRegistry) {
|
|
144
|
+
return resolveRefWithContext(propDef, schema, schemaRefRegistry, new Set())?.schema;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Merge properties and required fields from allOf entries recursively across refs.
|
|
148
|
+
* allOf entries are flattened depth-first; direct properties on later fragments override earlier ones.
|
|
149
|
+
*/
|
|
150
|
+
export function mergeVariantProperties(variant, schema, schemaRefRegistry) {
|
|
151
|
+
const properties = {};
|
|
152
|
+
const requiredSet = new Set();
|
|
153
|
+
const fragments = flattenAllOf(variant, schema, schemaRefRegistry, new Set());
|
|
154
|
+
for (const fragment of fragments) {
|
|
155
|
+
const fragmentProps = isObject(fragment.schema.properties)
|
|
156
|
+
? fragment.schema.properties
|
|
157
|
+
: undefined;
|
|
158
|
+
if (fragmentProps) {
|
|
159
|
+
for (const [key, value] of Object.entries(fragmentProps)) {
|
|
160
|
+
properties[key] = resolveRef(value, fragment.rootSchema, schemaRefRegistry) ?? value;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const req of asArray(fragment.schema.required)) {
|
|
164
|
+
requiredSet.add(req);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { properties, required: [...requiredSet] };
|
|
168
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type AnySchemaObject, type ValidateFunction } from 'ajv';
|
|
2
|
+
import type { SchemaMetadata, SchemaWithMetadata } from '../types';
|
|
3
|
+
export declare const bundledSchemasDir: string;
|
|
4
|
+
export declare function readRawSchema(schemaPath: string): AnySchemaObject;
|
|
5
|
+
/**
|
|
6
|
+
* Build the full two-layer registry for a schema path:
|
|
7
|
+
* - Layer 1: bundled schemas/ dir (partials only, as fallback)
|
|
8
|
+
* - Layer 2: schema's own dir (partials + target file) — overrides layer 1
|
|
9
|
+
* Does not throw on $id collision; layer 2 silently wins.
|
|
10
|
+
*/
|
|
11
|
+
export declare function buildFullRegistry(schemaPath: string): Map<string, AnySchemaObject>;
|
|
12
|
+
export declare function createValidator(schemaPath: string): ValidateFunction;
|
|
13
|
+
export declare function resolveNodeType(type: string, typeAliases: Record<string, string> | undefined): string;
|
|
14
|
+
export interface EntityInfo {
|
|
15
|
+
type: string;
|
|
16
|
+
properties: string[];
|
|
17
|
+
required: string[];
|
|
18
|
+
}
|
|
19
|
+
export declare function extractEntityInfo(schema: SchemaWithMetadata, schemaRefRegistry: Map<string, AnySchemaObject>): EntityInfo[];
|
|
20
|
+
export declare function extractSchemaTypeNames(schema: SchemaWithMetadata, schemaRefRegistry: Map<string, AnySchemaObject>): Set<string>;
|
|
21
|
+
export declare function loadMetadata(schemaPath: string): SchemaMetadata;
|
|
22
|
+
export interface LoadedSchema {
|
|
23
|
+
schema: SchemaWithMetadata;
|
|
24
|
+
schemaRefRegistry: Map<string, AnySchemaObject>;
|
|
25
|
+
schemaValidator: ValidateFunction;
|
|
26
|
+
}
|
|
27
|
+
export declare function loadSchema(schemaPath: string): LoadedSchema;
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
import Ajv, {} from 'ajv';
|
|
6
|
+
import { JSON5 } from 'bun';
|
|
7
|
+
import { DIALECT_META_SCHEMA, METADATA_SCHEMA, SCHEMA_META_ID, } from './metadata-contract';
|
|
8
|
+
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);
|
|
12
|
+
export function readRawSchema(schemaPath) {
|
|
13
|
+
return JSON5.parse(readFileSync(resolve(schemaPath), 'utf-8'));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build a registry of all schemas in the given directory, keyed by $id.
|
|
17
|
+
* Only loads "partial" schemas (starting with _) and an optional target file.
|
|
18
|
+
*/
|
|
19
|
+
function buildSchemaRegistry(dir, targetFile) {
|
|
20
|
+
const schemaRefRegistry = new Map();
|
|
21
|
+
if (!existsSync(dir))
|
|
22
|
+
return schemaRefRegistry;
|
|
23
|
+
for (const file of readdirSync(dir)) {
|
|
24
|
+
if (!file.endsWith('.json'))
|
|
25
|
+
continue;
|
|
26
|
+
const isPartial = file.startsWith('_');
|
|
27
|
+
const isTarget = targetFile !== undefined && file === targetFile;
|
|
28
|
+
if (!isPartial && !isTarget)
|
|
29
|
+
continue;
|
|
30
|
+
const schema = readRawSchema(join(dir, file));
|
|
31
|
+
if (typeof schema.$id === 'string')
|
|
32
|
+
schemaRefRegistry.set(schema.$id, schema);
|
|
33
|
+
}
|
|
34
|
+
return schemaRefRegistry;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build the full two-layer registry for a schema path:
|
|
38
|
+
* - 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.
|
|
41
|
+
*/
|
|
42
|
+
export function buildFullRegistry(schemaPath) {
|
|
43
|
+
const absPath = resolve(schemaPath);
|
|
44
|
+
const targetFile = basename(absPath);
|
|
45
|
+
const targetDir = dirname(absPath);
|
|
46
|
+
const schemaRefRegistry = new Map();
|
|
47
|
+
// Layer 1: bundled schemas/ dir (partials only)
|
|
48
|
+
for (const [id, schema] of buildSchemaRegistry(bundledSchemasDir)) {
|
|
49
|
+
schemaRefRegistry.set(id, schema);
|
|
50
|
+
}
|
|
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);
|
|
55
|
+
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.`);
|
|
58
|
+
}
|
|
59
|
+
schemaRefRegistry.set(id, schema);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return schemaRefRegistry;
|
|
63
|
+
}
|
|
64
|
+
function compileValidator(targetSchema, schemaRefRegistry) {
|
|
65
|
+
const ajv = new Ajv();
|
|
66
|
+
ajv.addFormat('path', (value) => value.length > 0 && !value.includes('\0'));
|
|
67
|
+
ajv.addFormat('date', (value) => /^\d{4}-\d{2}-\d{2}$/.test(value));
|
|
68
|
+
ajv.addFormat('wikilink', (value) => /^\[\[.+\]\]$/.test(value));
|
|
69
|
+
ajv.addKeyword({
|
|
70
|
+
keyword: '$metadata',
|
|
71
|
+
schemaType: 'object',
|
|
72
|
+
metaSchema: METADATA_SCHEMA,
|
|
73
|
+
valid: true,
|
|
74
|
+
errors: false,
|
|
75
|
+
});
|
|
76
|
+
const metaSchema = DIALECT_META_SCHEMA;
|
|
77
|
+
ajv.addSchema(metaSchema, SCHEMA_META_ID);
|
|
78
|
+
// Register all except target schema (AJV compiles targetSchema explicitly)
|
|
79
|
+
for (const [id, schema] of schemaRefRegistry) {
|
|
80
|
+
if (id === targetSchema.$id || id === SCHEMA_META_ID)
|
|
81
|
+
continue;
|
|
82
|
+
ajv.addSchema(schema);
|
|
83
|
+
}
|
|
84
|
+
return ajv.compile(targetSchema);
|
|
85
|
+
}
|
|
86
|
+
export function createValidator(schemaPath) {
|
|
87
|
+
return compileValidator(readRawSchema(schemaPath), buildFullRegistry(schemaPath));
|
|
88
|
+
}
|
|
89
|
+
export function resolveNodeType(type, typeAliases) {
|
|
90
|
+
return typeAliases?.[type] ?? type;
|
|
91
|
+
}
|
|
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
|
+
function readTopLevelMetadata(schema) {
|
|
95
|
+
const metadata = schema.$metadata;
|
|
96
|
+
if (!isObject(metadata))
|
|
97
|
+
return undefined;
|
|
98
|
+
if (!validateMetadataContract(metadata)) {
|
|
99
|
+
const schemaId = typeof schema.$id === 'string' ? schema.$id : '(unknown schema)';
|
|
100
|
+
const errors = validateMetadataContract.errors?.map((e) => `${e.instancePath || '(root)'} ${e.message}`).join('; ') ??
|
|
101
|
+
'unknown error';
|
|
102
|
+
throw new Error(`Invalid $metadata in schema "${schemaId}": ${errors}`);
|
|
103
|
+
}
|
|
104
|
+
return metadata;
|
|
105
|
+
}
|
|
106
|
+
function resolveRefTargetForRule(ref, currentRootSchema, schemaRefRegistry) {
|
|
107
|
+
if (ref.startsWith('#')) {
|
|
108
|
+
const pointer = ref.slice(1);
|
|
109
|
+
const rootId = typeof currentRootSchema.$id === 'string' ? currentRootSchema.$id : '(root schema)';
|
|
110
|
+
return {
|
|
111
|
+
value: resolveJsonPointer(currentRootSchema, pointer, ref),
|
|
112
|
+
rootSchema: currentRootSchema,
|
|
113
|
+
refKey: `${rootId}#${pointer}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const hashIndex = ref.indexOf('#');
|
|
117
|
+
const baseId = hashIndex >= 0 ? ref.slice(0, hashIndex) : ref;
|
|
118
|
+
const pointer = hashIndex >= 0 ? ref.slice(hashIndex + 1) : '';
|
|
119
|
+
const externalSchema = schemaRefRegistry.get(baseId);
|
|
120
|
+
if (!externalSchema) {
|
|
121
|
+
throw new Error(`Cannot resolve external $ref: ${ref}`);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
value: resolveJsonPointer(externalSchema, pointer, ref),
|
|
125
|
+
rootSchema: externalSchema,
|
|
126
|
+
refKey: `${baseId}#${pointer}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
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
|
+
}
|
|
150
|
+
function collectMetadataProviders(rootSchema, schemaRefRegistry) {
|
|
151
|
+
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)
|
|
161
|
+
continue;
|
|
162
|
+
walk(referencedSchema);
|
|
163
|
+
const metadata = readTopLevelMetadata(referencedSchema);
|
|
164
|
+
if (metadata) {
|
|
165
|
+
providers.push({ schemaId, schema: referencedSchema, metadata });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
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
|
+
}
|
|
175
|
+
return providers;
|
|
176
|
+
}
|
|
177
|
+
function isRuleRefEntry(value) {
|
|
178
|
+
if (!isObject(value))
|
|
179
|
+
return false;
|
|
180
|
+
return typeof value.$ref === 'string' && value.$ref.length > 0 && Object.keys(value).length === 1;
|
|
181
|
+
}
|
|
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);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
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));
|
|
255
|
+
}
|
|
256
|
+
function extractMetadata(schema, schemaRefRegistry) {
|
|
257
|
+
const metadataProviders = collectMetadataProviders(schema, schemaRefRegistry);
|
|
258
|
+
let hierarchyProvider;
|
|
259
|
+
let mergedHierarchy;
|
|
260
|
+
const mergedAliases = {};
|
|
261
|
+
const mergedRules = new Map();
|
|
262
|
+
const mergedRelationships = [];
|
|
263
|
+
for (const provider of metadataProviders) {
|
|
264
|
+
if (provider.metadata.hierarchy) {
|
|
265
|
+
if (mergedHierarchy) {
|
|
266
|
+
throw new Error(`Multiple metadata providers define "$metadata.hierarchy": "${hierarchyProvider}" and "${provider.schemaId}". Exactly one provider is allowed.`);
|
|
267
|
+
}
|
|
268
|
+
hierarchyProvider = provider.schemaId;
|
|
269
|
+
mergedHierarchy = provider.metadata.hierarchy;
|
|
270
|
+
}
|
|
271
|
+
if (provider.metadata.aliases) {
|
|
272
|
+
Object.assign(mergedAliases, provider.metadata.aliases);
|
|
273
|
+
}
|
|
274
|
+
if (provider.metadata.relationships) {
|
|
275
|
+
mergedRelationships.push(...provider.metadata.relationships);
|
|
276
|
+
}
|
|
277
|
+
if (provider.metadata.rules) {
|
|
278
|
+
for (const entry of provider.metadata.rules) {
|
|
279
|
+
const resolvedRules = resolveRuleEntries(entry, provider, schemaRefRegistry, new Set());
|
|
280
|
+
for (const incomingRule of resolvedRules) {
|
|
281
|
+
const existingRule = mergedRules.get(incomingRule.id);
|
|
282
|
+
if (!existingRule) {
|
|
283
|
+
mergedRules.set(incomingRule.id, { providerId: provider.schemaId, rule: incomingRule });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (incomingRule.override === true) {
|
|
287
|
+
mergedRules.set(incomingRule.id, { providerId: provider.schemaId, rule: incomingRule });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (!areRulesEquivalent(existingRule.rule, incomingRule)) {
|
|
291
|
+
throw new Error(`Conflicting rule "${incomingRule.id}" found in "${existingRule.providerId}" and "${provider.schemaId}". Set "override": true on the later rule to replace it.`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const levels = mergedHierarchy?.levels.map((entry) => {
|
|
298
|
+
if (typeof entry === 'string') {
|
|
299
|
+
return { type: entry, field: 'parent', fieldOn: 'child', multiple: false, selfRef: false };
|
|
300
|
+
}
|
|
301
|
+
// If selfRefField is set, imply selfRef: true
|
|
302
|
+
const selfRef = entry.selfRefField !== undefined ? true : (entry.selfRef ?? false);
|
|
303
|
+
return {
|
|
304
|
+
type: entry.type,
|
|
305
|
+
field: entry.field ?? 'parent',
|
|
306
|
+
fieldOn: entry.fieldOn === 'parent' ? 'parent' : 'child',
|
|
307
|
+
multiple: entry.multiple ?? false,
|
|
308
|
+
selfRef,
|
|
309
|
+
selfRefField: entry.selfRefField,
|
|
310
|
+
templateFormat: entry.templateFormat,
|
|
311
|
+
matchers: entry.matchers,
|
|
312
|
+
embeddedTemplateFields: entry.embeddedTemplateFields,
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
return {
|
|
316
|
+
hierarchy: levels !== undefined
|
|
317
|
+
? {
|
|
318
|
+
levels,
|
|
319
|
+
allowSkipLevels: mergedHierarchy?.allowSkipLevels,
|
|
320
|
+
}
|
|
321
|
+
: undefined,
|
|
322
|
+
typeAliases: Object.keys(mergedAliases).length > 0 ? mergedAliases : undefined,
|
|
323
|
+
rules: mergedRules.size > 0 ? [...mergedRules.values()].map(({ rule }) => normalizeRule(rule)) : undefined,
|
|
324
|
+
relationships: mergedRelationships.length > 0
|
|
325
|
+
? mergedRelationships.map((rel) => ({
|
|
326
|
+
...rel,
|
|
327
|
+
field: rel.field ?? 'parent',
|
|
328
|
+
fieldOn: rel.fieldOn === 'parent' ? 'parent' : 'child',
|
|
329
|
+
multiple: rel.multiple ?? false,
|
|
330
|
+
}))
|
|
331
|
+
: undefined,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
export function extractEntityInfo(schema, schemaRefRegistry) {
|
|
335
|
+
if (!Array.isArray(schema.oneOf))
|
|
336
|
+
return [];
|
|
337
|
+
const result = [];
|
|
338
|
+
for (const entry of schema.oneOf) {
|
|
339
|
+
const { properties, required } = mergeVariantProperties(entry, schema, schemaRefRegistry);
|
|
340
|
+
const typeDef = properties.type;
|
|
341
|
+
if (typeDef?.const !== undefined) {
|
|
342
|
+
result.push({
|
|
343
|
+
type: String(typeDef.const),
|
|
344
|
+
properties: Object.keys(properties).filter((k) => k !== 'type'),
|
|
345
|
+
required: required.filter((r) => r !== 'type'),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
else if (Array.isArray(typeDef?.enum)) {
|
|
349
|
+
for (const t of typeDef.enum) {
|
|
350
|
+
result.push({
|
|
351
|
+
type: String(t),
|
|
352
|
+
properties: Object.keys(properties).filter((k) => k !== 'type'),
|
|
353
|
+
required: required.filter((r) => r !== 'type'),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
export function extractSchemaTypeNames(schema, schemaRefRegistry) {
|
|
361
|
+
return new Set(extractEntityInfo(schema, schemaRefRegistry).map((e) => e.type));
|
|
362
|
+
}
|
|
363
|
+
export function loadMetadata(schemaPath) {
|
|
364
|
+
return extractMetadata(readRawSchema(schemaPath), buildFullRegistry(schemaPath));
|
|
365
|
+
}
|
|
366
|
+
export function loadSchema(schemaPath) {
|
|
367
|
+
const rawSchema = readRawSchema(schemaPath);
|
|
368
|
+
const schemaRefRegistry = buildFullRegistry(schemaPath);
|
|
369
|
+
const schema = {
|
|
370
|
+
...rawSchema,
|
|
371
|
+
metadata: extractMetadata(rawSchema, schemaRefRegistry),
|
|
372
|
+
};
|
|
373
|
+
return {
|
|
374
|
+
schema,
|
|
375
|
+
schemaRefRegistry,
|
|
376
|
+
schemaValidator: compileValidator(rawSchema, schemaRefRegistry),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { GraphViolation, SchemaMetadata, SpaceNode, UnresolvedRef } from '../types';
|
|
2
|
+
export interface GraphValidationResult {
|
|
3
|
+
violations: GraphViolation[];
|
|
4
|
+
refErrors: Array<{
|
|
5
|
+
file: string;
|
|
6
|
+
parent: string;
|
|
7
|
+
error: string;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Validate graph structure using pre-collected unresolved refs from resolveGraphEdges.
|
|
12
|
+
* Assumes resolveGraphEdges has already been called to populate node.resolvedParents.
|
|
13
|
+
*/
|
|
14
|
+
export declare function validateGraph(nodes: SpaceNode[], metadata: SchemaMetadata, unresolvedRefs?: UnresolvedRef[]): GraphValidationResult;
|
|
15
|
+
/**
|
|
16
|
+
* Validate that resolved parents follow hierarchy level rules and relationship type constraints.
|
|
17
|
+
*
|
|
18
|
+
* For hierarchy edges (fieldOn:'child', source:'hierarchy'): validates level ordering.
|
|
19
|
+
* For relationship edges (fieldOn:'child', source:'relationship'): validates parent type.
|
|
20
|
+
* For fieldOn:'parent' edges: validates child type, violation attributed to the field-owner node.
|
|
21
|
+
*
|
|
22
|
+
* Assumes resolveGraphEdges has already been called to populate node.resolvedParents.
|
|
23
|
+
*/
|
|
24
|
+
export declare function validateHierarchyStructure(nodes: SpaceNode[], metadata: SchemaMetadata): GraphViolation[];
|