structured-context 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +63 -22
  2. package/dist/api.d.ts +7 -5
  3. package/dist/api.js +4 -1
  4. package/dist/commands/diagram.d.ts +1 -0
  5. package/dist/commands/diagram.js +7 -1
  6. package/dist/commands/dump.d.ts +15 -2
  7. package/dist/commands/dump.js +18 -4
  8. package/dist/commands/render.d.ts +4 -0
  9. package/dist/commands/render.js +64 -9
  10. package/dist/commands/schemas.d.ts +2 -2
  11. package/dist/commands/schemas.js +114 -22
  12. package/dist/commands/show.d.ts +1 -0
  13. package/dist/commands/show.js +7 -1
  14. package/dist/commands/spaces.d.ts +3 -1
  15. package/dist/commands/spaces.js +11 -2
  16. package/dist/commands/validate-file.js +7 -4
  17. package/dist/commands/validate.js +38 -21
  18. package/dist/config.d.ts +18 -1
  19. package/dist/config.js +81 -10
  20. package/dist/filter/augment-nodes.d.ts +3 -1
  21. package/dist/filter/augment-nodes.js +12 -7
  22. package/dist/filter/filter-nodes.js +1 -1
  23. package/dist/index.js +39 -15
  24. package/dist/load-space-graph.d.ts +4 -2
  25. package/dist/load-space-graph.js +15 -4
  26. package/dist/plugins/loader.js +2 -2
  27. package/dist/plugins/markdown/extract-content-links.js +8 -29
  28. package/dist/plugins/markdown/index.js +8 -2
  29. package/dist/plugins/markdown/parse-embedded.d.ts +6 -0
  30. package/dist/plugins/markdown/parse-embedded.js +41 -7
  31. package/dist/plugins/markdown/read-space.js +38 -5
  32. package/dist/plugins/markdown/render-directory.d.ts +11 -0
  33. package/dist/plugins/markdown/render-directory.js +130 -0
  34. package/dist/plugins/mermaid/render-mermaid.js +2 -1
  35. package/dist/plugins/miro/sync.js +7 -6
  36. package/dist/plugins/util.d.ts +39 -1
  37. package/dist/plugins/util.js +139 -0
  38. package/dist/read/resolve-graph-edges.d.ts +1 -1
  39. package/dist/read/resolve-graph-edges.js +46 -27
  40. package/dist/read/wikilink-utils.d.ts +17 -0
  41. package/dist/read/wikilink-utils.js +40 -0
  42. package/dist/render/render.d.ts +3 -1
  43. package/dist/render/render.js +2 -2
  44. package/dist/schema/meta-schemas.d.ts +13 -0
  45. package/dist/schema/meta-schemas.js +50 -0
  46. package/dist/schema/metadata-contract.d.ts +343 -187
  47. package/dist/schema/metadata-contract.js +68 -30
  48. package/dist/schema/schema.d.ts +14 -8
  49. package/dist/schema/schema.js +277 -198
  50. package/dist/schema/validate-graph.d.ts +2 -1
  51. package/dist/schema/validate-graph.js +40 -23
  52. package/dist/schema/validate-rules.d.ts +5 -1
  53. package/dist/schema/validate-rules.js +20 -1
  54. package/dist/schemas/_knowledge_wiki.json +20 -20
  55. package/dist/schemas/_ost_strict.json +2 -2
  56. package/dist/schemas/_sctx_base.json +2 -2
  57. package/dist/schemas/_strategy_general.json +38 -38
  58. package/dist/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  59. package/dist/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
  60. package/dist/schemas/knowledge_wiki.json +10 -9
  61. package/dist/schemas/strategy_general.json +15 -14
  62. package/dist/schemas/strict_ost.json +17 -16
  63. package/dist/space-context.d.ts +3 -0
  64. package/dist/space-context.js +7 -2
  65. package/dist/space-graph.d.ts +9 -7
  66. package/dist/space-graph.js +7 -5
  67. package/dist/testing.d.ts +33 -0
  68. package/dist/testing.js +72 -0
  69. package/dist/types.d.ts +54 -13
  70. package/dist/validate.d.ts +3 -0
  71. package/dist/validate.js +19 -9
  72. package/docs/concepts.md +14 -4
  73. package/docs/config.md +8 -0
  74. package/docs/rules.md +49 -22
  75. package/docs/schemas.md +110 -35
  76. package/package.json +13 -8
  77. package/schemas/_knowledge_wiki.json +20 -20
  78. package/schemas/_ost_strict.json +2 -2
  79. package/schemas/_sctx_base.json +2 -2
  80. package/schemas/_strategy_general.json +38 -38
  81. package/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  82. package/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
  83. package/schemas/knowledge_wiki.json +10 -9
  84. package/schemas/strategy_general.json +15 -14
  85. package/schemas/strict_ost.json +17 -16
  86. package/dist/schemas/generated/_structured_context_schema_meta.json +0 -191
  87. package/schemas/generated/_structured_context_schema_meta.json +0 -191
