structured-context 0.9.3 → 0.11.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 (66) hide show
  1. package/README.md +22 -3
  2. package/dist/api.d.ts +33 -0
  3. package/dist/api.js +26 -0
  4. package/dist/commands/diagram.js +1 -1
  5. package/dist/commands/dump.js +1 -1
  6. package/dist/commands/miro-sync.d.ts +3 -0
  7. package/dist/commands/miro-sync.js +8 -0
  8. package/dist/commands/schemas.js +3 -2
  9. package/dist/commands/spaces.js +0 -2
  10. package/dist/commands/validate-file.d.ts +5 -24
  11. package/dist/commands/validate-file.js +26 -108
  12. package/dist/commands/validate.d.ts +1 -12
  13. package/dist/commands/validate.js +22 -157
  14. package/dist/config.d.ts +8 -12
  15. package/dist/config.js +25 -23
  16. package/dist/index.js +15 -23
  17. package/dist/load-space-graph.d.ts +40 -0
  18. package/dist/load-space-graph.js +42 -0
  19. package/dist/plugins/index.js +3 -1
  20. package/dist/plugins/loader.js +2 -2
  21. package/dist/plugins/markdown/extract-content-links.d.ts +23 -0
  22. package/dist/plugins/markdown/extract-content-links.js +144 -0
  23. package/dist/plugins/markdown/index.d.ts +42 -32
  24. package/dist/plugins/markdown/index.js +10 -15
  25. package/dist/plugins/markdown/parse-embedded.d.ts +1 -1
  26. package/dist/plugins/markdown/parse-embedded.js +9 -0
  27. package/dist/plugins/markdown/read-space.js +6 -3
  28. package/dist/plugins/markdown/template-sync.d.ts +1 -1
  29. package/dist/plugins/markdown/template-sync.js +2 -2
  30. package/dist/plugins/mermaid/index.d.ts +2 -0
  31. package/dist/plugins/mermaid/index.js +14 -0
  32. package/dist/plugins/{markdown → mermaid}/render-mermaid.js +17 -16
  33. package/dist/{integrations → plugins}/miro/cache.d.ts +1 -1
  34. package/dist/plugins/miro/index.d.ts +2 -0
  35. package/dist/plugins/miro/index.js +27 -0
  36. package/dist/{integrations → plugins}/miro/layout.d.ts +2 -2
  37. package/dist/{integrations → plugins}/miro/layout.js +3 -16
  38. package/dist/{integrations → plugins}/miro/styles.d.ts +2 -2
  39. package/dist/plugins/miro/sync.d.ts +7 -0
  40. package/dist/{integrations → plugins}/miro/sync.js +23 -41
  41. package/dist/plugins/util.d.ts +9 -0
  42. package/dist/plugins/util.js +4 -0
  43. package/dist/read/resolve-graph-edges.js +32 -0
  44. package/dist/render/render.d.ts +1 -0
  45. package/dist/render/render.js +20 -16
  46. package/dist/schema/evaluate-rule.js +1 -0
  47. package/dist/schema/schema.d.ts +3 -1
  48. package/dist/schema/schema.js +7 -3
  49. package/dist/schemas/_knowledge_wiki.json +1 -1
  50. package/dist/space-context.d.ts +24 -0
  51. package/dist/space-context.js +41 -0
  52. package/dist/types.d.ts +72 -2
  53. package/dist/validate.d.ts +59 -0
  54. package/dist/validate.js +316 -0
  55. package/docs/concepts.md +23 -0
  56. package/docs/config.md +2 -0
  57. package/package.json +11 -5
  58. package/schemas/_knowledge_wiki.json +1 -1
  59. package/dist/integrations/miro/sync.d.ts +0 -8
  60. package/dist/plugin-api.d.ts +0 -12
  61. package/dist/plugin-api.js +0 -7
  62. /package/dist/plugins/{markdown → mermaid}/render-mermaid.d.ts +0 -0
  63. /package/dist/{integrations → plugins}/miro/cache.js +0 -0
  64. /package/dist/{integrations → plugins}/miro/client.d.ts +0 -0
  65. /package/dist/{integrations → plugins}/miro/client.js +0 -0
  66. /package/dist/{integrations → plugins}/miro/styles.js +0 -0
