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.
Files changed (112) hide show
  1. package/README.md +348 -0
  2. package/dist/commands/diagram.d.ts +5 -0
  3. package/dist/commands/diagram.js +12 -0
  4. package/dist/commands/docs.d.ts +1 -0
  5. package/dist/commands/docs.js +67 -0
  6. package/dist/commands/dump.d.ts +2 -0
  7. package/dist/commands/dump.js +6 -0
  8. package/dist/commands/plugins.d.ts +1 -0
  9. package/dist/commands/plugins.js +23 -0
  10. package/dist/commands/render.d.ts +6 -0
  11. package/dist/commands/render.js +35 -0
  12. package/dist/commands/schemas.d.ts +6 -0
  13. package/dist/commands/schemas.js +268 -0
  14. package/dist/commands/show.d.ts +4 -0
  15. package/dist/commands/show.js +7 -0
  16. package/dist/commands/spaces.d.ts +1 -0
  17. package/dist/commands/spaces.js +36 -0
  18. package/dist/commands/template-sync.d.ts +3 -0
  19. package/dist/commands/template-sync.js +13 -0
  20. package/dist/commands/validate-file.d.ts +28 -0
  21. package/dist/commands/validate-file.js +133 -0
  22. package/dist/commands/validate.d.ts +16 -0
  23. package/dist/commands/validate.js +349 -0
  24. package/dist/config.d.ts +38 -0
  25. package/dist/config.js +179 -0
  26. package/dist/constants.d.ts +6 -0
  27. package/dist/constants.js +6 -0
  28. package/dist/filter/augment-nodes.d.ts +23 -0
  29. package/dist/filter/augment-nodes.js +95 -0
  30. package/dist/filter/expand-include.d.ts +62 -0
  31. package/dist/filter/expand-include.js +181 -0
  32. package/dist/filter/filter-nodes.d.ts +21 -0
  33. package/dist/filter/filter-nodes.js +73 -0
  34. package/dist/filter/parse-expression.d.ts +20 -0
  35. package/dist/filter/parse-expression.js +60 -0
  36. package/dist/index.d.ts +3 -0
  37. package/dist/index.js +161 -0
  38. package/dist/integrations/miro/cache.d.ts +21 -0
  39. package/dist/integrations/miro/cache.js +55 -0
  40. package/dist/integrations/miro/client.d.ts +99 -0
  41. package/dist/integrations/miro/client.js +118 -0
  42. package/dist/integrations/miro/layout.d.ts +28 -0
  43. package/dist/integrations/miro/layout.js +72 -0
  44. package/dist/integrations/miro/styles.d.ts +11 -0
  45. package/dist/integrations/miro/styles.js +65 -0
  46. package/dist/integrations/miro/sync.d.ts +8 -0
  47. package/dist/integrations/miro/sync.js +347 -0
  48. package/dist/plugin-api.d.ts +12 -0
  49. package/dist/plugin-api.js +7 -0
  50. package/dist/plugins/index.d.ts +3 -0
  51. package/dist/plugins/index.js +3 -0
  52. package/dist/plugins/loader.d.ts +21 -0
  53. package/dist/plugins/loader.js +104 -0
  54. package/dist/plugins/markdown/index.d.ts +48 -0
  55. package/dist/plugins/markdown/index.js +51 -0
  56. package/dist/plugins/markdown/parse-embedded.d.ts +90 -0
  57. package/dist/plugins/markdown/parse-embedded.js +663 -0
  58. package/dist/plugins/markdown/read-space.d.ts +7 -0
  59. package/dist/plugins/markdown/read-space.js +89 -0
  60. package/dist/plugins/markdown/render-bullets.d.ts +2 -0
  61. package/dist/plugins/markdown/render-bullets.js +42 -0
  62. package/dist/plugins/markdown/render-mermaid.d.ts +2 -0
  63. package/dist/plugins/markdown/render-mermaid.js +57 -0
  64. package/dist/plugins/markdown/template-sync.d.ts +16 -0
  65. package/dist/plugins/markdown/template-sync.js +294 -0
  66. package/dist/plugins/markdown/util.d.ts +19 -0
  67. package/dist/plugins/markdown/util.js +80 -0
  68. package/dist/plugins/util.d.ts +60 -0
  69. package/dist/plugins/util.js +7 -0
  70. package/dist/read/read-space.d.ts +2 -0
  71. package/dist/read/read-space.js +22 -0
  72. package/dist/read/resolve-graph-edges.d.ts +11 -0
  73. package/dist/read/resolve-graph-edges.js +201 -0
  74. package/dist/read/wikilink-utils.d.ts +16 -0
  75. package/dist/read/wikilink-utils.js +38 -0
  76. package/dist/render/registry.d.ts +13 -0
  77. package/dist/render/registry.js +22 -0
  78. package/dist/render/render.d.ts +4 -0
  79. package/dist/render/render.js +28 -0
  80. package/dist/schema/evaluate-rule.d.ts +30 -0
  81. package/dist/schema/evaluate-rule.js +82 -0
  82. package/dist/schema/metadata-contract.d.ts +538 -0
  83. package/dist/schema/metadata-contract.js +115 -0
  84. package/dist/schema/schema-refs.d.ts +22 -0
  85. package/dist/schema/schema-refs.js +168 -0
  86. package/dist/schema/schema.d.ts +27 -0
  87. package/dist/schema/schema.js +378 -0
  88. package/dist/schema/validate-graph.d.ts +24 -0
  89. package/dist/schema/validate-graph.js +141 -0
  90. package/dist/schema/validate-rules.d.ts +10 -0
  91. package/dist/schema/validate-rules.js +51 -0
  92. package/dist/schemas/_ost_strict.json +81 -0
  93. package/dist/schemas/_sctx_base.json +72 -0
  94. package/dist/schemas/general.json +261 -0
  95. package/dist/schemas/generated/_structured_context_schema_meta.json +191 -0
  96. package/dist/schemas/knowledge_wiki.json +206 -0
  97. package/dist/schemas/strict_ost.json +97 -0
  98. package/dist/space-graph.d.ts +28 -0
  99. package/dist/space-graph.js +82 -0
  100. package/dist/types.d.ts +145 -0
  101. package/dist/types.js +0 -0
  102. package/docs/concepts.md +391 -0
  103. package/docs/config.md +140 -0
  104. package/docs/rules.md +120 -0
  105. package/docs/schemas.md +340 -0
  106. package/package.json +69 -0
  107. package/schemas/_ost_strict.json +81 -0
  108. package/schemas/_sctx_base.json +72 -0
  109. package/schemas/general.json +261 -0
  110. package/schemas/generated/_structured_context_schema_meta.json +191 -0
  111. package/schemas/knowledge_wiki.json +206 -0
  112. package/schemas/strict_ost.json +97 -0
