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.
- package/README.md +22 -3
- package/dist/api.d.ts +33 -0
- package/dist/api.js +26 -0
- package/dist/commands/diagram.js +1 -1
- package/dist/commands/dump.js +1 -1
- package/dist/commands/miro-sync.d.ts +3 -0
- package/dist/commands/miro-sync.js +8 -0
- package/dist/commands/schemas.js +3 -2
- package/dist/commands/spaces.js +0 -2
- package/dist/commands/validate-file.d.ts +5 -24
- package/dist/commands/validate-file.js +26 -108
- package/dist/commands/validate.d.ts +1 -12
- package/dist/commands/validate.js +22 -157
- package/dist/config.d.ts +8 -12
- package/dist/config.js +25 -23
- package/dist/index.js +15 -23
- package/dist/load-space-graph.d.ts +40 -0
- package/dist/load-space-graph.js +42 -0
- package/dist/plugins/index.js +3 -1
- package/dist/plugins/loader.js +2 -2
- package/dist/plugins/markdown/extract-content-links.d.ts +23 -0
- package/dist/plugins/markdown/extract-content-links.js +144 -0
- package/dist/plugins/markdown/index.d.ts +42 -32
- package/dist/plugins/markdown/index.js +10 -15
- package/dist/plugins/markdown/parse-embedded.d.ts +1 -1
- package/dist/plugins/markdown/parse-embedded.js +9 -0
- package/dist/plugins/markdown/read-space.js +6 -3
- package/dist/plugins/markdown/template-sync.d.ts +1 -1
- package/dist/plugins/markdown/template-sync.js +2 -2
- package/dist/plugins/mermaid/index.d.ts +2 -0
- package/dist/plugins/mermaid/index.js +14 -0
- package/dist/plugins/{markdown → mermaid}/render-mermaid.js +17 -16
- package/dist/{integrations → plugins}/miro/cache.d.ts +1 -1
- package/dist/plugins/miro/index.d.ts +2 -0
- package/dist/plugins/miro/index.js +27 -0
- package/dist/{integrations → plugins}/miro/layout.d.ts +2 -2
- package/dist/{integrations → plugins}/miro/layout.js +3 -16
- package/dist/{integrations → plugins}/miro/styles.d.ts +2 -2
- package/dist/plugins/miro/sync.d.ts +7 -0
- package/dist/{integrations → plugins}/miro/sync.js +23 -41
- package/dist/plugins/util.d.ts +9 -0
- package/dist/plugins/util.js +4 -0
- package/dist/read/resolve-graph-edges.js +32 -0
- package/dist/render/render.d.ts +1 -0
- package/dist/render/render.js +20 -16
- package/dist/schema/evaluate-rule.js +1 -0
- package/dist/schema/schema.d.ts +3 -1
- package/dist/schema/schema.js +7 -3
- package/dist/schemas/_knowledge_wiki.json +1 -1
- package/dist/space-context.d.ts +24 -0
- package/dist/space-context.js +41 -0
- package/dist/types.d.ts +72 -2
- package/dist/validate.d.ts +59 -0
- package/dist/validate.js +316 -0
- package/docs/concepts.md +23 -0
- package/docs/config.md +2 -0
- package/package.json +11 -5
- package/schemas/_knowledge_wiki.json +1 -1
- package/dist/integrations/miro/sync.d.ts +0 -8
- package/dist/plugin-api.d.ts +0 -12
- package/dist/plugin-api.js +0 -7
- /package/dist/plugins/{markdown → mermaid}/render-mermaid.d.ts +0 -0
- /package/dist/{integrations → plugins}/miro/cache.js +0 -0
- /package/dist/{integrations → plugins}/miro/client.d.ts +0 -0
- /package/dist/{integrations → plugins}/miro/client.js +0 -0
- /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 `
|
|
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 `
|
|
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
|
-
|
|
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';
|
package/dist/commands/diagram.js
CHANGED
|
@@ -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('
|
|
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}`);
|
package/dist/commands/dump.js
CHANGED
package/dist/commands/schemas.js
CHANGED
|
@@ -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
|
|
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
|
};
|
package/dist/commands/spaces.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
7
|
+
export declare function validateFileCommand(filePath: string, options?: {
|
|
27
8
|
json?: boolean;
|
|
28
9
|
}): Promise<number>;
|
|
@@ -1,133 +1,51 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
9
|
+
export async function validateFileCommand(filePath, options = {}) {
|
|
35
10
|
const config = loadConfig();
|
|
36
|
-
const
|
|
37
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 {
|
|
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 {
|
|
5
|
-
import {
|
|
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
|
|
98
|
-
const
|
|
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
|
-
//
|
|
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...');
|