@@ -1,13 +1,22 @@
1
1
  import { basename, resolve } from 'node:path';
2
- import { configPath, loadConfig } from '../config';
2
+ import { configPath, getConfigSourceFiles, loadConfig } from '../config';
3
3
  function renderConfigValue(v) {
4
4
  if (typeof v === 'object' && v !== null)
5
5
  return JSON.stringify(v);
6
6
  return String(v);
7
7
  }
8
- export function listSpaces() {
8
+ export function listSpaces(options = {}) {
9
9
  const path = resolve(configPath());
10
10
  const config = loadConfig();
11
+ if (options.json) {
12
+ const spaces = config.spaces.map((space) => ({
13
+ name: space.name,
14
+ path: space.path,
15
+ schema: space.schema ?? config.schema,
16
+ }));
17
+ console.log(JSON.stringify({ configFiles: [...getConfigSourceFiles()], spaces }));
18
+ return;
19
+ }
11
20
  console.log(`Config: ${path}\n`);
12
21
  for (const space of config.spaces) {
13
22
  console.log(`${space.name}`);
@@ -34,8 +34,8 @@ function printHumanReadable(result) {
34
34
  }
35
35
  if (result.errorCount > 0) {
36
36
  console.log(`\n${red}✗${reset} ${result.label} (space: ${result.space}) — ${result.errorCount} error(s)\n`);
37
- for (const { kind, message } of Object.values(result.errors)) {
38
- console.log(` [${kind}] ${message}`);
37
+ for (const issue of Object.values(result.errors)) {
38
+ console.log(` [${issueLabel(issue)}] ${issue.message}`);
39
39
  }
40
40
  }
41
41
  else {
@@ -43,9 +43,12 @@ function printHumanReadable(result) {
43
43
  }
44
44
  if (result.warningCount > 0) {
45
45
  console.log(`\n ${yellow}${result.warningCount} warning(s):${reset}`);
46
- for (const { kind, message } of Object.values(result.warnings)) {
47
- console.log(` ${yellow}[${kind}]${reset} ${message}`);
46
+ for (const issue of Object.values(result.warnings)) {
47
+ console.log(` ${yellow}[${issueLabel(issue)}]${reset} ${issue.message}`);
48
48
  }
49
49
  }
50
50
  console.log('');
51
51
  }
52
+ function issueLabel({ kind, severity }) {
53
+ return severity ? `${kind}:${severity}` : kind;
54
+ }
@@ -1,18 +1,22 @@
1
1
  import { dirname } from 'node:path';
2
2
  import chokidar from 'chokidar';
3
3
  import { getConfigSourceFiles } from '../config';
4
+ import { resolveSpacePluginSchemas } from '../plugins/util';
4
5
  import { bundledSchemasDir } from '../schema/schema';
5
6
  import { formatErrors, validateSpace } from '../validate';
6
7
  export async function validate(context, options = {}) {
7
8
  const { schema, schemaRefRegistry } = context;
8
9
  const result = await validateSpace(context);
10
+ const ruleCounts = { error: 0, warning: 0, info: 0 };
11
+ for (const v of result.ruleViolations)
12
+ ruleCounts[v.severity]++;
9
13
  // JSON output mode
10
14
  if (options.json) {
11
15
  const errorsByFile = {};
12
- const addError = (file, key, kind, message) => {
16
+ const addError = (file, key, kind, message, severity) => {
13
17
  if (!errorsByFile[file])
14
18
  errorsByFile[file] = {};
15
- errorsByFile[file][key] = { kind, message };
19
+ errorsByFile[file][key] = severity ? { kind, message, severity } : { kind, message };
16
20
  };
17
21
  for (const { file, errors: ajvErrors, nodeData } of result.nodeErrors) {
18
22
  const formatted = formatErrors(ajvErrors, schema, schemaRefRegistry, nodeData);
@@ -30,21 +34,25 @@ export async function validate(context, options = {}) {
30
34
  }
31
35
  }
32
36
  for (const v of result.ruleViolations) {
33
- if (v.file) {
34
- addError(v.file, `rule:${v.ruleId}`, 'rule', `[${v.ruleId}] ${v.description}`);
37
+ if (v.file && v.severity === 'error') {
38
+ addError(v.file, `rule:${v.ruleId}`, 'rule', `[${v.ruleId}] ${v.description}`, v.severity);
35
39
  }
36
40
  }
41
+ const globalRuleErrorCount = result.ruleViolations.filter((v) => !v.file && v.severity === 'error').length;
37
42
  for (const v of result.hierarchyViolations) {
38
43
  addError(v.file, `hierarchy:${v.description}`, 'hierarchy', v.description);
39
44
  }
40
45
  const parseErrorCount = result.parseIssues.filter((i) => i.severity === 'error').length;
41
- const errorCount = Object.values(errorsByFile).reduce((sum, errs) => sum + Object.keys(errs).length, 0) + parseErrorCount;
46
+ const errorCount = Object.values(errorsByFile).reduce((sum, errs) => sum + Object.keys(errs).length, 0) +
47
+ parseErrorCount +
48
+ globalRuleErrorCount;
42
49
  console.log(JSON.stringify({
43
50
  space: context.space.name,
44
51
  valid: errorCount === 0,
45
52
  validCount: result.validCount,
46
53
  errorCount,
47
54
  errors: errorsByFile,
55
+ ruleViolations: result.ruleViolations,
48
56
  orphanCount: result.orphans.length,
49
57
  parseIssues: result.parseIssues,
50
58
  unresolvedContentLinks: result.unresolvedContentLinks,
@@ -79,7 +87,9 @@ export async function validate(context, options = {}) {
79
87
  console.log(fmt(' Schema validation errors', result.nodeErrorCount, true));
80
88
  console.log(fmt(' Broken links', result.refErrors.length, true));
81
89
  console.log(fmt(' Duplicate keys', result.duplicateErrors.length, true));
82
- console.log(fmt(' Rule violations', result.ruleViolations.length, true));
90
+ console.log(fmt(' Rule violations (error)', ruleCounts.error, true));
91
+ console.log(fmt(' Rule violations (warning)', ruleCounts.warning, true, true));
92
+ console.log(fmt(' Rule violations (info)', ruleCounts.info, true, true));
83
93
  console.log(fmt(' Hierarchy violations', result.hierarchyViolations.length, true));
84
94
  console.log(fmt(' Orphans (hierarchy nodes - no parent)', result.orphans.length, true, true));
85
95
  console.log(fmt(' Unresolved content links', result.unresolvedContentLinks.length, true, true));
@@ -107,14 +117,16 @@ export async function validate(context, options = {}) {
107
117
  if (result.parseIssues.length > 0) {
108
118
  console.log(`\nExcluded during parsing:`);
109
119
  for (const issue of result.parseIssues) {
120
+ const loc = issue.line !== undefined ? `:${issue.line}${issue.column !== undefined ? `:${issue.column}` : ''}` : '';
110
121
  const detail = issue.message ? ` - ${issue.message}` : '';
111
- console.log(` ${issue.file}: ${issue.severity} - ${issue.type}${detail}`);
122
+ console.log(` ${issue.file}${loc}: ${issue.severity} - ${issue.type}${detail}`);
112
123
  }
113
124
  }
114
125
  if (result.nodeErrors.length > 0) {
115
126
  console.log(`\nSchema validation errors:`);
116
- result.nodeErrors.forEach(({ file, errors, nodeData }) => {
117
- console.log(`\n ${file}:`);
127
+ result.nodeErrors.forEach(({ file, line, errors, nodeData }) => {
128
+ const loc = line !== undefined ? `:${line}` : '';
129
+ console.log(`\n ${file}${loc}:`);
118
130
  const formatted = formatErrors(errors, schema, schemaRefRegistry, nodeData);
119
131
  formatted.forEach(({ message }) => {
120
132
  console.log(` ${message}`);
@@ -123,8 +135,9 @@ export async function validate(context, options = {}) {
123
135
  }
124
136
  if (result.refErrors.length > 0) {
125
137
  console.log(`\nBroken links:`);
126
- result.refErrors.forEach(({ file, parent, error }) => {
127
- console.log(` ${file}: ${parent} → ${error}`);
138
+ result.refErrors.forEach(({ file, line, parent, error }) => {
139
+ const loc = line !== undefined ? `:${line}` : '';
140
+ console.log(` ${file}${loc}: ${parent} → ${error}`);
128
141
  });
129
142
  }
130
143
  if (result.duplicateErrors.length > 0) {
@@ -138,16 +151,14 @@ export async function validate(context, options = {}) {
138
151
  }
139
152
  if (result.ruleViolations.length > 0) {
140
153
  console.log(`\nRule violations:`);
141
- const byCategory = new Map();
142
- for (const v of result.ruleViolations) {
143
- if (!byCategory.has(v.category))
144
- byCategory.set(v.category, []);
145
- byCategory.get(v.category).push(v);
146
- }
147
- for (const [category, violations] of byCategory) {
148
- console.log(` ${category.toUpperCase()} (${violations.length}):`);
154
+ const severityColor = { error: red, warning: yellow, info: reset };
155
+ for (const severity of ['error', 'warning', 'info']) {
156
+ const violations = result.ruleViolations.filter((v) => v.severity === severity);
157
+ if (violations.length === 0)
158
+ continue;
159
+ console.log(` ${severityColor[severity]}${severity.toUpperCase()}${reset} (${violations.length}):`);
149
160
  for (const v of violations) {
150
- console.log(` ${v.file ? `${v.file}: ` : ''}${v.description}`);
161
+ console.log(` ${v.file ? `${v.file}: ` : ''}${v.description} [${v.category}: ${v.ruleId}]`);
151
162
  }
152
163
  }
153
164
  }
@@ -161,7 +172,7 @@ export async function validate(context, options = {}) {
161
172
  if (result.nodeErrorCount > 0 ||
162
173
  result.refErrors.length > 0 ||
163
174
  result.duplicateErrors.length > 0 ||
164
- result.ruleViolations.length > 0 ||
175
+ ruleCounts.error > 0 ||
165
176
  result.hierarchyViolations.length > 0 ||
166
177
  parseIssueErrorCount > 0) {
167
178
  return 1;
@@ -177,6 +188,12 @@ export async function watchValidate(context) {
177
188
  if (schemaDir !== bundledSchemasDir) {
178
189
  schemaDirs.push(schemaDir);
179
190
  }
191
+ const pluginSources = resolveSpacePluginSchemas(context.space, context.config, context.configDir);
192
+ for (const ps of pluginSources) {
193
+ if (!schemaDirs.includes(ps.schemasDir)) {
194
+ schemaDirs.push(ps.schemasDir);
195
+ }
196
+ }
180
197
  console.log(`👀 Watching for changes...`);
181
198
  console.log(` Config files: ${configFiles.join(', ')}`);
182
199
  console.log(` Schema dirs: ${schemaDirs.join(', ')}`);
package/dist/config.d.ts CHANGED
@@ -28,7 +28,24 @@ export declare function configPath(): string;
28
28
  export declare function loadConfig(): Config;
29
29
  /** Get the full space config entry by name. Throws if not found. */
30
30
  export declare function getSpaceConfig(name: string, config: Config): SpaceConfig;
31
+ /**
32
+ * Resolve a schema identifier or path to an absolute file path.
33
+ *
34
+ * Precedence:
35
+ * 1. Absolute path or URL → used directly.
36
+ * 2. Explicit relative path (./... or ../...) → resolved relative to configDir.
37
+ * 3. Plugin-qualified path (e.g. "sctx-wardley-mapping/wardley_map.json" or "wardley-mapping/wardley_map.json"):
38
+ * resolved against the matching loaded plugin's schemasDir.
39
+ * 4. Simple filename without directory (e.g. "wardley_map.json" or "strategy_general.json"):
40
+ * - configDir/filename if it exists
41
+ * - plugin.schemasDir/filename for any loaded plugin with schemasDir
42
+ * - bundledSchemasDir/filename if it exists
43
+ * - fallback: configDir/filename
44
+ */
45
+ export declare function resolveSchemaPath(rawSchema: string | undefined, configDir: string, space?: SpaceConfig, config?: Config): string;
31
46
  /** Resolve schema path: CLI arg > space-level config > global config. Throws if none configured. */
32
- export declare function resolveSchema(config: Config, space?: SpaceConfig): string;
47
+ export declare function resolveSchema(config: Config, space?: SpaceConfig, options?: {
48
+ configDir?: string;
49
+ }): string;
33
50
  /** Update a string field on a space entry and persist config. When `plugin` is given, updates that field inside `space.plugins[plugin]` instead. */
34
51
  export declare function updateSpaceField(spaceName: string, field: string, value: string, plugin?: string): void;
package/dist/config.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
- import Ajv from 'ajv';
4
+ import Ajv2020 from 'ajv/dist/2020';
5
5
  import JSON5 from 'json5';
6
6
  import { ENV_CONFIG_VAR, XDG_CONFIG_DIR } from './constants';
7
- import { normalizePluginName, shortenPluginName } from './plugins/util';
7
+ import { normalizePluginName, resolvePluginSchemasDir, resolveSpacePluginSchemas, shortenPluginName, } from './plugins/util';
8
8
  import { bundledSchemasDir } from './schema/schema';
9
9
  const CONFIG_SCHEMA = {
10
10
  type: 'object',
@@ -87,13 +87,10 @@ function resolveRelativePaths(config, configDir) {
87
87
  const relSchema = (p) => {
88
88
  if (!p || isAbsolute(p) || isUrl(p))
89
89
  return p;
90
- if (!p.includes('/') && !p.includes('\\')) {
91
- const localPath = resolve(configDir, p);
92
- if (existsSync(localPath))
93
- return localPath;
94
- return join(bundledSchemasDir, p);
90
+ if (p.startsWith('./') || p.startsWith('../')) {
91
+ return resolve(configDir, p);
95
92
  }
96
- return resolve(configDir, p);
93
+ return p;
97
94
  };
98
95
  return {
99
96
  ...config,
@@ -111,7 +108,7 @@ function _loadConfig(path) {
111
108
  throw new Error(`Config file not found: ${path}`);
112
109
  }
113
110
  const config = JSON5.parse(readFileSync(path, 'utf-8'));
114
- const ajv = new Ajv();
111
+ const ajv = new Ajv2020();
115
112
  const validate = ajv.compile(CONFIG_SCHEMA);
116
113
  if (!validate(config)) {
117
114
  throw new Error(`Invalid config in ${path}: ${JSON.stringify(validate.errors)}`);
@@ -162,12 +159,86 @@ export function getSpaceConfig(name, config) {
162
159
  }
163
160
  return space;
164
161
  }
162
+ /**
163
+ * Resolve a schema identifier or path to an absolute file path.
164
+ *
165
+ * Precedence:
166
+ * 1. Absolute path or URL → used directly.
167
+ * 2. Explicit relative path (./... or ../...) → resolved relative to configDir.
168
+ * 3. Plugin-qualified path (e.g. "sctx-wardley-mapping/wardley_map.json" or "wardley-mapping/wardley_map.json"):
169
+ * resolved against the matching loaded plugin's schemasDir.
170
+ * 4. Simple filename without directory (e.g. "wardley_map.json" or "strategy_general.json"):
171
+ * - configDir/filename if it exists
172
+ * - plugin.schemasDir/filename for any loaded plugin with schemasDir
173
+ * - bundledSchemasDir/filename if it exists
174
+ * - fallback: configDir/filename
175
+ */
176
+ export function resolveSchemaPath(rawSchema, configDir, space, config) {
177
+ if (!rawSchema) {
178
+ throw new Error('No schema configured. Set "schema" in the space config or at the top level of the config file.');
179
+ }
180
+ if (isAbsolute(rawSchema) || isUrl(rawSchema)) {
181
+ return rawSchema;
182
+ }
183
+ if (rawSchema.startsWith('./') || rawSchema.startsWith('../')) {
184
+ return resolve(configDir, rawSchema);
185
+ }
186
+ if (rawSchema.includes('/') || rawSchema.includes('\\')) {
187
+ const normalizedSlash = rawSchema.replace(/\\/g, '/');
188
+ const slashIdx = normalizedSlash.indexOf('/');
189
+ const prefix = normalizedSlash.slice(0, slashIdx);
190
+ const subPath = normalizedSlash.slice(slashIdx + 1);
191
+ const pluginSchemasDir = resolvePluginSchemasDir(prefix, configDir);
192
+ if (pluginSchemasDir) {
193
+ const candidate = resolve(pluginSchemasDir, subPath);
194
+ if (existsSync(candidate)) {
195
+ return candidate;
196
+ }
197
+ }
198
+ // Check if relative to configDir exists
199
+ const localCandidate = resolve(configDir, rawSchema);
200
+ if (existsSync(localCandidate)) {
201
+ return localCandidate;
202
+ }
203
+ // If plugin schemas dir was found, return candidate under it
204
+ if (pluginSchemasDir) {
205
+ return resolve(pluginSchemasDir, subPath);
206
+ }
207
+ return localCandidate;
208
+ }
209
+ // Simple filename without directory:
210
+ // 1. configDir/filename
211
+ const localCandidate = resolve(configDir, rawSchema);
212
+ if (existsSync(localCandidate)) {
213
+ return localCandidate;
214
+ }
215
+ // 2. Plugin schemas from declared space / config plugins
216
+ if (space || config) {
217
+ const pluginSources = resolveSpacePluginSchemas(space, config ?? { spaces: [] }, configDir);
218
+ for (const source of pluginSources) {
219
+ const pluginCandidate = resolve(source.schemasDir, rawSchema);
220
+ if (existsSync(pluginCandidate)) {
221
+ return pluginCandidate;
222
+ }
223
+ }
224
+ }
225
+ // 3. Bundled schemas
226
+ const bundledCandidate = join(bundledSchemasDir, rawSchema);
227
+ if (existsSync(bundledCandidate)) {
228
+ return bundledCandidate;
229
+ }
230
+ return localCandidate;
231
+ }
165
232
  /** Resolve schema path: CLI arg > space-level config > global config. Throws if none configured. */
166
- export function resolveSchema(config, space) {
233
+ export function resolveSchema(config, space, options) {
167
234
  const schema = space?.schema ?? config.schema;
168
235
  if (!schema) {
169
236
  throw new Error('No schema configured. Set "schema" in the space config or at the top level of the config file.');
170
237
  }
238
+ const configDir = options?.configDir ?? space?.sourceDir;
239
+ if (configDir) {
240
+ return resolveSchemaPath(schema, configDir, space, config);
241
+ }
171
242
  return schema;
172
243
  }
173
244
  /** Update a string field on a space entry and persist config. When `plugin` is given, updates that field inside `space.plugins[plugin]` instead. */
@@ -3,6 +3,8 @@ import type { SpaceNode } from '../types';
3
3
  export type EdgeMetadata = {
4
4
  _field: string;
5
5
  _source: 'hierarchy' | 'relationship';
6
+ /** Hierarchy name, present only for hierarchy edges. */
7
+ _hierarchy?: string;
6
8
  _selfRef: boolean;
7
9
  };
8
10
  /** A flat node representation augmented with ancestor and descendant traversal arrays. */
@@ -18,6 +20,6 @@ export type AugmentedFlatNode = Record<string, unknown> & {
18
20
  *
19
21
  * - ancestors: BFS from node via resolvedParents, nearest first, deduplicated by title.
20
22
  * - 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).
23
+ * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _hierarchy, _selfRef).
22
24
  */
23
25
  export declare function augmentNode(node: SpaceNode, nodeIndex: ReadonlyMap<string, SpaceNode>, childrenIndex: ReadonlyMap<string, readonly SpaceNode[]>): AugmentedFlatNode;
@@ -1,3 +1,12 @@
1
+ /** Edge metadata for the resolvedParents entry connecting two nodes. */
2
+ function edgeMetadata(ref) {
3
+ return {
4
+ _field: ref.field,
5
+ _source: ref.source,
6
+ ...(ref.hierarchy !== undefined ? { _hierarchy: ref.hierarchy } : {}),
7
+ _selfRef: ref.selfRef,
8
+ };
9
+ }
1
10
  /** Flatten a SpaceNode's data fields for use in an augmented representation. */
2
11
  function flattenData(node) {
3
12
  return {
@@ -13,7 +22,7 @@ function flattenData(node) {
13
22
  *
14
23
  * - ancestors: BFS from node via resolvedParents, nearest first, deduplicated by title.
15
24
  * - descendants: BFS via childrenIndex, nearest first, deduplicated by title.
16
- * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _selfRef).
25
+ * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _hierarchy, _selfRef).
17
26
  */
18
27
  export function augmentNode(node, nodeIndex, childrenIndex) {
19
28
  const ancestors = buildAncestors(node, nodeIndex);
@@ -44,9 +53,7 @@ function buildAncestors(node, nodeIndex) {
44
53
  visited.add(title);
45
54
  result.push({
46
55
  ...flattenData(item.node),
47
- _field: item.ref.field,
48
- _source: item.ref.source,
49
- _selfRef: item.ref.selfRef,
56
+ ...edgeMetadata(item.ref),
50
57
  });
51
58
  // Continue BFS upward
52
59
  for (const ref of item.node.resolvedParents) {
@@ -78,9 +85,7 @@ function buildDescendants(node, childrenIndex) {
78
85
  visited.add(title);
79
86
  result.push({
80
87
  ...flattenData(item.childNode),
81
- _field: item.ref.field,
82
- _source: item.ref.source,
83
- _selfRef: item.ref.selfRef,
88
+ ...edgeMetadata(item.ref),
84
89
  });
85
90
  const grandchildren = childrenIndex.get(title) ?? [];
86
91
  for (const grandchild of grandchildren) {
@@ -69,5 +69,5 @@ export async function filterNodes(expression, graph) {
69
69
  else {
70
70
  matchedNodes = matched;
71
71
  }
72
- return buildSpaceGraph(matchedNodes, graph.levels);
72
+ return buildSpaceGraph(matchedNodes, graph.hierarchy);
73
73
  }
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { createRequire } from 'node:module';
3
+ import { format } from 'node:util';
3
4
  import { Command } from 'commander';
4
5
  import { diagram } from './commands/diagram';
5
6
  import { docs } from './commands/docs';
@@ -16,6 +17,11 @@ import { validateFileCommand } from './commands/validate-file';
16
17
  import { loadConfig, setConfigPath } from './config';
17
18
  import { CLI_NAME } from './constants';
18
19
  import { createSpaceContext, SpaceNotFoundError } from './space-context';
20
+ // Once node:process is loaded (vfile, via unified, loads it), Bun's console.log drops piped output
21
+ // past 64KB when the reader is slow. process.stdout.write delivers all of it before exit.
22
+ console.log = (...args) => {
23
+ process.stdout.write(`${format(...args)}\n`);
24
+ };
19
25
  export function buildSpaceContext(spaceName) {
20
26
  try {
21
27
  return createSpaceContext(spaceName, loadConfig());
@@ -45,8 +51,7 @@ program
45
51
  .argument('<path>', 'Path to the file to validate')
46
52
  .option('--json', 'Output results as JSON (machine-readable, for hooks)')
47
53
  .action(async (filePath, options) => {
48
- const exitCode = await validateFileCommand(filePath, { json: options.json });
49
- process.exit(exitCode);
54
+ process.exitCode = await validateFileCommand(filePath, { json: options.json });
50
55
  });
51
56
  program
52
57
  .command('validate')
@@ -60,8 +65,8 @@ program
60
65
  await watchValidate(context);
61
66
  }
62
67
  else {
63
- const exitCode = await validate(context, { json: options.json });
64
- process.exit(exitCode);
68
+ // exitCode rather than process.exit(): exiting straight away can cut off large piped output
69
+ process.exitCode = await validate(context, { json: options.json });
65
70
  }
66
71
  });
67
72
  program
@@ -69,19 +74,23 @@ program
69
74
  .description('Generate mermaid diagram from space')
70
75
  .argument('<space-name>', 'Space name')
71
76
  .option('--filter <filter>', 'Filter view name (from config) or inline filter expression')
77
+ .option('--hierarchy <name>', 'Hierarchy to render (default: the main hierarchy)')
72
78
  .option('-o, --output <path>', 'Output file path (default: stdout)')
73
- .action((spaceName, options) => diagram(buildSpaceContext(spaceName), options));
79
+ .action(async (spaceName, options) => diagram(buildSpaceContext(spaceName), options));
74
80
  program
75
81
  .command('show')
76
82
  .description('Print space graph as a bullet list')
77
83
  .argument('<space-name>', 'Space name')
78
84
  .option('--filter <filter>', 'Filter view name (from config) or inline filter expression')
79
- .action((spaceName, options) => show(buildSpaceContext(spaceName), options));
85
+ .option('--hierarchy <name>', 'Hierarchy to render (default: the main hierarchy)')
86
+ .action(async (spaceName, options) => show(buildSpaceContext(spaceName), options));
80
87
  program
81
88
  .command('dump')
82
89
  .description('Dump parsed space nodes as JSON')
83
90
  .argument('<space-name>', 'Space name')
84
- .action((spaceName) => dump(buildSpaceContext(spaceName)));
91
+ .option('--filter <filter>', 'Filter view name (from config) or inline filter expression')
92
+ .option('--hierarchy <name>', 'Hierarchy used to evaluate --filter (default: the main hierarchy)')
93
+ .action(async (spaceName, options) => dump(buildSpaceContext(spaceName), options));
85
94
  program
86
95
  .command('miro-sync')
87
96
  .description('Sync space to a Miro board')
@@ -89,16 +98,16 @@ program
89
98
  .option('--new-frame <title>', 'Create a new frame on the board and sync into it')
90
99
  .option('--dry-run', 'Show what would change without touching Miro')
91
100
  .option('-v, --verbose', 'Detailed output')
92
- .action((spaceName, options) => miroSyncCommand(buildSpaceContext(spaceName), options));
101
+ .action(async (spaceName, options) => miroSyncCommand(buildSpaceContext(spaceName), options));
93
102
  program
94
103
  .command('template-sync')
95
104
  .description('Sync schema examples to templates')
96
105
  .argument('<space-name>', 'Space name')
97
106
  .option('--create-missing', 'Create missing template files for schema types')
98
107
  .option('--dry-run', 'Preview changes without writing files')
99
- .action((spaceName, options) => {
108
+ .action(async (spaceName, options) => {
100
109
  const context = buildSpaceContext(spaceName);
101
- templateSync(context, options);
110
+ await templateSync(context, options);
102
111
  });
103
112
  program
104
113
  .command('plugins')
@@ -108,13 +117,14 @@ const spacesCmd = new Command('spaces').description('List configured spaces');
108
117
  spacesCmd
109
118
  .command('list', { isDefault: true })
110
119
  .description('List all configured spaces and their paths')
111
- .action(listSpaces);
120
+ .option('--json', 'Output as JSON: config files, and each space with its path and schema')
121
+ .action((options) => listSpaces(options));
112
122
  program.addCommand(spacesCmd);
113
123
  const schemasCmd = new Command('schemas').alias('schema').description('List and inspect schemas');
114
124
  schemasCmd
115
125
  .command('list', { isDefault: true })
116
126
  .description('List available schemas')
117
- .action(() => listSchemas());
127
+ .action(async () => await listSchemas());
118
128
  schemasCmd
119
129
  .command('show')
120
130
  .description('Show schema structure (or raw JSON with --raw, or Mermaid ERD with --mermaid-erd)')
@@ -122,7 +132,7 @@ schemasCmd
122
132
  .option('--space <name>', 'Resolve schema from space config')
123
133
  .option('--raw', 'Output raw JSON file content')
124
134
  .option('--mermaid-erd', 'Output Mermaid Entity Relationship Diagram')
125
- .action((file, options) => showSchema(file, {
135
+ .action(async (file, options) => await showSchema(file, {
126
136
  space: options.space,
127
137
  raw: options.raw ?? false,
128
138
  mermaidErd: options.mermaidErd ?? false,
@@ -141,11 +151,25 @@ renderCmd
141
151
  const context = spaceName ? buildSpaceContext(spaceName) : undefined;
142
152
  await renderList(context);
143
153
  });
154
+ function parseDataOptions(val, prev = {}) {
155
+ const eqIdx = val.indexOf('=');
156
+ if (eqIdx >= 0) {
157
+ prev[val.slice(0, eqIdx).trim()] = val.slice(eqIdx + 1).trim();
158
+ }
159
+ else {
160
+ prev[val.trim()] = 'true';
161
+ }
162
+ return prev;
163
+ }
144
164
  renderCmd
145
165
  .argument('<space-name>', 'Space name')
146
- .argument('<format>', 'Render format (e.g. markdown.bullets)')
166
+ .argument('<format>', 'Render format (e.g. markdown.bullets, markdown.directory)')
147
167
  .option('--filter <filter>', 'Filter view name (from config) or inline filter expression')
148
- .option('-o, --output <path>', 'Output file path (default: stdout)')
168
+ .option('--hierarchy <name>', 'Hierarchy to render (default: the main hierarchy)')
169
+ .option('-o, --output <path>', 'Output file or directory path (default: stdout)')
170
+ .option('--out <path>', 'Output directory or file path')
171
+ .option('-f, --force', 'Overwrite existing files when writing to disk')
172
+ .option('-d, --data <key=value...>', 'Format-specific key=value options', parseDataOptions)
149
173
  .action(async (spaceName, format, options) => {
150
174
  await render(buildSpaceContext(spaceName), format, options);
151
175
  });
@@ -4,13 +4,15 @@ import type { ReadSpaceResult, SpaceContext } from './types';
4
4
  export interface AssembleSpaceGraphOptions {
5
5
  /** Named view (resolved via space.views) or a raw filter DSL expression. */
6
6
  filter?: string;
7
+ /** Name of the hierarchy to structure the graph by. Defaults to the schema's main hierarchy. */
8
+ hierarchy?: string;
7
9
  /** Pre-loaded read result to assemble from, avoiding a re-read of the space. */
8
10
  readResult?: ReadSpaceResult;
9
11
  }
10
12
  /**
11
13
  * Assemble a SpaceGraph from a space context: read the space, drop nodes that
12
- * fail schema validation, build the hierarchy graph, and apply an optional
13
- * view/filter expression.
14
+ * fail schema validation, build the graph for the selected hierarchy (the main
15
+ * hierarchy by default), and apply an optional view/filter expression.
14
16
  *
15
17
  * Nodes that fail schema validation are silently excluded so the graph is
16
18
  * well-formed enough to render and traverse. This is NOT a validation pass —
@@ -4,8 +4,8 @@ import { createSpaceContext } from './space-context';
4
4
  import { buildSpaceGraph } from './space-graph';
5
5
  /**
6
6
  * Assemble a SpaceGraph from a space context: read the space, drop nodes that
7
- * fail schema validation, build the hierarchy graph, and apply an optional
8
- * view/filter expression.
7
+ * fail schema validation, build the graph for the selected hierarchy (the main
8
+ * hierarchy by default), and apply an optional view/filter expression.
9
9
  *
10
10
  * Nodes that fail schema validation are silently excluded so the graph is
11
11
  * well-formed enough to render and traverse. This is NOT a validation pass —
@@ -19,14 +19,25 @@ export async function assembleSpaceGraph(context, options = {}) {
19
19
  const { nodes: allNodes } = options.readResult ?? (await readSpace(context));
20
20
  const { schemaValidator } = context;
21
21
  const validNodes = allNodes.filter((node) => schemaValidator(node.schemaData));
22
- const levels = context.schema.metadata.hierarchy?.levels ?? [];
23
- let graph = buildSpaceGraph(validNodes, levels);
22
+ let graph = buildSpaceGraph(validNodes, selectHierarchy(context.schema.metadata, options.hierarchy));
24
23
  if (options.filter) {
25
24
  const expression = context.space.views?.[options.filter]?.expression ?? options.filter;
26
25
  graph = await filterNodes(expression, graph);
27
26
  }
28
27
  return graph;
29
28
  }
29
+ /** Resolve a hierarchy by name, or the main hierarchy when no name is given. */
30
+ function selectHierarchy(metadata, name) {
31
+ if (name === undefined)
32
+ return metadata.hierarchy;
33
+ const hierarchies = metadata.hierarchies ?? [];
34
+ const hierarchy = hierarchies.find((h) => h.name === name);
35
+ if (!hierarchy) {
36
+ const available = hierarchies.map((h) => h.name).join(', ');
37
+ throw new Error(`Unknown hierarchy: "${name}".${available ? ` Available: ${available}` : ' The schema defines no hierarchies.'}`);
38
+ }
39
+ return hierarchy;
40
+ }
30
41
  /**
31
42
  * Load a named space and return its assembled, filtered SpaceGraph.
32
43
  *
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync } from 'node:fs';
2
2
  import { dirname, isAbsolute, join, resolve } from 'node:path';
3
- import Ajv, {} from 'ajv';
3
+ import Ajv2020 from 'ajv/dist/2020';
4
4
  import { getConfigSourceFiles } from '../config';
5
5
  import { builtinPlugins } from '.';
6
6
  import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX, shortenPluginName, } from './util';
@@ -71,7 +71,7 @@ export async function discoverPlugins() {
71
71
  */
72
72
  export async function loadPlugins(pluginMap, configDir) {
73
73
  const builtinsByName = new Map(builtinPlugins.map((p) => [p.name, p]));
74
- const ajv = new Ajv();
74
+ const ajv = new Ajv2020();
75
75
  ajv.addFormat('path', (value) => value.length > 0 && !value.includes('\0'));
76
76
  const loaded = [];
77
77
  // External plugins: entries in the map that are not built-in names