@@ -0,0 +1,268 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { loadConfig, resolveSchema } from '../config';
4
+ import { bundledSchemasDir, extractEntityInfo, loadSchema, readRawSchema } from '../schema/schema';
5
+ import { mergeVariantProperties } from '../schema/schema-refs';
6
+ function isBundledPath(schemaPath) {
7
+ return dirname(schemaPath) === bundledSchemasDir;
8
+ }
9
+ function extractRefs(obj, refs) {
10
+ if (typeof obj !== 'object' || obj === null)
11
+ return;
12
+ for (const [key, value] of Object.entries(obj)) {
13
+ if (key === '$ref' && typeof value === 'string') {
14
+ refs.add(value);
15
+ }
16
+ else {
17
+ extractRefs(value, refs);
18
+ }
19
+ }
20
+ }
21
+ function extractEntities(oneOf, schemaRefRegistry, schema) {
22
+ return oneOf.map((entry) => {
23
+ const entryObj = entry;
24
+ const { properties, required } = mergeVariantProperties(entryObj, schema, schemaRefRegistry);
25
+ const typeDef = properties.type;
26
+ let types = [];
27
+ if (typeDef?.const)
28
+ types = [String(typeDef.const)];
29
+ else if (Array.isArray(typeDef?.enum))
30
+ types = typeDef.enum.map(String);
31
+ return {
32
+ types,
33
+ description: typeof entryObj.description === 'string' ? entryObj.description : undefined,
34
+ properties: Object.keys(properties).filter((k) => k !== 'type'),
35
+ required: required.filter((r) => r !== 'type'),
36
+ };
37
+ });
38
+ }
39
+ function showEntities(oneOf, schemaRefRegistry, schema) {
40
+ const entities = extractEntities(oneOf, schemaRefRegistry, schema);
41
+ console.log('\nEntities:');
42
+ for (const { types, description, properties, required } of entities) {
43
+ const label = types.length > 0 ? types.join(', ') : '(unknown)';
44
+ if (properties.length === 0) {
45
+ console.log(` ${label}${description ? ` — ${description}` : ''}`);
46
+ }
47
+ else {
48
+ const propList = properties.map((p) => (required.includes(p) ? `${p}*` : p)).join(' ');
49
+ console.log(` ${label}${description ? ` — ${description}` : ''}`);
50
+ console.log(` ${propList}`);
51
+ }
52
+ }
53
+ }
54
+ function showDefs(defs) {
55
+ const keys = Object.keys(defs).filter((k) => !k.startsWith('_'));
56
+ if (keys.length === 0)
57
+ return;
58
+ console.log('\nDefinitions:');
59
+ for (const key of keys) {
60
+ const def = defs[key];
61
+ const props = def.properties ? Object.keys(def.properties) : [];
62
+ const desc = def.description ? ` — ${def.description}` : '';
63
+ if (props.length > 0) {
64
+ console.log(` ${key}${desc}`);
65
+ console.log(` properties: ${props.join(', ')}`);
66
+ }
67
+ else {
68
+ const typeInfo = def.type ? ` (${def.type})` : '';
69
+ const enumInfo = Array.isArray(def.enum) ? ` [${def.enum.join(', ')}]` : '';
70
+ console.log(` ${key}${typeInfo}${enumInfo}${desc}`);
71
+ }
72
+ }
73
+ }
74
+ function showMetadata(metadata) {
75
+ if (metadata.hierarchy?.levels.length) {
76
+ const parts = metadata.hierarchy.levels.map((l) => (l.selfRef ? `${l.type}(+)` : l.type));
77
+ console.log(`\nhierarchy: ${parts.join(' → ')}`);
78
+ }
79
+ else {
80
+ console.log('\nhierarchy: (none)');
81
+ }
82
+ if (metadata.typeAliases && Object.keys(metadata.typeAliases).length > 0) {
83
+ const aliasParts = Object.entries(metadata.typeAliases).map(([k, v]) => `${k} → ${v}`);
84
+ console.log(`type aliases: ${aliasParts.join(', ')}`);
85
+ }
86
+ if (metadata.rules) {
87
+ const groups = new Map();
88
+ for (const rule of metadata.rules) {
89
+ const rules = groups.get(rule.category) ?? [];
90
+ rules.push({ id: rule.id, description: rule.description });
91
+ groups.set(rule.category, rules);
92
+ }
93
+ if (groups.size > 0) {
94
+ console.log('\nRules:');
95
+ for (const [group, items] of groups) {
96
+ console.log(` ${group}:`);
97
+ for (const item of items) {
98
+ console.log(` ${item.id}: ${item.description}`);
99
+ }
100
+ }
101
+ }
102
+ }
103
+ if (metadata.relationships && metadata.relationships.length > 0) {
104
+ console.log('\nRelationships:');
105
+ for (const rel of metadata.relationships) {
106
+ const fields = rel.embeddedTemplateFields?.length ? ` [fields: ${rel.embeddedTemplateFields.join(', ')}]` : '';
107
+ const matchers = rel.matchers?.length ? ` (matches: ${rel.matchers.join(', ')})` : '';
108
+ console.log(` ${rel.parent} → ${rel.type} [${rel.templateFormat ?? 'page'}]${fields}${matchers}`);
109
+ }
110
+ }
111
+ }
112
+ function showRegistry(schemaPath, schemaRefRegistry) {
113
+ const bundledIds = new Set();
114
+ if (existsSync(bundledSchemasDir)) {
115
+ for (const file of readdirSync(bundledSchemasDir).filter((f) => f.endsWith('.json'))) {
116
+ const s = readRawSchema(join(bundledSchemasDir, file));
117
+ if (typeof s.$id === 'string')
118
+ bundledIds.add(s.$id);
119
+ }
120
+ }
121
+ console.log(`\nRegistry (${schemaPath}):`);
122
+ for (const [id] of schemaRefRegistry) {
123
+ console.log(` [${bundledIds.has(id) ? 'bundled' : 'local'}] ${id}`);
124
+ }
125
+ }
126
+ /**
127
+ * Generate Mermaid ERD from schema metadata.
128
+ * Shows entity types, properties, and parent-child relationships.
129
+ */
130
+ function generateMermaidErd(metadata, entities) {
131
+ let mmd = 'erDiagram\n';
132
+ const hierarchyLevels = metadata.hierarchy?.levels ?? [];
133
+ // Generate entity definitions with properties
134
+ for (const entity of entities) {
135
+ const safeName = entity.type.replace(/[^a-zA-Z0-9_]/g, '_');
136
+ mmd += ` ${safeName} {\n`;
137
+ mmd += ` string title PK\n`;
138
+ for (const prop of entity.properties) {
139
+ if (prop === 'title')
140
+ continue; // Skip title since it's already declared as PK
141
+ const isRequired = entity.required.includes(prop);
142
+ const optional = isRequired ? '' : ' "optional"';
143
+ mmd += ` string ${prop}${optional}\n`;
144
+ }
145
+ mmd += ' }\n';
146
+ }
147
+ // Generate relationships based on hierarchy metadata
148
+ if (hierarchyLevels.length > 0) {
149
+ for (let i = 0; i < hierarchyLevels.length - 1; i++) {
150
+ const current = hierarchyLevels[i];
151
+ const next = hierarchyLevels[i + 1];
152
+ if (!current || !next)
153
+ continue;
154
+ const currentSafe = current.type.replace(/[^a-zA-Z0-9_]/g, '_');
155
+ const nextSafe = next.type.replace(/[^a-zA-Z0-9_]/g, '_');
156
+ // Determine cardinality based on selfRef
157
+ // If next.selfRef is true, the child type can have parents of the same type
158
+ if (next.selfRef) {
159
+ mmd += ` ${currentSafe} ||--|{ ${nextSafe} : "parent"\n`;
160
+ }
161
+ else {
162
+ mmd += ` ${currentSafe} ||--o{ ${nextSafe} : "parent"\n`;
163
+ }
164
+ }
165
+ }
166
+ return mmd;
167
+ }
168
+ export function listSchemas() {
169
+ const config = loadConfig();
170
+ // List all schemas known to config
171
+ if (existsSync(bundledSchemasDir)) {
172
+ const files = readdirSync(bundledSchemasDir)
173
+ .filter((f) => f.endsWith('.json'))
174
+ .sort();
175
+ if (files.length > 0) {
176
+ console.log('Bundled schemas:');
177
+ for (const file of files) {
178
+ const schema = readRawSchema(join(bundledSchemasDir, file));
179
+ const id = typeof schema.$id === 'string' ? schema.$id : '(no $id)';
180
+ console.log(` ${file}${file.startsWith('_') ? ' [partial]' : ''} (${id})`);
181
+ }
182
+ }
183
+ }
184
+ const configured = [];
185
+ const seen = new Set();
186
+ if (config.schema && !isBundledPath(config.schema)) {
187
+ configured.push({ source: 'global', path: config.schema });
188
+ seen.add(config.schema);
189
+ }
190
+ for (const space of config.spaces) {
191
+ if (space.schema && !seen.has(space.schema) && !isBundledPath(space.schema)) {
192
+ configured.push({ source: space.name, path: space.schema });
193
+ seen.add(space.schema);
194
+ }
195
+ }
196
+ if (configured.length > 0) {
197
+ console.log('\nConfigured schemas:');
198
+ for (const { source, path } of configured) {
199
+ console.log(` ${source}: ${path}`);
200
+ }
201
+ }
202
+ }
203
+ export function showSchema(file, options) {
204
+ const config = loadConfig();
205
+ let schemaPath;
206
+ if (options.space) {
207
+ const space = config.spaces.find((s) => s.name === options.space);
208
+ if (!space) {
209
+ console.error(`Error: Unknown space "${options.space}"`);
210
+ process.exit(1);
211
+ }
212
+ schemaPath = resolveSchema(config, space);
213
+ }
214
+ else if (!file) {
215
+ console.error('Error: specify a file argument or use --space');
216
+ process.exit(1);
217
+ }
218
+ else if (file.startsWith('/') || file.startsWith('./')) {
219
+ schemaPath = file;
220
+ }
221
+ else {
222
+ schemaPath = join(bundledSchemasDir, file.endsWith('.json') ? file : `${file}.json`);
223
+ }
224
+ if (!existsSync(schemaPath)) {
225
+ console.error(`Schema not found: ${schemaPath}`);
226
+ console.error('\nHint: Use ./ or / prefix for local paths (e.g. ./schemas/my_schema.json),');
227
+ console.error(" or use --space to show a space's configured schema.");
228
+ const matchingSpace = config.spaces.find((s) => s.schema?.includes(file || ''));
229
+ if (matchingSpace) {
230
+ console.error(` Did you mean: --space ${matchingSpace.name}?`);
231
+ }
232
+ process.exit(1);
233
+ }
234
+ const content = readFileSync(schemaPath, 'utf-8');
235
+ if (options.raw) {
236
+ process.stdout.write(content);
237
+ return;
238
+ }
239
+ const { schema, schemaRefRegistry } = loadSchema(schemaPath);
240
+ // Handle --mermaid-erd: generate ERD and exit
241
+ if (options.mermaidErd) {
242
+ const entityInfo = extractEntityInfo(schema, schemaRefRegistry);
243
+ const mermaid = generateMermaidErd(schema.metadata, entityInfo);
244
+ process.stdout.write(mermaid);
245
+ return;
246
+ }
247
+ console.log(`$id: ${schema.$id ?? '(none)'}`);
248
+ if (schema.title)
249
+ console.log(`title: ${schema.title}`);
250
+ if (schema.description)
251
+ console.log(`description: ${schema.description}`);
252
+ showMetadata(schema.metadata);
253
+ if (Array.isArray(schema.oneOf)) {
254
+ showEntities(schema.oneOf, schemaRefRegistry, schema);
255
+ }
256
+ const defs = schema.$defs;
257
+ if (defs)
258
+ showDefs(defs);
259
+ const refs = new Set();
260
+ extractRefs(schema, refs);
261
+ if (refs.size > 0) {
262
+ console.log('\n$refs:');
263
+ for (const ref of [...refs].sort()) {
264
+ console.log(` ${ref}`);
265
+ }
266
+ }
267
+ showRegistry(schemaPath, schemaRefRegistry);
268
+ }
@@ -0,0 +1,4 @@
1
+ import type { SpaceContext } from '../types';
2
+ export declare function show(context: SpaceContext, options?: {
3
+ filter?: string;
4
+ }): Promise<void>;
@@ -0,0 +1,7 @@
1
+ import { executeRender } from '../render/render';
2
+ export async function show(context, options) {
3
+ const result = await executeRender('markdown.bullets', context, { filter: options?.filter });
4
+ process.stdout.write(result);
5
+ if (!result.endsWith('\n'))
6
+ process.stdout.write('\n');
7
+ }
@@ -0,0 +1 @@
1
+ export declare function listSpaces(): void;
@@ -0,0 +1,36 @@
1
+ import { basename, resolve } from 'node:path';
2
+ import { configPath, loadConfig, resolveSchema } from '../config';
3
+ function renderConfigValue(v) {
4
+ if (typeof v === 'object' && v !== null)
5
+ return JSON.stringify(v);
6
+ return String(v);
7
+ }
8
+ export function listSpaces() {
9
+ const path = resolve(configPath());
10
+ const config = loadConfig();
11
+ console.log(`Config: ${path}\n`);
12
+ for (const space of config.spaces) {
13
+ console.log(`${space.name}`);
14
+ console.log(` path: ${space.path}`);
15
+ console.log(` schema: ${basename(resolveSchema(config, space))}`);
16
+ if (space.miroBoardId)
17
+ console.log(` miro: configured`);
18
+ const plugins = space.plugins ?? {};
19
+ if (Object.keys(plugins).length > 0) {
20
+ console.log(' plugins:');
21
+ for (const [name, cfg] of Object.entries(plugins)) {
22
+ const entries = Object.entries(cfg);
23
+ if (entries.length === 0) {
24
+ console.log(` ${name}`);
25
+ }
26
+ else {
27
+ console.log(` ${name}:`);
28
+ for (const [k, v] of entries) {
29
+ console.log(` ${k}: ${renderConfigValue(v)}`);
30
+ }
31
+ }
32
+ }
33
+ }
34
+ console.log('');
35
+ }
36
+ }
@@ -0,0 +1,3 @@
1
+ import type { TemplateSyncOptions } from '../plugins/util';
2
+ import type { SpaceContext } from '../types';
3
+ export declare function templateSync(context: SpaceContext, options: TemplateSyncOptions): Promise<void>;
@@ -0,0 +1,13 @@
1
+ import { loadPlugins } from '../plugins/loader';
2
+ export async function templateSync(context, options) {
3
+ const pluginMap = context.space?.plugins ?? {};
4
+ const loaded = await loadPlugins(pluginMap, context.configDir);
5
+ for (const { plugin, pluginConfig } of loaded) {
6
+ if (!plugin.templateSync)
7
+ continue;
8
+ const result = await plugin.templateSync({ ...context, pluginConfig }, options);
9
+ if (result !== null)
10
+ return;
11
+ }
12
+ throw new Error('No plugin supports template-sync for this space');
13
+ }
@@ -0,0 +1,28 @@
1
+ export interface FileValidationResult {
2
+ file: string;
3
+ label: string;
4
+ space: string;
5
+ /** Errors keyed by composite id (e.g. `schema:/status:enum:active`, `rule:my-rule-id`). */
6
+ errors: Record<string, {
7
+ kind: string;
8
+ message: string;
9
+ }>;
10
+ errorCount: number;
11
+ inSpace: true;
12
+ }
13
+ export interface FileNotInSpaceResult {
14
+ file: string;
15
+ inSpace: false;
16
+ message: string;
17
+ }
18
+ export type ValidateFileOutput = FileValidationResult | FileNotInSpaceResult;
19
+ /**
20
+ * Validate a single file within its space.
21
+ *
22
+ * Reads the full space (required for graph correctness) but filters all reported
23
+ * errors to only those attributable to the target file. Exits 0 if the file is
24
+ * not in any configured space (not an error — hooks call this on all file writes).
25
+ */
26
+ export declare function validateFile(filePath: string, options?: {
27
+ json?: boolean;
28
+ }): Promise<number>;
@@ -0,0 +1,133 @@
1
+ import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { getSpaceConfigDir, loadConfig, resolveSchema } from '../config';
3
+ import { readSpace } from '../read/read-space';
4
+ import { loadSchema } from '../schema/schema';
5
+ import { validateGraph } from '../schema/validate-graph';
6
+ import { validateRules } from '../schema/validate-rules';
7
+ import { formatErrors } from './validate';
8
+ /** Find which space a file belongs to by checking directory containment. */
9
+ function resolveFileSpace(filePath, config) {
10
+ const absFile = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
11
+ for (const space of config.spaces) {
12
+ const absSpace = isAbsolute(space.path) ? space.path : resolve(process.cwd(), space.path);
13
+ // Trailing slash prevents prefix-match false positives (e.g. /foo matching /foobar/)
14
+ const spaceDir = absSpace.endsWith('/') ? absSpace : `${absSpace}/`;
15
+ if (absFile.startsWith(spaceDir) || absFile === absSpace) {
16
+ return { space, label: relative(absSpace, absFile) };
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ function buildContextForSpace(space, config) {
22
+ const resolvedSchemaPath = resolveSchema(config, space);
23
+ const { schema, schemaRefRegistry, schemaValidator } = loadSchema(resolvedSchemaPath);
24
+ const configDir = getSpaceConfigDir(space.name);
25
+ return { space, config, resolvedSchemaPath, schema, schemaRefRegistry, schemaValidator, configDir };
26
+ }
27
+ /**
28
+ * Validate a single file within its space.
29
+ *
30
+ * Reads the full space (required for graph correctness) but filters all reported
31
+ * errors to only those attributable to the target file. Exits 0 if the file is
32
+ * not in any configured space (not an error — hooks call this on all file writes).
33
+ */
34
+ export async function validateFile(filePath, options = {}) {
35
+ const config = loadConfig();
36
+ const resolved = resolveFileSpace(filePath, config);
37
+ if (!resolved) {
38
+ const result = {
39
+ file: filePath,
40
+ inSpace: false,
41
+ message: 'File does not belong to any configured space.',
42
+ };
43
+ if (options.json) {
44
+ console.log(JSON.stringify(result, null, 2));
45
+ }
46
+ return 0;
47
+ }
48
+ const { space, label } = resolved;
49
+ const context = buildContextForSpace(space, config);
50
+ const { schema, schemaRefRegistry, schemaValidator } = context;
51
+ const metadata = schema.metadata;
52
+ const readResult = await readSpace(context);
53
+ const { nodes } = readResult;
54
+ const errors = {};
55
+ // Schema validation errors for this node
56
+ for (const node of nodes) {
57
+ if (node.label !== label)
58
+ continue;
59
+ const valid = schemaValidator(node.schemaData);
60
+ if (!valid) {
61
+ const formatted = formatErrors(schemaValidator.errors ?? [], schema, schemaRefRegistry, node.schemaData);
62
+ for (const { message, dedupeKey } of formatted) {
63
+ errors[`schema:${dedupeKey}`] = { kind: 'schema', message };
64
+ }
65
+ }
66
+ }
67
+ // Duplicate key errors — include if this file is one of the duplicates
68
+ const titleToFiles = new Map();
69
+ for (const node of nodes) {
70
+ if (!titleToFiles.has(node.title))
71
+ titleToFiles.set(node.title, []);
72
+ titleToFiles.get(node.title).push(node.label);
73
+ }
74
+ for (const [title, files] of titleToFiles) {
75
+ if (files.length > 1 && files.includes(label)) {
76
+ const others = files.filter((f) => f !== label);
77
+ errors[`duplicate:${title}`] = {
78
+ kind: 'duplicate',
79
+ message: `Duplicate title "${title}" also exists in: ${others.join(', ')}`,
80
+ };
81
+ }
82
+ }
83
+ // Broken links and hierarchy violations from this file
84
+ const hierarchyValidation = validateGraph(nodes, metadata, readResult.unresolvedRefs);
85
+ for (const { file, parent, error } of hierarchyValidation.refErrors) {
86
+ if (file === label) {
87
+ errors[`broken-link:${parent}`] = { kind: 'broken-link', message: `${parent} → ${error}` };
88
+ }
89
+ }
90
+ for (const v of hierarchyValidation.violations) {
91
+ if (v.file === label) {
92
+ errors[`hierarchy:${v.description}`] = { kind: 'hierarchy', message: v.description };
93
+ }
94
+ }
95
+ // Rule violations for this node
96
+ if (metadata.rules) {
97
+ const ruleViolations = await validateRules(nodes, metadata.rules);
98
+ for (const v of ruleViolations) {
99
+ if (v.file === label) {
100
+ errors[`rule:${v.ruleId}`] = { kind: 'rule', message: `[${v.ruleId}] ${v.description}` };
101
+ }
102
+ }
103
+ }
104
+ const result = {
105
+ file: isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath),
106
+ label,
107
+ space: space.name,
108
+ errors,
109
+ errorCount: Object.keys(errors).length,
110
+ inSpace: true,
111
+ };
112
+ if (options.json) {
113
+ console.log(JSON.stringify(result, null, 2));
114
+ }
115
+ else {
116
+ printHumanReadable(result);
117
+ }
118
+ return Object.keys(errors).length > 0 ? 1 : 0;
119
+ }
120
+ function printHumanReadable(result) {
121
+ const reset = '\x1b[0m';
122
+ const green = '\x1b[32m';
123
+ const red = '\x1b[31m';
124
+ if (result.errorCount === 0) {
125
+ console.log(`${green}✓${reset} ${result.label} (space: ${result.space})`);
126
+ return;
127
+ }
128
+ console.log(`\n${red}✗${reset} ${result.label} (space: ${result.space}) — ${result.errorCount} error(s)\n`);
129
+ for (const { kind, message } of Object.values(result.errors)) {
130
+ console.log(` [${kind}] ${message}`);
131
+ }
132
+ console.log('');
133
+ }
@@ -0,0 +1,16 @@
1
+ import type { ErrorObject } from 'ajv';
2
+ import { extractEntityInfo } from '../schema/schema';
3
+ import type { SchemaWithMetadata, SpaceContext } from '../types';
4
+ export interface FormattedError {
5
+ message: string;
6
+ dedupeKey: string;
7
+ }
8
+ /**
9
+ * Format AJV errors for better readability.
10
+ * Groups related errors and extracts helpful context like allowed values.
11
+ */
12
+ export declare function formatErrors(errors: ErrorObject[], schema: SchemaWithMetadata, schemaRefRegistry: Parameters<typeof extractEntityInfo>[1], nodeData: Record<string, unknown>): FormattedError[];
13
+ export declare function validate(context: SpaceContext, options?: {
14
+ json?: boolean;
15
+ }): Promise<number>;
16
+ export declare function watchValidate(context: SpaceContext): Promise<never>;