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
package/README.md CHANGED
@@ -28,10 +28,13 @@ Content and structure
28
28
  Schema validation errors 0
29
29
  Broken links 1
30
30
  Duplicate keys 0
31
- Rule violations 2
31
+ Rule violations (error) 0
32
+ Rule violations (warning) 1
33
+ Rule violations (info) 1
32
34
  Hierarchy violations 0
33
35
  Orphans (hierarchy nodes - no parent) 1
34
- Ignored during parsing 0
36
+ Unresolved content links 0
37
+ Excluded during parsing 0
35
38
 
36
39
  Orphans (hierarchy nodes - no parent):
37
40
  Solution looking for a problem.md
@@ -40,13 +43,14 @@ Broken links:
40
43
  Steal underpants.md: [[Gnome goals]] → Link target "Gnome goals" in field "related" not found
41
44
 
42
45
  Rule violations:
43
- workflow (1):
44
- Build Death Star.md: Opportunities should contribute to at least one Outcome
45
-
46
- best-practice (1):
47
- High tech juice press.md: Explore multiple candidate solutions (aim for at least three) for an active target opportunity
46
+ WARNING (1):
47
+ Build Death Star.md: Opportunities should contribute to at least one Outcome [workflow: opportunity-has-outcome]
48
+ INFO (1):
49
+ High tech juice press.md: Explore multiple candidate solutions (aim for at least three) for the target opportunity [best-practice: solution-quantity]
48
50
  ```
49
51
 
52
+ Rule violations carry a severity (`error`, `warning` or `info`) that defaults from the rule's category. Only errors fail validation — see [docs/rules.md](docs/rules.md#categories-and-severity).
53
+
50
54
  ## Installation
51
55
 
52
56
  Requires [Bun](https://bun.sh) runtime.
@@ -104,7 +108,7 @@ Schemas define the structure and rules for the entities in a space, allowing cus
104
108
 
105
109
  Several schemas (`strict_ost`, `knowledge_wiki` and `general`) are included. The general (strategy) schema combines a basic vision/mission/goals hierarchy with a hierarchy loosely based on Opportunity Solution Trees. It is intentionally flexible to support rapid initial adoption. The strict OST schema has a narrower scope, and reflects Teresa Torres' specific recommendations for Opportunity Solution Trees more closely. The knowledge wiki schema defines a flatter set of types used for knowledge bases.
106
110
 
107
- structured-context schemas use a metaschema based on JSON Schema Draft-07 that adds a top-level `$metadata` block:
111
+ structured-context schemas use a metaschema based on JSON Schema 2020-12 that adds a top-level `$metadata` block:
108
112
 
109
113
  ```json5