package/README.md CHANGED
@@ -335,13 +335,26 @@ sctx schemas show strategy_general --mermaid-erd
335
335
  sctx miro-sync <space> [--new-frame <title>] [--dry-run] [--verbose]
336
336
  ```
337
337
 
338
- Syncs space nodes to a Miro board as cards with connectors. Requires `MIRO_TOKEN` env var and `miroBoardId` set in the space's config entry.
338
+ Syncs space nodes to a Miro board as cards with connectors. Requires `MIRO_TOKEN` env var and `boardId` set in the `miro` plugin config for the space.
339
339
 
340
- - `--new-frame <title>` — create a new frame on the board and sync into it; auto-saves the resulting `miroFrameId` back to the config file
340
+ - `--new-frame <title>` — create a new frame on the board and sync into it; auto-saves the resulting `frameId` back to the miro plugin config
341
341
  - `--dry-run` — show what would change without touching Miro
342
342
  - `--verbose` / `-v` — detailed per-card and per-connector output
343
343
 
344
- On subsequent runs, the cached `miroFrameId` is used automatically. Cards are colour-coded by node type and linked by parent→child connectors. A local `.miro-cache/` directory tracks Miro IDs to enable incremental updates.
344
+ Configure the miro plugin in the space's config entry:
345
+
346
+ ```json
347
+ {
348
+ "plugins": {
349
+ "miro": {
350
+ "boardId": "your-board-id",
351
+ "frameId": "your-frame-id"
352
+ }
353
+ }
354
+ }
355
+ ```
356
+
357
+ On subsequent runs, the cached `frameId` is used automatically. Cards are colour-coded by node type and linked by parent→child connectors. A local `.miro-cache/` directory tracks Miro IDs to enable incremental updates.
345
358
 
346
359
  Sync is one-way (OST → Miro) and scoped to a single frame. Only cards and connectors created by this tool within that frame are managed — everything else on the board is left untouched. Card content and connectors are overwritten or recreated to match the markdown source; any edits made directly in Miro to managed cards will be lost on the next sync. Existing card positions are not changed.
347
360
 
@@ -357,6 +370,12 @@ Keeps Obsidian template files in sync with schema examples:
357
370
  - `templatePrefix` in `plugins.markdown` config (default blank) sets a naming convention for templates (`{templatePrefix}{type}.md`). This will be used to check existing filenames, and create new templates with `--create-missing`.
358
371
  - `--dry-run` previews changes without writing files
359
372
 
373
+ ## Programmatic API
374
+
375
+ The package exposes a library entry point at `structured-context/api` for embedding the tooling
376
+ in other applications. See
377
+ [docs/api.md](docs/api.md) for the full reference and usage examples.
378
+
360
379
  ## Development
361
380
 
362
381
  ```bash
