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,349 @@
1
+ import { dirname } from 'node:path';
2
+ import chokidar from 'chokidar';
3
+ import { getConfigSourceFiles } from '../config';
4
+ import { readSpace } from '../read/read-space';
5
+ import { bundledSchemasDir, extractEntityInfo } from '../schema/schema';
6
+ import { validateGraph } from '../schema/validate-graph';
7
+ import { validateRules } from '../schema/validate-rules';
8
+ import { buildSpaceGraph } from '../space-graph';
9
+ /**
10
+ * Format AJV errors for better readability.
11
+ * Groups related errors and extracts helpful context like allowed values.
12
+ */
13
+ export function formatErrors(errors, schema, schemaRefRegistry, nodeData) {
14
+ const formatted = [];
15
+ const seen = new Set();
16
+ // Group errors by instancePath
17
+ const byPath = new Map();
18
+ for (const err of errors) {
19
+ const path = err.instancePath || 'root';
20
+ if (!byPath.has(path)) {
21
+ byPath.set(path, []);
22
+ }
23
+ byPath.get(path).push(err);
24
+ }
25
+ for (const [path, pathErrors] of byPath) {
26
+ // Check if this is a oneOf failure at root - extract valid types from schema
27
+ const isRootOneOf = path === 'root' || path === '/type';
28
+ let hasOneOfContext = false;
29
+ if (isRootOneOf && pathErrors.length > 1) {
30
+ hasOneOfContext = Array.isArray(schema.oneOf);
31
+ if (hasOneOfContext) {
32
+ const entities = extractEntityInfo(schema, schemaRefRegistry);
33
+ const validTypes = entities.map((e) => e.type).sort();
34
+ if (validTypes.length > 0) {
35
+ const actualValue = nodeData.type;
36
+ // Only show type error if the actual type is NOT in the valid types list
37
+ if (actualValue !== undefined && !validTypes.includes(String(actualValue))) {
38
+ const message = `Invalid type "${actualValue}". Valid types are: ${validTypes.join(', ')}`;
39
+ const key = `type:${validTypes.join(',')}`;
40
+ if (!seen.has(key)) {
41
+ seen.add(key);
42
+ formatted.push({ message, dedupeKey: key });
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
48
+ // Handle individual errors
49
+ for (const err of pathErrors) {
50
+ // Skip individual type const/enum errors when in oneOf context (we handle it above)
51
+ if (hasOneOfContext &&
52
+ (err.keyword === 'const' || err.keyword === 'enum') &&
53
+ (err.instancePath === '' || err.instancePath === '/type')) {
54
+ continue;
55
+ }
56
+ const parts = err.instancePath.split('/').filter(Boolean);
57
+ const fieldName = parts.length > 0 ? parts[parts.length - 1] : 'root';
58
+ let message = err.message;
59
+ let key = `${err.instancePath}:${err.keyword}`;
60
+ // Enhance const errors
61
+ if (err.keyword === 'const' && err.params?.allowedValue !== undefined) {
62
+ const actual = err.data !== undefined ? `"${err.data}"` : 'missing value';
63
+ const expected = `"${err.params.allowedValue}"`;
64
+ message = `${fieldName}: expected ${expected}, got ${actual}`;
65
+ key = `${err.instancePath}:const:${err.params.allowedValue}`;
66
+ }
67
+ // Enhance enum errors
68
+ else if (err.keyword === 'enum' && err.params?.allowedValues && Array.isArray(err.params.allowedValues)) {
69
+ let actual = err.data !== undefined ? `"${err.data}"` : null;
70
+ // If err.data is undefined, try to get the value from nodeData using the field name
71
+ if (actual === null && fieldName !== 'root') {
72
+ const actualValue = nodeData[fieldName];
73
+ if (actualValue !== undefined) {
74
+ actual = `"${actualValue}"`;
75
+ }
76
+ }
77
+ if (actual === null) {
78
+ actual = 'missing value';
79
+ }
80
+ const allowed = err.params.allowedValues.map((v) => `"${v}"`).join(', ');
81
+ message = `${fieldName}: ${actual} is not valid. Allowed: ${allowed}`;
82
+ key = `${err.instancePath}:enum:${err.params.allowedValues.join(',')}`;
83
+ }
84
+ // Generic message with path
85
+ else {
86
+ message = `${fieldName}: ${err.message}`;
87
+ }
88
+ if (!seen.has(key)) {
89
+ seen.add(key);
90
+ formatted.push({ message, dedupeKey: key });
91
+ }
92
+ }
93
+ }
94
+ return formatted;
95
+ }
96
+ export async function validate(context, options = {}) {
97
+ const { schema, schemaRefRegistry, schemaValidator } = context;
98
+ const metadata = schema.metadata;
99
+ const readResult = await readSpace(context);
100
+ const { nodes, parseIgnored } = readResult;
101
+ const result = {
102
+ validCount: 0,
103
+ nodeErrorCount: 0,
104
+ nodeErrors: [],
105
+ refErrors: [],
106
+ duplicateErrors: [],
107
+ ruleViolations: [],
108
+ hierarchyViolations: [],
109
+ orphanCount: 0,
110
+ parseIgnored: parseIgnored || [],
111
+ };
112
+ for (const node of nodes) {
113
+ const valid = schemaValidator(node.schemaData);
114
+ if (valid) {
115
+ result.validCount++;
116
+ }
117
+ else {
118
+ result.nodeErrorCount++;
119
+ result.nodeErrors.push({
120
+ file: node.label,
121
+ errors: schemaValidator.errors || [],
122
+ nodeData: node.schemaData,
123
+ });
124
+ }
125
+ }
126
+ // Detect duplicate node keys (titles)
127
+ const titleToFiles = new Map();
128
+ for (const node of nodes) {
129
+ const title = node.title;
130
+ if (!titleToFiles.has(title)) {
131
+ titleToFiles.set(title, []);
132
+ }
133
+ titleToFiles.get(title).push(node.label);
134
+ }
135
+ for (const [title, files] of titleToFiles) {
136
+ if (files.length > 1) {
137
+ result.duplicateErrors.push({ title, files });
138
+ }
139
+ }
140
+ // Validate all hierarchy constraints (field references and structure)
141
+ const hierarchyValidation = validateGraph(nodes, metadata, readResult.unresolvedRefs);
142
+ result.refErrors.push(...hierarchyValidation.refErrors);
143
+ result.hierarchyViolations = [...hierarchyValidation.violations];
144
+ // Calculate orphan count (informational, not a validation error)
145
+ if (metadata.hierarchy) {
146
+ result.orphanCount = buildSpaceGraph(nodes, metadata.hierarchy.levels).orphans.length;
147
+ }
148
+ // Load and execute rules validation if schema defines rules
149
+ if (metadata.rules) {
150
+ result.ruleViolations = await validateRules(nodes, metadata.rules);
151
+ }
152
+ // JSON output mode
153
+ if (options.json) {
154
+ const errorsByFile = {};
155
+ const addError = (file, key, kind, message) => {
156
+ if (!errorsByFile[file])
157
+ errorsByFile[file] = {};
158
+ errorsByFile[file][key] = { kind, message };
159
+ };
160
+ for (const { file, errors: ajvErrors, nodeData } of result.nodeErrors) {
161
+ const formatted = formatErrors(ajvErrors, schema, schemaRefRegistry, nodeData);
162
+ for (const { message, dedupeKey } of formatted) {
163
+ addError(file, `schema:${dedupeKey}`, 'schema', message);
164
+ }
165
+ }
166
+ for (const { file, parent, error } of result.refErrors) {
167
+ addError(file, `broken-link:${parent}`, 'broken-link', `${parent} → ${error}`);
168
+ }
169
+ for (const { title, files } of result.duplicateErrors) {
170
+ for (const file of files) {
171
+ const others = files.filter((f) => f !== file);
172
+ addError(file, `duplicate:${title}`, 'duplicate', `Duplicate title "${title}" also exists in: ${others.join(', ')}`);
173
+ }
174
+ }
175
+ for (const v of result.ruleViolations) {
176
+ if (v.file) {
177
+ addError(v.file, `rule:${v.ruleId}`, 'rule', `[${v.ruleId}] ${v.description}`);
178
+ }
179
+ }
180
+ for (const v of result.hierarchyViolations) {
181
+ addError(v.file, `hierarchy:${v.description}`, 'hierarchy', v.description);
182
+ }
183
+ const errorCount = Object.values(errorsByFile).reduce((sum, errs) => sum + Object.keys(errs).length, 0);
184
+ console.log(JSON.stringify({
185
+ space: context.space.name,
186
+ valid: errorCount === 0,
187
+ validCount: result.validCount,
188
+ errorCount,
189
+ errors: errorsByFile,
190
+ orphanCount: result.orphanCount,
191
+ parseIgnored: result.parseIgnored,
192
+ }, null, 2));
193
+ return errorCount > 0 ? 1 : 0;
194
+ }
195
+ // Report
196
+ const reset = '\x1b[0m';
197
+ const green = '\x1b[32m';
198
+ const red = '\x1b[31m';
199
+ const yellow = '\x1b[33m';
200
+ const colorFor = (count, isWarning) => {
201
+ if (count === 0)
202
+ return green;
203
+ return isWarning ? yellow : red;
204
+ };
205
+ const fmt = (label, count, isError = false, isWarning = false) => {
206
+ let color;
207
+ if (isError) {
208
+ color = colorFor(count, isWarning);
209
+ }
210
+ else {
211
+ // For non-error items (like "Valid"), green if count > 0, red if 0
212
+ color = count > 0 ? green : red;
213
+ }
214
+ const countStr = String(count).padStart(3);
215
+ return `${label.padEnd(40)} ${color}${countStr}${reset}`;
216
+ };
217
+ console.log(`\nSpace Validation Results`);
218
+ console.log(`━`.repeat(45));
219
+ console.log('Content and structure');
220
+ console.log(fmt(' Valid', result.validCount));
221
+ console.log(fmt(' Schema validation errors', result.nodeErrorCount, true));
222
+ console.log(fmt(' Broken links', result.refErrors.length, true));
223
+ console.log(fmt(' Duplicate keys', result.duplicateErrors.length, true));
224
+ console.log(fmt(' Rule violations', result.ruleViolations.length, true));
225
+ console.log(fmt(' Hierarchy violations', result.hierarchyViolations.length, true));
226
+ console.log(fmt(' Orphans (hierarchy nodes - no parent)', result.orphanCount, true, true));
227
+ console.log(fmt(' Ignored during parsing', result.parseIgnored.length, true, true));
228
+ if (result.parseIgnored.length > 0) {
229
+ console.log(`\nIgnored during parsing:`);
230
+ for (const f of result.parseIgnored)
231
+ console.log(` ${f}`);
232
+ }
233
+ if (result.nodeErrors.length > 0) {
234
+ console.log(`\nSchema validation errors:`);
235
+ result.nodeErrors.forEach(({ file, errors, nodeData }) => {
236
+ console.log(`\n ${file}:`);
237
+ const formatted = formatErrors(errors, schema, schemaRefRegistry, nodeData);
238
+ formatted.forEach(({ message }) => {
239
+ console.log(` ${message}`);
240
+ });
241
+ });
242
+ }
243
+ if (result.refErrors.length > 0) {
244
+ console.log(`\nBroken links:`);
245
+ result.refErrors.forEach(({ file, parent, error }) => {
246
+ console.log(` ${file}: ${parent} → ${error}`);
247
+ });
248
+ }
249
+ if (result.duplicateErrors.length > 0) {
250
+ console.log(`\nDuplicate keys (same title in multiple files):`);
251
+ result.duplicateErrors.forEach(({ title, files }) => {
252
+ console.log(` "${title}":`);
253
+ for (const f of files) {
254
+ console.log(` ${f}`);
255
+ }
256
+ });
257
+ }
258
+ if (result.ruleViolations.length > 0) {
259
+ console.log(`\nRule violations:`);
260
+ // Group by category
261
+ const byCategory = new Map();
262
+ for (const v of result.ruleViolations) {
263
+ if (!byCategory.has(v.category)) {
264
+ byCategory.set(v.category, []);
265
+ }
266
+ byCategory.get(v.category).push(v);
267
+ }
268
+ // Report each category
269
+ for (const [category, violations] of byCategory) {
270
+ console.log(` ${category.toUpperCase()} (${violations.length}):`);
271
+ for (const v of violations) {
272
+ console.log(` ${v.file ? `${v.file}: ` : ''}${v.description}`);
273
+ }
274
+ }
275
+ }
276
+ if (result.hierarchyViolations.length > 0) {
277
+ console.log(`\nHierarchy violations:`);
278
+ for (const v of result.hierarchyViolations) {
279
+ console.log(` ${v.file}: ${v.description}`);
280
+ }
281
+ }
282
+ console.log(`\n`);
283
+ // Return exit code (0 for success, 1 for validation failures)
284
+ if (result.nodeErrorCount > 0 ||
285
+ result.refErrors.length > 0 ||
286
+ result.duplicateErrors.length > 0 ||
287
+ result.ruleViolations.length > 0 ||
288
+ result.hierarchyViolations.length > 0) {
289
+ return 1;
290
+ }
291
+ return 0;
292
+ }
293
+ export async function watchValidate(context) {
294
+ const spacePath = context.space.path;
295
+ const schemaPath = context.resolvedSchemaPath;
296
+ const configFiles = Array.from(getConfigSourceFiles());
297
+ const schemaDir = dirname(schemaPath);
298
+ const schemaDirs = [bundledSchemasDir];
299
+ if (schemaDir !== bundledSchemasDir) {
300
+ schemaDirs.push(schemaDir);
301
+ }
302
+ console.log(`👀 Watching for changes...`);
303
+ console.log(` Config files: ${configFiles.join(', ')}`);
304
+ console.log(` Schema dirs: ${schemaDirs.join(', ')}`);
305
+ console.log(` Space: ${spacePath}`);
306
+ console.log(` Press Ctrl+C to stop\n`);
307
+ // Save cursor position after header (for clearing later)
308
+ process.stdout.write('\x1b[s');
309
+ let exitCode = 0;
310
+ const innerValidate = async () => {
311
+ try {
312
+ exitCode = await validate(context);
313
+ }
314
+ catch (error) {
315
+ console.error(`❌ Error during validation: ${error instanceof Error ? error.message : String(error)}`);
316
+ exitCode = 2;
317
+ }
318
+ };
319
+ await innerValidate();
320
+ const watchPaths = [...configFiles, ...schemaDirs, spacePath];
321
+ const watcher = chokidar.watch(watchPaths, {
322
+ ignoreInitial: true,
323
+ awaitWriteFinish: {
324
+ stabilityThreshold: 100,
325
+ pollInterval: 50,
326
+ },
327
+ });
328
+ const handleFileChange = async (filePath, action) => {
329
+ // Restore cursor to header position and clear everything below
330
+ process.stdout.write('\x1b[u\x1b[0J');
331
+ console.log(`🔄 ${filePath} ${action}, re-validating...\n`);
332
+ await innerValidate();
333
+ };
334
+ watcher.on('add', (path) => handleFileChange(path, 'added'));
335
+ watcher.on('change', (path) => handleFileChange(path, 'changed'));
336
+ watcher.on('unlink', (path) => handleFileChange(path, 'removed'));
337
+ watcher.on('error', (error) => {
338
+ console.error(`Watcher error: ${error}`);
339
+ });
340
+ // Keep process alive
341
+ return new Promise((_, reject) => {
342
+ process.on('SIGINT', () => {
343
+ console.log('\n\n👋 Stopping watch mode...');
344
+ watcher.close();
345
+ process.exit(exitCode);
346
+ });
347
+ process.on('uncaughtException', reject);
348
+ });
349
+ }
@@ -0,0 +1,38 @@
1
+ export type SpaceConfig = {
2
+ name: string;
3
+ path: string;
4
+ schema?: string;
5
+ miroBoardId?: string;
6
+ miroFrameId?: string;
7
+ /** Plugin name → plugin config map. Overrides top-level plugins when set. */
8
+ plugins?: Record<string, Record<string, unknown>>;
9
+ /** Named filter views for this space. Keys are view names; values contain the filter expression. */
10
+ views?: Record<string, {
11
+ expression: string;
12
+ }>;
13
+ };
14
+ export type Config = {
15
+ spaces: SpaceConfig[];
16
+ schema?: string;
17
+ includeSpacesFrom?: string[];
18
+ };
19
+ /** Override the config file path used by loadConfig/updateSpaceField. */
20
+ export declare function setConfigPath(path: string | undefined): void;
21
+ /** Get all config file paths that were loaded (main config + included configs). */
22
+ export declare function getConfigSourceFiles(): Set<string>;
23
+ /**
24
+ * Get the directory of the config file that defines a given space.
25
+ */
26
+ export declare function getSpaceConfigDir(spaceName: string): string;
27
+ export declare function configPath(): string;
28
+ export declare function loadConfig(): Config;
29
+ /** Get the full space config entry by name. Throws if not found. */
30
+ export declare function getSpaceConfig(name: string, config: Config): SpaceConfig;
31
+ /** Resolve schema path: CLI arg > space-level config > global config > hardcoded default. */
32
+ export declare function resolveSchema(config: Config, space?: SpaceConfig): string;
33
+ type StringFields<T> = {
34
+ [K in keyof T]: T[K] extends string | undefined ? K : never;
35
+ }[keyof T];
36
+ /** Update a string field on a space entry and persist config. */
37
+ export declare function updateSpaceField(spaceName: string, field: StringFields<SpaceConfig>, value: string): void;
38
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,179 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import Ajv from 'ajv';
5
+ import { JSON5 } from 'bun';
6
+ import { ENV_CONFIG_VAR, XDG_CONFIG_DIR } from './constants';
7
+ import { normalizePluginName } from './plugins/util';
8
+ import { bundledSchemasDir } from './schema/schema';
9
+ const CONFIG_SCHEMA = {
10
+ type: 'object',
11
+ properties: {
12
+ spaces: {
13
+ type: 'array',
14
+ items: {
15
+ type: 'object',
16
+ properties: {
17
+ name: { type: 'string', pattern: '^[a-z0-9_-]+$' },
18
+ path: { type: 'string' },
19
+ schema: { type: 'string' },
20
+ miroBoardId: { type: 'string' },
21
+ miroFrameId: { type: 'string' },
22
+ plugins: { type: 'object', additionalProperties: { type: 'object' } },
23
+ views: {
24
+ type: 'object',
25
+ additionalProperties: {
26
+ type: 'object',
27
+ properties: { expression: { type: 'string', minLength: 1 } },
28
+ required: ['expression'],
29
+ additionalProperties: false,
30
+ },
31
+ },
32
+ },
33
+ required: ['name', 'path'],
34
+ additionalProperties: false,
35
+ },
36
+ },
37
+ schema: { type: 'string' },
38
+ includeSpacesFrom: { type: 'array', items: { type: 'string' } },
39
+ },
40
+ required: ['spaces'],
41
+ additionalProperties: false,
42
+ };
43
+ let _configPathOverride;
44
+ const _spaceSourceFiles = new Map();
45
+ /** Override the config file path used by loadConfig/updateSpaceField. */
46
+ export function setConfigPath(path) {
47
+ _configPathOverride = path;
48
+ _spaceSourceFiles.clear(); // Clear cache when config path changes
49
+ }
50
+ /** Get all config file paths that were loaded (main config + included configs). */
51
+ export function getConfigSourceFiles() {
52
+ if (_spaceSourceFiles.size === 0) {
53
+ loadConfig();
54
+ }
55
+ return new Set(_spaceSourceFiles.values());
56
+ }
57
+ /**
58
+ * Get the directory of the config file that defines a given space.
59
+ */
60
+ export function getSpaceConfigDir(spaceName) {
61
+ if (_spaceSourceFiles.size === 0) {
62
+ loadConfig();
63
+ }
64
+ const spaceConfigPath = _spaceSourceFiles.get(spaceName);
65
+ if (!spaceConfigPath)
66
+ throw new Error('Space config path not found');
67
+ return dirname(spaceConfigPath);
68
+ }
69
+ export function configPath() {
70
+ if (_configPathOverride) {
71
+ return _configPathOverride;
72
+ }
73
+ if (process.env[ENV_CONFIG_VAR]) {
74
+ return process.env[ENV_CONFIG_VAR];
75
+ }
76
+ const xdgBase = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
77
+ const xdgPath = join(xdgBase, XDG_CONFIG_DIR, 'config.json');
78
+ if (existsSync(xdgPath)) {
79
+ return xdgPath;
80
+ }
81
+ const cwdPath = join(process.cwd(), 'config.json');
82
+ if (existsSync(cwdPath)) {
83
+ return cwdPath;
84
+ }
85
+ return xdgPath;
86
+ }
87
+ function normalizePlugins(plugins) {
88
+ if (!plugins)
89
+ return undefined;
90
+ return Object.fromEntries(Object.entries(plugins).map(([name, cfg]) => [normalizePluginName(name), cfg]));
91
+ }
92
+ function isUrl(p) {
93
+ return /^https?:\/\//i.test(p);
94
+ }
95
+ function resolveRelativePaths(config, configDir) {
96
+ const rel = (p) => {
97
+ if (!p || isAbsolute(p) || isUrl(p))
98
+ return p;
99
+ return resolve(configDir, p);
100
+ };
101
+ return {
102
+ ...config,
103
+ schema: rel(config.schema),
104
+ spaces: config.spaces.map((s) => ({
105
+ ...s,
106
+ path: rel(s.path),
107
+ schema: rel(s.schema),
108
+ plugins: normalizePlugins(s.plugins),
109
+ })),
110
+ };
111
+ }
112
+ function _loadConfig(path) {
113
+ if (!existsSync(path)) {
114
+ throw new Error(`Config file not found: ${path}`);
115
+ }
116
+ const config = JSON5.parse(readFileSync(path, 'utf-8'));
117
+ const ajv = new Ajv();
118
+ const validate = ajv.compile(CONFIG_SCHEMA);
119
+ if (!validate(config)) {
120
+ throw new Error(`Invalid config in ${path}: ${JSON.stringify(validate.errors)}`);
121
+ }
122
+ return config;
123
+ }
124
+ export function loadConfig() {
125
+ const path = configPath();
126
+ if (!existsSync(path)) {
127
+ console.warn(`Config file not found: ${path}`);
128
+ return { spaces: [] };
129
+ }
130
+ const config = _loadConfig(path);
131
+ _spaceSourceFiles.clear();
132
+ // Track which spaces come from the main config file
133
+ for (const space of config.spaces) {
134
+ _spaceSourceFiles.set(space.name, path);
135
+ }
136
+ // Load includeSpacesFrom configs and merge their spaces in, with later entries taking precedence over earlier ones
137
+ if (config.includeSpacesFrom) {
138
+ for (const includePath of config.includeSpacesFrom) {
139
+ // Resolve relative to the main config file
140
+ const resolvedIncludePath = isAbsolute(includePath) ? includePath : resolve(dirname(path), includePath);
141
+ const includedConfig = resolveRelativePaths(_loadConfig(resolvedIncludePath), dirname(resolvedIncludePath));
142
+ if (includedConfig.spaces.some((s) => config.spaces.some((existing) => existing.name === s.name))) {
143
+ throw new Error(`Included config contains spaces with duplicate names: ${resolvedIncludePath}`);
144
+ }
145
+ // Track which spaces come from this included config file
146
+ for (const space of includedConfig.spaces) {
147
+ _spaceSourceFiles.set(space.name, resolvedIncludePath);
148
+ }
149
+ config.spaces.push(...includedConfig.spaces);
150
+ }
151
+ }
152
+ return resolveRelativePaths(config, dirname(resolve(path)));
153
+ }
154
+ /** Get the full space config entry by name. Throws if not found. */
155
+ export function getSpaceConfig(name, config) {
156
+ const space = config.spaces.find((s) => s.name === name);
157
+ if (!space) {
158
+ throw new Error(`Unknown space: "${name}". Check config.`);
159
+ }
160
+ return space;
161
+ }
162
+ /** Resolve schema path: CLI arg > space-level config > global config > hardcoded default. */
163
+ export function resolveSchema(config, space) {
164
+ return space?.schema ?? config.schema ?? join(bundledSchemasDir, 'general.json');
165
+ }
166
+ /** Update a string field on a space entry and persist config. */
167
+ export function updateSpaceField(spaceName, field, value) {
168
+ const sourcePath = _spaceSourceFiles.get(spaceName);
169
+ if (!sourcePath) {
170
+ throw new Error(`Space "${spaceName}" not found in any config file`);
171
+ }
172
+ const config = _loadConfig(sourcePath);
173
+ const space = config.spaces?.find((s) => s.name === spaceName);
174
+ if (!space) {
175
+ throw new Error(`Unknown space config: "${spaceName}". Check config.`);
176
+ }
177
+ space[field] = value;
178
+ writeFileSync(sourcePath, `${JSON5.stringify(config, null, 2)}\n`);
179
+ }
@@ -0,0 +1,6 @@
1
+ export declare const PACKAGE_NAME = "structured-context";
2
+ export declare const XDG_CONFIG_DIR = "structured-context";
3
+ export declare const CLI_NAME = "sctx";
4
+ export declare const PLUGIN_PREFIX = "sctx-";
5
+ export declare const ENV_CONFIG_VAR = "SCTX_CONFIG";
6
+ export declare const SCHEMA_URI_SCHEME = "sctx";
@@ -0,0 +1,6 @@
1
+ export const PACKAGE_NAME = 'structured-context';
2
+ export const XDG_CONFIG_DIR = PACKAGE_NAME;
3
+ export const CLI_NAME = 'sctx';
4
+ export const PLUGIN_PREFIX = `${CLI_NAME}-`;
5
+ export const ENV_CONFIG_VAR = 'SCTX_CONFIG';
6
+ export const SCHEMA_URI_SCHEME = CLI_NAME;
@@ -0,0 +1,23 @@
1
+ import type { SpaceNode } from '../types';
2
+ /** Edge metadata merged into each ancestor/descendant entry. */
3
+ export type EdgeMetadata = {
4
+ _field: string;
5
+ _source: 'hierarchy' | 'relationship';
6
+ _selfRef: boolean;
7
+ };
8
+ /** A flat node representation augmented with ancestor and descendant traversal arrays. */
9
+ export type AugmentedFlatNode = Record<string, unknown> & {
10
+ resolvedType: string;
11
+ resolvedParentTitles: string[];
12
+ ancestors: Array<Record<string, unknown> & EdgeMetadata>;
13
+ descendants: Array<Record<string, unknown> & EdgeMetadata>;
14
+ };
15
+ /**
16
+ * Build the augmented flat representation of a node, including pre-computed
17
+ * ancestors[] and descendants[] arrays with edge metadata.
18
+ *
19
+ * - ancestors: BFS from node via resolvedParents, nearest first, deduplicated by title.
20
+ * - descendants: BFS via childrenIndex, nearest first, deduplicated by title.
21
+ * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _selfRef).
22
+ */
23
+ export declare function augmentNode(node: SpaceNode, nodeIndex: ReadonlyMap<string, SpaceNode>, childrenIndex: ReadonlyMap<string, readonly SpaceNode[]>): AugmentedFlatNode;