110
114
  "$metadata": {
@@ -179,23 +183,48 @@ Bare wikilink items (`- [[Existing Node]]`) in any embedding section populate a
179
183
 
180
184
  With `fieldOn: "parent"`, embedded child nodes (parsed from a matching heading's list or table) are appended as wikilinks to the parent's `field` array, rather than receiving a `parent` field. This matches schemas where the content model naturally lists children on the parent (e.g. `activity.tasks: ["[[Task A]]"]`).
181
185
 
182
- Metadata is composable across `$ref` graphs:
183
- - `hierarchy`: multiple schema files may define a hierarchy; **last one wins** (root schema overrides partials)
184
- - `aliases` are shallow-merged (later wins)
185
- - `rules` merge by `id`; conflicts error unless the later rule sets `override: true`
186
- - `$metadata.rules` supports `$ref` imports for reusable rule packs
187
- - `relationships` are collected from all files
186
+ `$ref` reuses JSON Schema definitions for validation only; it brings no metadata. Metadata is composed only through explicit imports:
187
+
188
+ ```json5
189
+ "$metadata": { "imports": ["sctx://core/_strategy_general"] }
190
+ ```
191
+
192
+ - An import brings the whole schema's `$metadata`, and is transitive (the imported schema's own imports come too).
193
+ - Merge order: imports in order, depth-first, then the schema's own metadata last.
194
+ - One rule for every key: a duplicate rule `id`, alias, relationship (same `parent`/`type`/`field`) or hierarchy `name` is an error unless the later entry sets `override: true` (for an alias, write the value as `{ "type": "goal", "override": true }`).
195
+ - `hierarchy`: each schema file declares at most one hierarchy, and every imported hierarchy is kept as a separate named hierarchy (see [Multiple hierarchies](#multiple-hierarchies))
196
+ - Each rule's `category` supplies its default severity (`validation` → `error`, `coherence`/`workflow` → `warning`, `best-practice` → `info`); an optional `severity` field overrides it.
188
197
 
189
198
  If no file defines `hierarchy`, hierarchy-specific checks are skipped.
190
199
 
200
+ #### Multiple hierarchies
201
+
202
+ A space can have several hierarchies by importing schemas that each declare one. For example, a root schema might import a work hierarchy (`goal → task`) and a skills hierarchy (`skill_area → skill`).
203
+
204
+ - **Naming**: a hierarchy's `name` defaults to the declaring schema's file stem (e.g. `_skills` for `_skills.json`). Set `"name"` in `$metadata.hierarchy` to override it.
205
+ - **Main hierarchy**: if the root schema declares a hierarchy, it is the main one. If the root declares none and exactly one is imported, that one is the main hierarchy. If several are imported, the root must select one:
206
+
207
+ ```json5
208
+ "$metadata": {
209
+ "imports": ["sctx://_work", "sctx://_skills"],
210
+ "hierarchy": { "$ref": "sctx://_work#/$metadata/hierarchy" }
211
+ }
212
+ ```
213
+
214
+ A root may either declare a hierarchy or select one with `$ref`, not both.
215
+ - **Each type belongs to at most one hierarchy.** A type in two hierarchies is an error. To change an imported hierarchy's levels, declare a hierarchy with the same `name` and `"override": true`.
216
+ - **Validation** (layering, parent types, skip levels, orphans) runs per hierarchy. Relationships can connect types across hierarchies.
217
+ - The main hierarchy drives `space_on_a_page` parsing, hierarchy embedding, `template-sync`, and the default `show`/`diagram`/`render` output. Use `--hierarchy <name>` to render another one.
218
+
191
219
  **Customizing Schemas:**
192
220
  - **Partial schemas**: Files starting with an underscore (like `_sctx_base.json`, `_strategy_general.json`, `_knowledge_wiki.json`) are loaded and used to resolve references (using `$ref`).
193
221
  - **Partials as entity libraries**: Partials can define reusable entity types in `$defs` that composing schemas reference via `$ref`. Bundled partials like `_strategy_general` and `_knowledge_wiki` provide common entity sets for strategy and wiki content.
194
- - **Partials can carry metadata**: Unlike plain JSON Schema, partials may include `$metadata` (hierarchy, aliases, relationships, rules). This makes them self-contained units that bundle both type definitions and behavioral metadata.
195
- - **No-metadata partials**: If a partial has no `$metadata`, prefer `$schema: "http://json-schema.org/draft-07/schema#"` so it validates standalone as plain JSON Schema.
196
- - **Loading priority**: Partial schemas are loaded from both the default schema directory and the directory of your specified target schema.
222
+ - **Partials can carry metadata**: Unlike plain JSON Schema, partials may include `$metadata` (hierarchy, aliases, relationships, rules). A schema gets that metadata only by listing the partial in `$metadata.imports`.
223
+ - **No-metadata partials**: If a partial has no `$metadata`, prefer `$schema: "https://json-schema.org/draft/2020-12/schema"` so it validates standalone as plain JSON Schema.
224
+ - **Loading priority**: Partial schemas are loaded from the default schema directory, configured plugins (`schemas/`), and the directory of your specified target schema.
197
225
  - **Transitive resolution**: `$ref` chains are resolved recursively across files/schemas (including nested `allOf` usage in partials).
198
- - **Unique IDs**: To encourage clean namespacing, local partial schemas **must** have unique `$id`s that do not collide with the default schemas. If a collision is detected, validation will fail with an error.
226
+ - **`$id` namespaces**: All `$id`s use `sctx://`. Bundled schemas are `sctx://core/<name>`, plugin schemas `sctx://<pluginName>/<name>`, and local schemas a single name, `sctx://<name>`. Collisions are errors. Legacy bundled ids (`sctx://<name>`, e.g. `sctx://_sctx_base`) resolve to `sctx://core/` with a deprecation warning until 1.0. See [docs/schemas.md](docs/schemas.md#schema-id-namespaces).
227
+ - **Plugin-contributed schemas**: External plugins can contribute schemas via `<pluginRoot>/schemas/` (or `"sctx": { "schemas": "..." }` in `package.json`). Spaces can reference them using `sctx-plugin/schema.json` or `plugin/schema.json`, or by simple filename if the plugin is declared in `plugins`. Schemas resolve statically without executing plugin JavaScript code.
199
228
 
200
229
  Schema resolution order: space config `schema` > global config `schema`. Schema resolution fails if none is configured.
201
230
 
@@ -216,13 +245,24 @@ Validates markdown files against the JSON schema:
216
245
  - Skips files without frontmatter or without a `type` field
217
246
  - Reports validation results with counts and per-file errors
218
247
 
248
+ ### Render space
249
+
250
+ ```bash
251
+ sctx render <space> <format> [--out <dir-or-file>] [--force] [--filter <view>] [--hierarchy <name>] [--data key=value]
252
+ ```
253
+
254
+ Renders a space in a plugin-provided format. Use `sctx render list [space]` to list available formats.
255
+
256
+ - `markdown.bullets` — Indented bullet list tree (outputs to stdout or `--out <file>`).
257
+ - `markdown.directory` — Markdown space directory with one file per node. Writes to `--out <dir>` (refuses to overwrite existing files unless `--force` is passed). Flat directory by default, or organized into per-type subfolders with `--data folders=type` or when configured with `typeInference.folderMap`. Without `--out`, prints a summary of files to be written.
258
+
219
259
  ### Show space tree
220
260
 
221
261
  ```bash
222
- sctx show <space> [--filter <view-or-expression>]
262
+ sctx show <space> [--filter <view-or-expression>] [--hierarchy <name>]
223
263
  ```
224
264
 
225
- Prints the space as an indented hierarchy tree. Hierarchy roots are listed first, followed by orphans (nodes in the hierarchy but with no resolved parent) and non-hierarchy nodes.
265
+ Prints the space as an indented hierarchy tree. Hierarchy roots are listed first, followed by orphans (nodes in the hierarchy but with no resolved parent) and non-hierarchy nodes. The main hierarchy is shown by default; `--hierarchy <name>` selects another (see [Multiple hierarchies](#multiple-hierarchies)). `diagram` and `render` accept the same option.
226
266
 
227
267
  When a node appears under multiple parents (DAG hierarchy), it is printed in full under its first parent. Subsequent appearances with children show a `(*)` marker indicating the subtree is omitted.
228
268
 
@@ -254,6 +294,7 @@ The WHERE predicate is a [JSONata](https://docs.jsonata.org/overview) expression
254
294
  - **`ancestors[]`** — flat array of ancestor nodes, nearest first, deduplicated. Each entry includes all schema fields of the ancestor node, plus:
255
295
  - `_field` — the edge field name that connects to the ancestor
256
296
  - `_source` — `'hierarchy'` or `'relationship'`
297
+ - `_hierarchy` — the hierarchy name, for hierarchy edges
257
298
  - `_selfRef` — whether the edge is a same-type (self-referential) link
258
299
  - **`descendants[]`** — same structure, for descendant nodes
259
300
 
@@ -304,7 +345,7 @@ SELECT relationships(assumption) WHERE resolvedType='opportunity'
304
345
  ### Generate Mermaid diagram
305
346
 
306
347
  ```bash
307
- sctx diagram <space> [--output path/to/output.mmd]
348
+ sctx diagram <space> [--output path/to/output.mmd] [--hierarchy <name>]
308
349
  ```
309
350
 
310
351
  Generates a Mermaid `graph TD` diagram from validated space nodes:
@@ -321,7 +362,7 @@ sctx schemas show <schema-file> [--mermaid-erd] [--space <name>]
321
362
 
322
363
  Generates a Mermaid Entity Relationship Diagram from a schema:
323
364
  - Shows all entity types and their properties
324
- - Displays parent-child relationships based on hierarchy metadata
365
+ - Displays parent-child relationships based on the levels of each hierarchy
325
366
  - Useful for visualizing schema structure during development
326
367
 
327
368
  Example:
package/dist/api.d.ts CHANGED
@@ -19,15 +19,17 @@
19
19
  */
20
20
  export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
21
21
  export type { Config, SpaceConfig } from './config';
22
- export { loadConfig, setConfigPath } from './config';
22
+ export { loadConfig, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
23
23
  export { PLUGIN_PREFIX } from './constants';
24
24
  export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
25
- export type { ParseHook, ParseResult, PluginContext, RenderFormat, RenderHook, RenderOptions, StructuredContextPlugin, TemplateSyncHook, TemplateSyncOptions, } from './plugins/util';
25
+ export type { ParseHook, ParseResult, PluginContext, RenderFileOutput, RenderFormat, RenderHook, RenderMultiFileOutput, RenderOptions, RenderResult, StructuredContextPlugin, TemplateSyncHook, TemplateSyncOptions, } from './plugins/util';
26
+ export { isRenderMultiFileOutput, resolvePluginDir, resolvePluginSchemasDir, resolveSpacePluginSchemas, } from './plugins/util';
26
27
  export { readSpace } from './read/read-space';
28
+ export { buildTargetIndex, formatWikilink, type ParsedWikilink, parseWikilink, wikilinkToTarget, } from './read/wikilink-utils';
27
29
  export type { SharedEmbeddingFields } from './schema/metadata-contract';
28
- export { bundledSchemasDir, loadSchema, setBundledSchemasDir } from './schema/schema';
30
+ export { bundledSchemasDir, loadSchema, type PluginSchemaSource, setBundledSchemasDir } from './schema/schema';
29
31
  export { createSpaceContext, SpaceNotFoundError } from './space-context';
30
- export type { SpaceGraph } from './space-graph';
31
- export type { BaseNode, ContentLink, EdgeDefinition, FileNotInSpaceResult, FileValidationResult, HierarchyLevel, ParseIssue, ReadSpaceResult, Relationship, ResolvedContentLink, SchemaMetadata, SchemaWithMetadata, SpaceContext, SpaceNode, UnresolvedRef, ValidateFileOutput, } from './types';
32
+ export { buildSpaceGraph, type SpaceGraph } from './space-graph';
33
+ export type { BaseNode, ContentLink, EdgeDefinition, FileNotInSpaceResult, FileValidationIssue, FileValidationResult, Hierarchy, HierarchyLevel, ParseIssue, ReadSpaceResult, Relationship, ResolvedContentLink, RuleCategory, RuleSeverity, RuleViolation, SchemaMetadata, SchemaWithMetadata, SourceLocation, SpaceContext, SpaceNode, UnresolvedRef, ValidateFileOutput, } from './types';
32
34
  export type { ValidationResult } from './validate';
33
35
  export { validateFile, validateSpace } from './validate';
package/dist/api.js CHANGED
@@ -17,10 +17,13 @@
17
17
  * const graph = await assembleSpaceGraph(ctx, { readResult });
18
18
  * const validation = await validateSpace(ctx, { readResult });
19
19
  */
20
- export { loadConfig, setConfigPath } from './config';
20
+ export { loadConfig, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
21
21
  export { PLUGIN_PREFIX } from './constants';
22
22
  export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
23
+ export { isRenderMultiFileOutput, resolvePluginDir, resolvePluginSchemasDir, resolveSpacePluginSchemas, } from './plugins/util';
23
24
  export { readSpace } from './read/read-space';
25
+ export { buildTargetIndex, formatWikilink, parseWikilink, wikilinkToTarget, } from './read/wikilink-utils';
24
26
  export { bundledSchemasDir, loadSchema, setBundledSchemasDir } from './schema/schema';
25
27
  export { createSpaceContext, SpaceNotFoundError } from './space-context';
28
+ export { buildSpaceGraph } from './space-graph';
26
29
  export { validateFile, validateSpace } from './validate';
@@ -2,4 +2,5 @@ import type { SpaceContext } from '../types';
2
2
  export declare function diagram(context: SpaceContext, options: {
3
3
  output?: string;
4
4
  filter?: string;
5
+ hierarchy?: string;
5
6
  }): Promise<void>;
@@ -1,7 +1,13 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  import { executeRender } from '../render/render';
3
3
  export async function diagram(context, options) {
4
- const result = await executeRender('mermaid.graph', context, { filter: options.filter });
4
+ const result = await executeRender('mermaid.graph', context, {
5
+ filter: options.filter,
6
+ hierarchy: options.hierarchy,
7
+ });
8
+ if (typeof result !== 'string') {
9
+ throw new Error('Expected string output from mermaid diagram renderer');
10
+ }
5
11
  if (options.output) {
6
12
  writeFileSync(options.output, result);
7
13
  console.log(`Mermaid diagram written to ${options.output}`);
@@ -1,2 +1,15 @@
1
- import type { SpaceContext } from '../types';
2
- export declare function dump(context: SpaceContext): Promise<void>;
1
+ import type { ReadSpaceResult, SpaceContext } from '../types';
2
+ export interface DumpOptions {
3
+ /** Named view (resolved via space.views) or a raw filter DSL expression. */
4
+ filter?: string;
5
+ /** Hierarchy used when evaluating the filter (default: the main hierarchy). */
6
+ hierarchy?: string;
7
+ }
8
+ /**
9
+ * Build the dump payload: every parsed node, or only nodes matched by a filter.
10
+ *
11
+ * Unfiltered dumps include schema-invalid nodes, since dump is a debugging aid.
12
+ * Filtered dumps match show/render: the filter runs over the graph of schema-valid nodes.
13
+ */
14
+ export declare function buildDump(context: SpaceContext, options?: DumpOptions): Promise<ReadSpaceResult>;
15
+ export declare function dump(context: SpaceContext, options?: DumpOptions): Promise<void>;
@@ -1,6 +1,20 @@
1
- import JSON5 from 'json5';
1
+ import { assembleSpaceGraph } from '../load-space-graph';
2
2
  import { readSpace } from '../read/read-space';
3
- export async function dump(context) {
4
- const { nodes, source, diagnostics } = await readSpace(context);
5
- console.log(JSON5.stringify({ nodes, source, diagnostics }, null, 2));
3
+ /**
4
+ * Build the dump payload: every parsed node, or only nodes matched by a filter.
5
+ *
6
+ * Unfiltered dumps include schema-invalid nodes, since dump is a debugging aid.
7
+ * Filtered dumps match show/render: the filter runs over the graph of schema-valid nodes.
8
+ */
9
+ export async function buildDump(context, options = {}) {
10
+ const readResult = await readSpace(context);
11
+ if (!options.filter)
12
+ return readResult;
13
+ const graph = await assembleSpaceGraph(context, { ...options, readResult });
14
+ const nodes = readResult.nodes.filter((node) => graph.nodes.get(node.title) === node);
15
+ return { ...readResult, nodes };
16
+ }
17
+ export async function dump(context, options = {}) {
18
+ const { nodes, source, diagnostics } = await buildDump(context, options);
19
+ console.log(JSON.stringify({ nodes, source, diagnostics }, null, 2));
6
20
  }
@@ -1,6 +1,10 @@
1
1
  import type { SpaceContext } from '../types';
2
2
  export declare function render(context: SpaceContext, format: string, options: {
3
3
  filter?: string;
4
+ hierarchy?: string;
4
5
  output?: string;
6
+ out?: string;
7
+ force?: boolean;
8
+ data?: Record<string, unknown>;
5
9
  }): Promise<void>;
6
10
  export declare function renderList(context?: SpaceContext): Promise<void>;
@@ -1,17 +1,72 @@
1
- import { writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
2
3
  import { discoverPlugins, loadPlugins } from '../plugins/loader';
4
+ import { isRenderMultiFileOutput } from '../plugins/util';
3
5
  import { buildFormatRegistry } from '../render/registry';
4
6
  import { executeRender } from '../render/render';
5
7
  export async function render(context, format, options) {
6
- const result = await executeRender(format, context, { filter: options.filter });
7
- if (options.output) {
8
- writeFileSync(options.output, result);
9
- console.error(`Written to ${options.output}`);
8
+ const result = await executeRender(format, context, {
9
+ filter: options.filter,
10
+ hierarchy: options.hierarchy,
11
+ data: options.data,
12
+ });
13
+ const targetPath = options.out ?? options.output;
14
+ const force = Boolean(options.force);
15
+ if (typeof result === 'string') {
16
+ if (targetPath) {
17
+ const resolved = resolve(targetPath);
18
+ if (existsSync(resolved)) {
19
+ if (statSync(resolved).isDirectory()) {
20
+ throw new Error(`Output path "${targetPath}" is a directory. Please specify a file path.`);
21
+ }
22
+ if (!force) {
23
+ throw new Error(`File "${targetPath}" already exists. Use --force to overwrite.`);
24
+ }
25
+ }
26
+ mkdirSync(dirname(resolved), { recursive: true });
27
+ writeFileSync(resolved, result, 'utf8');
28
+ console.error(`Written to ${targetPath}`);
29
+ }
30
+ else {
31
+ process.stdout.write(result);
32
+ if (!result.endsWith('\n'))
33
+ process.stdout.write('\n');
34
+ }
35
+ return;
10
36
  }
11
- else {
12
- process.stdout.write(result);
13
- if (!result.endsWith('\n'))
14
- process.stdout.write('\n');
37
+ if (isRenderMultiFileOutput(result)) {
38
+ if (targetPath) {
39
+ const targetDir = resolve(targetPath);
40
+ const conflicts = [];
41
+ for (const file of result.files) {
42
+ const fullPath = join(targetDir, file.path);
43
+ if (existsSync(fullPath)) {
44
+ conflicts.push(file.path);
45
+ }
46
+ }
47
+ if (conflicts.length > 0 && !force) {
48
+ const preview = conflicts
49
+ .slice(0, 5)
50
+ .map((f) => ` ${f}`)
51
+ .join('\n');
52
+ const extra = conflicts.length > 5 ? `\n ... and ${conflicts.length - 5} more` : '';
53
+ throw new Error(`Refusing to overwrite ${conflicts.length} existing file(s) in "${targetPath}" without --force:\n${preview}${extra}`);
54
+ }
55
+ for (const file of result.files) {
56
+ const fullPath = join(targetDir, file.path);
57
+ mkdirSync(dirname(fullPath), { recursive: true });
58
+ writeFileSync(fullPath, file.content, 'utf8');
59
+ }
60
+ console.error(`Written ${result.files.length} file(s) to ${targetPath}`);
61
+ }
62
+ else {
63
+ console.log(`Rendered ${result.files.length} file(s):`);
64
+ for (const file of result.files) {
65
+ const bytes = Buffer.byteLength(file.content, 'utf8');
66
+ console.log(` ${file.path} (${bytes} bytes)`);
67
+ }
68
+ console.log('\nSpecify --out <dir> to write files to disk.');
69
+ }
15
70
  }
16
71
  }
17
72
  export async function renderList(context) {
@@ -1,6 +1,6 @@
1
- export declare function listSchemas(): void;
1
+ export declare function listSchemas(): Promise<void>;
2
2
  export declare function showSchema(file: string | undefined, options: {
3
3
  space?: string;
4
4
  raw: boolean;
5
5
  mermaidErd?: boolean;
6
- }): void;
6
+ }): Promise<void>;
@@ -1,8 +1,12 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
3
- import { loadConfig, resolveSchema } from '../config';
4
- import { bundledSchemasDir, extractEntityInfo, loadSchema, readRawSchema } from '../schema/schema';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { loadConfig } from '../config';
4
+ import { discoverPlugins } from '../plugins/loader';
5
+ import { normalizePluginName, resolvePluginSchemasDir, resolveSpacePluginSchemas, shortenPluginName, } from '../plugins/util';
6
+ import { bundledSchemasDir, extractEntityInfo, loadSchema, readRawSchema, } from '../schema/schema';
5
7
  import { mergeVariantProperties, resolveRef } from '../schema/schema-refs';
8
+ import { DEFAULT_SEVERITY_BY_CATEGORY } from '../schema/validate-rules';
9
+ import { createSpaceContext } from '../space-context';
6
10
  function isBundledPath(schemaPath) {
7
11
  return dirname(schemaPath) === bundledSchemasDir;
8
12
  }
@@ -73,9 +77,14 @@ function showDefs(defs) {
73
77
  }
74
78
  }
75
79
  function showMetadata(metadata) {
76
- if (metadata.hierarchy?.levels.length) {
77
- const parts = metadata.hierarchy.levels.map((l) => (l.selfRef ? `${l.type}(+)` : l.type));
78
- console.log(`\nhierarchy: ${parts.join(' → ')}`);
80
+ const hierarchies = metadata.hierarchies ?? [];
81
+ if (hierarchies.length > 0) {
82
+ console.log('');
83
+ for (const hierarchy of hierarchies) {
84
+ const parts = hierarchy.levels.map((l) => (l.selfRef ? `${l.type}(+)` : l.type));
85
+ const main = hierarchy === metadata.hierarchy ? ' (main)' : '';
86
+ console.log(`hierarchy "${hierarchy.name}"${main}: ${parts.join(' → ')}`);
87
+ }
79
88
  }
80
89
  else {
81
90
  console.log('\nhierarchy: (none)');
@@ -88,15 +97,16 @@ function showMetadata(metadata) {
88
97
  const groups = new Map();
89
98
  for (const rule of metadata.rules) {
90
99
  const rules = groups.get(rule.category) ?? [];
91
- rules.push({ id: rule.id, description: rule.description });
100
+ rules.push(rule);
92
101
  groups.set(rule.category, rules);
93
102
  }
94
103
  if (groups.size > 0) {
95
104
  console.log('\nRules:');
96
- for (const [group, items] of groups) {
97
- console.log(` ${group}:`);
98
- for (const item of items) {
99
- console.log(` ${item.id}: ${item.description}`);
105
+ for (const [category, rules] of groups) {
106
+ console.log(` ${category} (default severity: ${DEFAULT_SEVERITY_BY_CATEGORY[category]}):`);
107
+ for (const rule of rules) {
108
+ const override = rule.severity ? ` [severity: ${rule.severity}]` : '';
109
+ console.log(` ${rule.id}: ${rule.description}${override}`);
100
110
  }
101
111
  }
102
112
  }
@@ -110,7 +120,7 @@ function showMetadata(metadata) {
110
120
  }
111
121
  }
112
122
  }
113
- function showRegistry(schemaPath, schemaRefRegistry) {
123
+ function showRegistry(schemaPath, schemaRefRegistry, pluginSchemas) {
114
124
  const bundledIds = new Set();
115
125
  if (existsSync(bundledSchemasDir)) {
116
126
  for (const file of readdirSync(bundledSchemasDir).filter((f) => f.endsWith('.json'))) {
@@ -119,9 +129,22 @@ function showRegistry(schemaPath, schemaRefRegistry) {
119
129
  bundledIds.add(s.$id);
120
130
  }
121
131
  }
132
+ const pluginIds = new Set();
133
+ if (pluginSchemas) {
134
+ for (const { schemasDir } of pluginSchemas) {
135
+ if (!existsSync(schemasDir))
136
+ continue;
137
+ for (const file of readdirSync(schemasDir).filter((f) => f.endsWith('.json'))) {
138
+ const s = readRawSchema(join(schemasDir, file));
139
+ if (typeof s.$id === 'string')
140
+ pluginIds.add(s.$id);
141
+ }
142
+ }
143
+ }
122
144
  console.log(`\nRegistry (${schemaPath}):`);
123
145
  for (const [id] of schemaRefRegistry) {
124
- console.log(` [${bundledIds.has(id) ? 'bundled' : 'local'}] ${id}`);
146
+ const origin = bundledIds.has(id) ? 'bundled' : pluginIds.has(id) ? 'plugin' : 'local';
147
+ console.log(` [${origin}] ${id}`);
125
148
  }
126
149
  }
127
150
  /**
@@ -130,7 +153,6 @@ function showRegistry(schemaPath, schemaRefRegistry) {
130
153
  */
131
154
  function generateMermaidErd(metadata, entities) {
132
155
  let mmd = 'erDiagram\n';
133
- const hierarchyLevels = metadata.hierarchy?.levels ?? [];
134
156
  // Generate entity definitions with properties
135
157
  for (const entity of entities) {
136
158
  const safeName = entity.type.replace(/[^a-zA-Z0-9_]/g, '_');
@@ -145,8 +167,8 @@ function generateMermaidErd(metadata, entities) {
145
167
  }
146
168
  mmd += ' }\n';
147
169
  }
148
- // Generate relationships based on hierarchy metadata
149
- if (hierarchyLevels.length > 0) {
170
+ // Generate relationships based on the levels of each hierarchy
171
+ for (const { levels: hierarchyLevels } of metadata.hierarchies ?? []) {
150
172
  for (let i = 0; i < hierarchyLevels.length - 1; i++) {
151
173
  const current = hierarchyLevels[i];
152
174
  const next = hierarchyLevels[i + 1];
@@ -166,7 +188,7 @@ function generateMermaidErd(metadata, entities) {
166
188
  }
167
189
  return mmd;
168
190
  }
169
- export function listSchemas() {
191
+ export async function listSchemas() {
170
192
  const config = loadConfig();
171
193
  // List all schemas known to config
172
194
  if (existsSync(bundledSchemasDir)) {
@@ -182,6 +204,23 @@ export function listSchemas() {
182
204
  }
183
205
  }
184
206
  }
207
+ // List schemas provided by discovered plugins
208
+ const plugins = await discoverPlugins();
209
+ for (const p of plugins) {
210
+ if (p.schemasDir && existsSync(p.schemasDir)) {
211
+ const files = readdirSync(p.schemasDir)
212
+ .filter((f) => f.endsWith('.json'))
213
+ .sort();
214
+ if (files.length > 0) {
215
+ console.log(`\nPlugin schemas (${p.name}):`);
216
+ for (const file of files) {
217
+ const schema = readRawSchema(join(p.schemasDir, file));
218
+ const id = typeof schema.$id === 'string' ? schema.$id : '(no $id)';
219
+ console.log(` ${file}${file.startsWith('_') ? ' [partial]' : ''} (${id})`);
220
+ }
221
+ }
222
+ }
223
+ }
185
224
  const configured = [];
186
225
  const seen = new Set();
187
226
  if (config.schema && !isBundledPath(config.schema)) {
@@ -201,16 +240,23 @@ export function listSchemas() {
201
240
  }
202
241
  }
203
242
  }
204
- export function showSchema(file, options) {
243
+ export async function showSchema(file, options) {
205
244
  const config = loadConfig();
206
245
  let schemaPath;
246
+ let schema;
247
+ let schemaRefRegistry;
248
+ let pluginSchemas;
207
249
  if (options.space) {
208
250
  const space = config.spaces.find((s) => s.name === options.space);
209
251
  if (!space) {
210
252
  console.error(`Error: Unknown space "${options.space}"`);
211
253
  process.exit(1);
212
254
  }
213
- schemaPath = resolveSchema(config, space);
255
+ const context = createSpaceContext(options.space, config);
256
+ schemaPath = context.resolvedSchemaPath;
257
+ schema = context.schema;
258
+ schemaRefRegistry = context.schemaRefRegistry;
259
+ pluginSchemas = resolveSpacePluginSchemas(context.space, config, context.configDir);
214
260
  }
215
261
  else if (!file) {
216
262
  console.error('Error: specify a file argument or use --space');
@@ -220,7 +266,49 @@ export function showSchema(file, options) {
220
266
  schemaPath = file;
221
267
  }
222
268
  else {
223
- schemaPath = join(bundledSchemasDir, file.endsWith('.json') ? file : `${file}.json`);
269
+ // Check discovered plugins
270
+ const discovered = await discoverPlugins();
271
+ pluginSchemas = discovered
272
+ .filter((p) => p.schemasDir)
273
+ .map((p) => ({ pluginName: shortenPluginName(p.name), schemasDir: p.schemasDir }));
274
+ let candidatePath;
275
+ if (file.includes('/')) {
276
+ const slashIdx = file.indexOf('/');
277
+ const prefix = file.slice(0, slashIdx);
278
+ const subPath = file.slice(slashIdx + 1);
279
+ const norm = normalizePluginName(prefix);
280
+ const short = shortenPluginName(prefix);
281
+ const pluginDir = resolvePluginSchemasDir(prefix, process.cwd());
282
+ if (pluginDir) {
283
+ candidatePath = resolve(pluginDir, subPath.endsWith('.json') ? subPath : `${subPath}.json`);
284
+ if (!pluginSchemas.some((ps) => ps.pluginName === short)) {
285
+ pluginSchemas.push({ pluginName: short, schemasDir: pluginDir });
286
+ }
287
+ }
288
+ else {
289
+ const match = discovered.find((p) => (p.name === norm || p.name === short) && p.schemasDir);
290
+ if (match?.schemasDir) {
291
+ candidatePath = resolve(match.schemasDir, subPath.endsWith('.json') ? subPath : `${subPath}.json`);
292
+ }
293
+ }
294
+ }
295
+ else {
296
+ for (const p of discovered) {
297
+ if (p.schemasDir) {
298
+ const c = resolve(p.schemasDir, file.endsWith('.json') ? file : `${file}.json`);
299
+ if (existsSync(c)) {
300
+ candidatePath = c;
301
+ break;
302
+ }
303
+ }
304
+ }
305
+ }
306
+ if (candidatePath && existsSync(candidatePath)) {
307
+ schemaPath = candidatePath;
308
+ }
309
+ else {
310
+ schemaPath = join(bundledSchemasDir, file.endsWith('.json') ? file : `${file}.json`);
311
+ }
224
312
  }
225
313
  if (!existsSync(schemaPath)) {
226
314
  console.error(`Schema not found: ${schemaPath}`);
@@ -237,7 +325,11 @@ export function showSchema(file, options) {
237
325
  process.stdout.write(content);
238
326
  return;
239
327
  }
240
- const { schema, schemaRefRegistry } = loadSchema(schemaPath);
328
+ if (!schema || !schemaRefRegistry) {
329
+ const loaded = loadSchema(schemaPath, pluginSchemas);
330
+ schema = loaded.schema;
331
+ schemaRefRegistry = loaded.schemaRefRegistry;
332
+ }
241
333
  // Handle --mermaid-erd: generate ERD and exit
242
334
  if (options.mermaidErd) {
243
335
  const entityInfo = extractEntityInfo(schema, schemaRefRegistry);
@@ -265,5 +357,5 @@ export function showSchema(file, options) {
265
357
  console.log(` ${ref}`);
266
358
  }
267
359
  }
268
- showRegistry(schemaPath, schemaRefRegistry);
360
+ showRegistry(schemaPath, schemaRefRegistry, pluginSchemas);
269
361
  }
@@ -1,4 +1,5 @@
1
1
  import type { SpaceContext } from '../types';
2
2
  export declare function show(context: SpaceContext, options?: {
3
3
  filter?: string;
4
+ hierarchy?: string;
4
5
  }): Promise<void>;
@@ -1,6 +1,12 @@
1
1
  import { executeRender } from '../render/render';
2
2
  export async function show(context, options) {
3
- const result = await executeRender('markdown.bullets', context, { filter: options?.filter });
3
+ const result = await executeRender('markdown.bullets', context, {
4
+ filter: options?.filter,
5
+ hierarchy: options?.hierarchy,
6
+ });
7
+ if (typeof result !== 'string') {
8
+ throw new Error('Expected string output from markdown.bullets renderer');
9
+ }
4
10
  process.stdout.write(result);
5
11
  if (!result.endsWith('\n'))
6
12
  process.stdout.write('\n');
@@ -1 +1,3 @@
1
- export declare function listSpaces(): void;
1
+ export declare function listSpaces(options?: {
2
+ json?: boolean;
3
+ }): void;