package/dist/api.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Public API for structured-context.
3
+ *
4
+ * Import from this module to use structured-context as a library:
5
+ *
6
+ * import { loadConfig, loadSpaceGraph, validateSpace, validateFile } from 'structured-context/api';
7
+ * import type { StructuredContextPlugin, PluginContext, ParseResult } from 'structured-context/api';
8
+ *
9
+ * Common patterns:
10
+ *
11
+ * const config = loadConfig();
12
+ * const graph = await loadSpaceGraph('my-space', config, { filter: 'my-view' });
13
+ *
14
+ * // Read once, then assemble a graph and validate from the same read and context:
15
+ * const ctx = createSpaceContext('my-space', config);
16
+ * const readResult = await readSpace(ctx);
17
+ * const graph = await assembleSpaceGraph(ctx, { readResult });
18
+ * const validation = await validateSpace(ctx, { readResult });
19
+ */
20
+ export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
21
+ export type { Config, SpaceConfig } from './config';
22
+ export { loadConfig, setConfigPath } from './config';
23
+ export { PLUGIN_PREFIX } from './constants';
24
+ export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
25
+ export type { ParseHook, ParseResult, PluginContext, RenderFormat, RenderHook, RenderOptions, StructuredContextPlugin, TemplateSyncHook, TemplateSyncOptions, } from './plugins/util';
26
+ export { readSpace } from './read/read-space';
27
+ export type { SharedEmbeddingFields } from './schema/metadata-contract';
28
+ export { bundledSchemasDir, loadSchema, setBundledSchemasDir } from './schema/schema';
29
+ 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 type { ValidationResult } from './validate';
33
+ export { validateFile, validateSpace } from './validate';
package/dist/api.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Public API for structured-context.
3
+ *
4
+ * Import from this module to use structured-context as a library:
5
+ *
6
+ * import { loadConfig, loadSpaceGraph, validateSpace, validateFile } from 'structured-context/api';
7
+ * import type { StructuredContextPlugin, PluginContext, ParseResult } from 'structured-context/api';
8
+ *
9
+ * Common patterns:
10
+ *
11
+ * const config = loadConfig();
12
+ * const graph = await loadSpaceGraph('my-space', config, { filter: 'my-view' });
13
+ *
14
+ * // Read once, then assemble a graph and validate from the same read and context:
15
+ * const ctx = createSpaceContext('my-space', config);
16
+ * const readResult = await readSpace(ctx);
17
+ * const graph = await assembleSpaceGraph(ctx, { readResult });
18
+ * const validation = await validateSpace(ctx, { readResult });
19
+ */
20
+ export { loadConfig, setConfigPath } from './config';
21
+ export { PLUGIN_PREFIX } from './constants';
22
+ export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
23
+ export { readSpace } from './read/read-space';
24
+ export { bundledSchemasDir, loadSchema, setBundledSchemasDir } from './schema/schema';
25
+ export { createSpaceContext, SpaceNotFoundError } from './space-context';
26
+ export { validateFile, validateSpace } from './validate';
@@ -1,7 +1,7 @@
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('markdown.mermaid', context, { filter: options.filter });
4
+ const result = await executeRender('mermaid.graph', context, { filter: options.filter });
5
5
  if (options.output) {
6
6
  writeFileSync(options.output, result);
7
7
  console.log(`Mermaid diagram written to ${options.output}`);
@@ -1,4 +1,4 @@
1
- import { JSON5 } from 'bun';
1
+ import JSON5 from 'json5';
2
2
  import { readSpace } from '../read/read-space';
3
3
  export async function dump(context) {
4
4
  const { nodes, source, diagnostics } = await readSpace(context);
@@ -0,0 +1,3 @@
1
+ import type { SyncOptions } from '../plugins/miro/sync';
2
+ import type { SpaceContext } from '../types';
3
+ export declare function miroSyncCommand(context: SpaceContext, options: SyncOptions): Promise<void>;
@@ -0,0 +1,8 @@
1
+ import { executeRender } from '../render/render';
2
+ export async function miroSyncCommand(context, options) {
3
+ const summary = await executeRender('sctx-miro:board', context, {
4
+ filter: undefined,
5
+ data: options,
6
+ });
7
+ console.log(summary);
8
+ }
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { loadConfig, resolveSchema } from '../config';
4
4
  import { bundledSchemasDir, extractEntityInfo, loadSchema, readRawSchema } from '../schema/schema';
5
- import { mergeVariantProperties } from '../schema/schema-refs';
5
+ import { mergeVariantProperties, resolveRef } from '../schema/schema-refs';
6
6
  function isBundledPath(schemaPath) {
7
7
  return dirname(schemaPath) === bundledSchemasDir;
8
8
  }
@@ -21,6 +21,7 @@ function extractRefs(obj, refs) {
21
21
  function extractEntities(oneOf, schemaRefRegistry, schema) {
22
22
  return oneOf.map((entry) => {
23
23
  const entryObj = entry;
24
+ const resolvedObj = resolveRef(entryObj, schema, schemaRefRegistry) ?? entryObj;
24
25
  const { properties, required } = mergeVariantProperties(entryObj, schema, schemaRefRegistry);
25
26
  const typeDef = properties.type;
26
27
  let types = [];
@@ -30,7 +31,7 @@ function extractEntities(oneOf, schemaRefRegistry, schema) {
30
31
  types = typeDef.enum.map(String);
31
32
  return {
32
33
  types,
33
- description: typeof entryObj.description === 'string' ? entryObj.description : undefined,
34
+ description: typeof resolvedObj.description === 'string' ? resolvedObj.description : undefined,
34
35
  properties: Object.keys(properties).filter((k) => k !== 'type'),
35
36
  required: required.filter((r) => r !== 'type'),
36
37
  };
@@ -14,8 +14,6 @@ export function listSpaces() {
14
14
  console.log(` path: ${space.path}`);
15
15
  const schema = space.schema ?? config.schema;
16
16
  console.log(` schema: ${schema ? basename(schema) : '(none)'}`);
17
- if (space.miroBoardId)
18
- console.log(` miro: configured`);
19
17
  const plugins = space.plugins ?? {};
20
18
  if (Object.keys(plugins).length > 0) {
21
19
  console.log(' plugins:');
@@ -1,28 +1,9 @@
1
- export interface FileValidationResult {
2
- file: string;
3
- label: string;
4
- space: string;
5
- /** Errors keyed by composite id (e.g. `schema:/status:enum:active`, `rule:my-rule-id`). */
6
- errors: Record<string, {
7
- kind: string;
8
- message: string;
9
- }>;
10
- errorCount: number;
11
- inSpace: true;
12
- }
13
- export interface FileNotInSpaceResult {
14
- file: string;
15
- inSpace: false;
16
- message: string;
17
- }
18
- export type ValidateFileOutput = FileValidationResult | FileNotInSpaceResult;
1
+ export { validateFile } from '../validate';
19
2
  /**
20
- * Validate a single file within its space.
21
- *
22
- * Reads the full space (required for graph correctness) but filters all reported
23
- * errors to only those attributable to the target file. Exits 0 if the file is
24
- * not in any configured space (not an error — hooks call this on all file writes).
3
+ * CLI command: validate a file and print results to stdout.
4
+ * Loads config automatically from the environment, outputs human-readable or JSON,
5
+ * and returns an exit code (0 = valid, 1 = errors found).
25
6
  */
26
- export declare function validateFile(filePath: string, options?: {
7
+ export declare function validateFileCommand(filePath: string, options?: {
27
8
  json?: boolean;
28
9
  }): Promise<number>;
@@ -1,133 +1,51 @@
1
- import { isAbsolute, relative, resolve } from 'node:path';
2
- import { getSpaceConfigDir, loadConfig, resolveSchema } from '../config';
3
- import { readSpace } from '../read/read-space';
4
- import { loadSchema } from '../schema/schema';
5
- import { validateGraph } from '../schema/validate-graph';
6
- import { validateRules } from '../schema/validate-rules';
7
- import { formatErrors } from './validate';
8
- /** Find which space a file belongs to by checking directory containment. */
9
- function resolveFileSpace(filePath, config) {
10
- const absFile = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
11
- for (const space of config.spaces) {
12
- const absSpace = isAbsolute(space.path) ? space.path : resolve(process.cwd(), space.path);
13
- // Trailing slash prevents prefix-match false positives (e.g. /foo matching /foobar/)
14
- const spaceDir = absSpace.endsWith('/') ? absSpace : `${absSpace}/`;
15
- if (absFile.startsWith(spaceDir) || absFile === absSpace) {
16
- return { space, label: relative(absSpace, absFile) };
17
- }
18
- }
19
- return null;
20
- }
21
- function buildContextForSpace(space, config) {
22
- const resolvedSchemaPath = resolveSchema(config, space);
23
- const { schema, schemaRefRegistry, schemaValidator } = loadSchema(resolvedSchemaPath);
24
- const configDir = getSpaceConfigDir(space.name);
25
- return { space, config, resolvedSchemaPath, schema, schemaRefRegistry, schemaValidator, configDir };
26
- }
1
+ import { loadConfig } from '../config';
2
+ import { validateFile } from '../validate';
3
+ export { validateFile } from '../validate';
27
4
  /**
28
- * Validate a single file within its space.
29
- *
30
- * Reads the full space (required for graph correctness) but filters all reported
31
- * errors to only those attributable to the target file. Exits 0 if the file is
32
- * not in any configured space (not an error — hooks call this on all file writes).
5
+ * CLI command: validate a file and print results to stdout.
6
+ * Loads config automatically from the environment, outputs human-readable or JSON,
7
+ * and returns an exit code (0 = valid, 1 = errors found).
33
8
  */
34
- export async function validateFile(filePath, options = {}) {
9
+ export async function validateFileCommand(filePath, options = {}) {
35
10
  const config = loadConfig();
36
- const resolved = resolveFileSpace(filePath, config);
37
- if (!resolved) {
38
- const result = {
39
- file: filePath,
40
- inSpace: false,
41
- message: 'File does not belong to any configured space.',
42
- };
11
+ const result = await validateFile(filePath, config);
12
+ if (!result.inSpace) {
43
13
  if (options.json) {
44
14
  console.log(JSON.stringify(result, null, 2));
45
15
  }
46
16
  return 0;
47
17
  }
48
- const { space, label } = resolved;
49
- const context = buildContextForSpace(space, config);
50
- const { schema, schemaRefRegistry, schemaValidator } = context;
51
- const metadata = schema.metadata;
52
- const readResult = await readSpace(context);
53
- const { nodes } = readResult;
54
- const errors = {};
55
- // Schema validation errors for this node
56
- for (const node of nodes) {
57
- if (node.label !== label)
58
- continue;
59
- const valid = schemaValidator(node.schemaData);
60
- if (!valid) {
61
- const formatted = formatErrors(schemaValidator.errors ?? [], schema, schemaRefRegistry, node.schemaData);
62
- for (const { message, dedupeKey } of formatted) {
63
- errors[`schema:${dedupeKey}`] = { kind: 'schema', message };
64
- }
65
- }
66
- }
67
- // Duplicate key errors — include if this file is one of the duplicates
68
- const titleToFiles = new Map();
69
- for (const node of nodes) {
70
- if (!titleToFiles.has(node.title))
71
- titleToFiles.set(node.title, []);
72
- titleToFiles.get(node.title).push(node.label);
73
- }
74
- for (const [title, files] of titleToFiles) {
75
- if (files.length > 1 && files.includes(label)) {
76
- const others = files.filter((f) => f !== label);
77
- errors[`duplicate:${title}`] = {
78
- kind: 'duplicate',
79
- message: `Duplicate title "${title}" also exists in: ${others.join(', ')}`,
80
- };
81
- }
82
- }
83
- // Broken links and hierarchy violations from this file
84
- const hierarchyValidation = validateGraph(nodes, metadata, readResult.unresolvedRefs);
85
- for (const { file, parent, error } of hierarchyValidation.refErrors) {
86
- if (file === label) {
87
- errors[`broken-link:${parent}`] = { kind: 'broken-link', message: `${parent} → ${error}` };
88
- }
89
- }
90
- for (const v of hierarchyValidation.violations) {
91
- if (v.file === label) {
92
- errors[`hierarchy:${v.description}`] = { kind: 'hierarchy', message: v.description };
93
- }
94
- }
95
- // Rule violations for this node
96
- if (metadata.rules) {
97
- const ruleViolations = await validateRules(nodes, metadata.rules);
98
- for (const v of ruleViolations) {
99
- if (v.file === label) {
100
- errors[`rule:${v.ruleId}`] = { kind: 'rule', message: `[${v.ruleId}] ${v.description}` };
101
- }
102
- }
103
- }
104
- const result = {
105
- file: isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath),
106
- label,
107
- space: space.name,
108
- errors,
109
- errorCount: Object.keys(errors).length,
110
- inSpace: true,
111
- };
112
18
  if (options.json) {
113
19
  console.log(JSON.stringify(result, null, 2));
114
20
  }
115
21
  else {
116
22
  printHumanReadable(result);
117
23
  }
118
- return Object.keys(errors).length > 0 ? 1 : 0;
24
+ return result.errorCount > 0 ? 1 : 0;
119
25
  }
120
26
  function printHumanReadable(result) {
121
27
  const reset = '\x1b[0m';
122
28
  const green = '\x1b[32m';
123
29
  const red = '\x1b[31m';
124
- if (result.errorCount === 0) {
30
+ const yellow = '\x1b[33m';
31
+ if (result.errorCount === 0 && result.warningCount === 0) {
125
32
  console.log(`${green}✓${reset} ${result.label} (space: ${result.space})`);
126
33
  return;
127
34
  }
128
- console.log(`\n${red}✗${reset} ${result.label} (space: ${result.space}) — ${result.errorCount} error(s)\n`);
129
- for (const { kind, message } of Object.values(result.errors)) {
130
- console.log(` [${kind}] ${message}`);
35
+ if (result.errorCount > 0) {
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}`);
39
+ }
40
+ }
41
+ else {
42
+ console.log(`\n${green}✓${reset} ${result.label} (space: ${result.space})`);
43
+ }
44
+ if (result.warningCount > 0) {
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}`);
48
+ }
131
49
  }
132
50
  console.log('');
133
51
  }
@@ -1,15 +1,4 @@
1
- import type { ErrorObject } from 'ajv';
2
- import { extractEntityInfo } from '../schema/schema';
3
- import type { SchemaWithMetadata, SpaceContext } from '../types';
4
- export interface FormattedError {
5
- message: string;
6
- dedupeKey: string;
7
- }
8
- /**
9
- * Format AJV errors for better readability.
10
- * Groups related errors and extracts helpful context like allowed values.
11
- */
12
- export declare function formatErrors(errors: ErrorObject[], schema: SchemaWithMetadata, schemaRefRegistry: Parameters<typeof extractEntityInfo>[1], nodeData: Record<string, unknown>): FormattedError[];
1
+ import type { SpaceContext } from '../types';
13
2
  export declare function validate(context: SpaceContext, options?: {
14
3
  json?: boolean;
15
4
  }): Promise<number>;
@@ -1,154 +1,11 @@
1
1
  import { dirname } from 'node:path';
2
2
  import chokidar from 'chokidar';
3
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
- }
4
+ import { bundledSchemasDir } from '../schema/schema';
5
+ import { formatErrors, validateSpace } from '../validate';
96
6
  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, parseIssues } = readResult;
101
- const result = {
102
- validCount: 0,
103
- nodeErrorCount: 0,
104
- nodeErrors: [],
105
- refErrors: [],
106
- duplicateErrors: [],
107
- ruleViolations: [],
108
- hierarchyViolations: [],
109
- orphans: [],
110
- parseIssues,
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.orphans = [...buildSpaceGraph(nodes, metadata.hierarchy.levels).orphans];
147
- }
148
- // Load and execute rules validation if schema defines rules
149
- if (metadata.rules) {
150
- result.ruleViolations = await validateRules(nodes, metadata.rules);
151
- }
7
+ const { schema, schemaRefRegistry } = context;
8
+ const result = await validateSpace(context);
152
9
  // JSON output mode
153
10
  if (options.json) {
154
11
  const errorsByFile = {};
@@ -190,10 +47,11 @@ export async function validate(context, options = {}) {
190
47
  errors: errorsByFile,
191
48
  orphanCount: result.orphans.length,
192
49
  parseIssues: result.parseIssues,
50
+ unresolvedContentLinks: result.unresolvedContentLinks,
193
51
  }, null, 2));
194
52
  return errorCount > 0 ? 1 : 0;
195
53
  }
196
- // Report
54
+ // Human-readable output
197
55
  const reset = '\x1b[0m';
198
56
  const green = '\x1b[32m';
199
57
  const red = '\x1b[31m';
@@ -209,7 +67,6 @@ export async function validate(context, options = {}) {
209
67
  color = colorFor(count, isWarning);
210
68
  }
211
69
  else {
212
- // For non-error items (like "Valid"), green if count > 0, red if 0
213
70
  color = count > 0 ? green : red;
214
71
  }
215
72
  const countStr = String(count).padStart(3);
@@ -225,6 +82,7 @@ export async function validate(context, options = {}) {
225
82
  console.log(fmt(' Rule violations', result.ruleViolations.length, true));
226
83
  console.log(fmt(' Hierarchy violations', result.hierarchyViolations.length, true));
227
84
  console.log(fmt(' Orphans (hierarchy nodes - no parent)', result.orphans.length, true, true));
85
+ console.log(fmt(' Unresolved content links', result.unresolvedContentLinks.length, true, true));
228
86
  const parseIssueErrorCount = result.parseIssues.filter((i) => i.severity === 'error').length;
229
87
  console.log(fmt(' Excluded during parsing', result.parseIssues.length, true, parseIssueErrorCount === 0));
230
88
  if (result.orphans.length > 0) {
@@ -232,6 +90,20 @@ export async function validate(context, options = {}) {
232
90
  for (const node of result.orphans)
233
91
  console.log(` ${node.label}`);
234
92
  }
93
+ if (result.unresolvedContentLinks.length > 0) {
94
+ console.log(`\nUnresolved content links (may be outside-space or broken):`);
95
+ const byFile = new Map();
96
+ for (const { file, target } of result.unresolvedContentLinks) {
97
+ if (!byFile.has(file))
98
+ byFile.set(file, []);
99
+ byFile.get(file).push(target);
100
+ }
101
+ for (const [file, targets] of byFile) {
102
+ console.log(` ${file}:`);
103
+ for (const target of targets)
104
+ console.log(` ${target}`);
105
+ }
106
+ }
235
107
  if (result.parseIssues.length > 0) {
236
108
  console.log(`\nExcluded during parsing:`);
237
109
  for (const issue of result.parseIssues) {
@@ -266,15 +138,12 @@ export async function validate(context, options = {}) {
266
138
  }
267
139
  if (result.ruleViolations.length > 0) {
268
140
  console.log(`\nRule violations:`);
269
- // Group by category
270
141
  const byCategory = new Map();
271
142
  for (const v of result.ruleViolations) {
272
- if (!byCategory.has(v.category)) {
143
+ if (!byCategory.has(v.category))
273
144
  byCategory.set(v.category, []);
274
- }
275
145
  byCategory.get(v.category).push(v);
276
146
  }
277
- // Report each category
278
147
  for (const [category, violations] of byCategory) {
279
148
  console.log(` ${category.toUpperCase()} (${violations.length}):`);
280
149
  for (const v of violations) {
@@ -289,7 +158,6 @@ export async function validate(context, options = {}) {
289
158
  }
290
159
  }
291
160
  console.log(`\n`);
292
- // Return exit code (0 for success, 1 for validation failures)
293
161
  if (result.nodeErrorCount > 0 ||
294
162
  result.refErrors.length > 0 ||
295
163
  result.duplicateErrors.length > 0 ||
@@ -314,7 +182,6 @@ export async function watchValidate(context) {
314
182
  console.log(` Schema dirs: ${schemaDirs.join(', ')}`);
315
183
  console.log(` Space: ${spacePath}`);
316
184
  console.log(` Press Ctrl+C to stop\n`);
317
- // Save cursor position after header (for clearing later)
318
185
  process.stdout.write('\x1b[s');
319
186
  let exitCode = 0;
320
187
  const innerValidate = async () => {
@@ -336,7 +203,6 @@ export async function watchValidate(context) {
336
203
  },
337
204
  });
338
205
  const handleFileChange = async (filePath, action) => {
339
- // Restore cursor to header position and clear everything below
340
206
  process.stdout.write('\x1b[u\x1b[0J');
341
207
  console.log(`🔄 ${filePath} ${action}, re-validating...\n`);
342
208
  await innerValidate();
@@ -347,7 +213,6 @@ export async function watchValidate(context) {
347
213
  watcher.on('error', (error) => {
348
214
  console.error(`Watcher error: ${error}`);
349
215
  });
350
- // Keep process alive
351
216
  return new Promise((_, reject) => {
352
217
  process.on('SIGINT', () => {
353
218
  console.log('\n\n👋 Stopping watch mode...');