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,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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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,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;
|