structured-context 0.9.3 → 0.10.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/dist/api.d.ts +15 -0
- package/dist/api.js +9 -0
- package/dist/commands/dump.js +1 -1
- package/dist/commands/validate-file.d.ts +5 -24
- package/dist/commands/validate-file.js +10 -104
- package/dist/commands/validate.d.ts +1 -12
- package/dist/commands/validate.js +6 -157
- package/dist/config.js +1 -1
- package/dist/index.js +2 -2
- package/dist/plugins/markdown/index.d.ts +42 -32
- package/dist/plugins/markdown/index.js +9 -8
- package/dist/plugins/markdown/parse-embedded.d.ts +1 -1
- package/dist/plugins/markdown/read-space.js +3 -3
- package/dist/plugins/markdown/template-sync.d.ts +1 -1
- package/dist/plugins/markdown/template-sync.js +2 -2
- package/dist/schema/schema.js +1 -1
- package/dist/types.d.ts +20 -2
- package/dist/validate.d.ts +50 -0
- package/dist/validate.js +291 -0
- package/package.json +11 -5
- package/dist/plugin-api.d.ts +0 -12
- package/dist/plugin-api.js +0 -7
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for structured-context.
|
|
3
|
+
*
|
|
4
|
+
* Import from this module to use structured-context as a library:
|
|
5
|
+
*
|
|
6
|
+
* import { validateFile } from 'structured-context/api';
|
|
7
|
+
* import type { StructuredContextPlugin, PluginContext, ParseResult } from 'structured-context/api';
|
|
8
|
+
*/
|
|
9
|
+
export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
|
|
10
|
+
export type { Config, SpaceConfig } from './config';
|
|
11
|
+
export type { ParseHook, ParseResult, PluginContext, RenderFormat, RenderHook, RenderOptions, StructuredContextPlugin, TemplateSyncHook, TemplateSyncOptions, } from './plugins/util';
|
|
12
|
+
export type { SharedEmbeddingFields } from './schema/metadata-contract';
|
|
13
|
+
export type { SpaceGraph } from './space-graph';
|
|
14
|
+
export type { BaseNode, EdgeDefinition, FileNotInSpaceResult, FileValidationResult, HierarchyLevel, ParseIssue, Relationship, SchemaMetadata, SchemaWithMetadata, SpaceContext, SpaceNode, UnresolvedRef, ValidateFileOutput, } from './types';
|
|
15
|
+
export { validateFile } from './validate';
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for structured-context.
|
|
3
|
+
*
|
|
4
|
+
* Import from this module to use structured-context as a library:
|
|
5
|
+
*
|
|
6
|
+
* import { validateFile } from 'structured-context/api';
|
|
7
|
+
* import type { StructuredContextPlugin, PluginContext, ParseResult } from 'structured-context/api';
|
|
8
|
+
*/
|
|
9
|
+
export { validateFile } from './validate';
|
package/dist/commands/dump.js
CHANGED
|
@@ -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,121 +1,27 @@
|
|
|
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 Object.keys(errors).length > 0 ? 1 : 0;
|
|
24
|
+
return Object.keys(result.errors).length > 0 ? 1 : 0;
|
|
119
25
|
}
|
|
120
26
|
function printHumanReadable(result) {
|
|
121
27
|
const reset = '\x1b[0m';
|
|
@@ -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 = {};
|
|
@@ -193,7 +50,7 @@ export async function validate(context, options = {}) {
|
|
|
193
50
|
}, null, 2));
|
|
194
51
|
return errorCount > 0 ? 1 : 0;
|
|
195
52
|
}
|
|
196
|
-
//
|
|
53
|
+
// Human-readable output
|
|
197
54
|
const reset = '\x1b[0m';
|
|
198
55
|
const green = '\x1b[32m';
|
|
199
56
|
const red = '\x1b[31m';
|
|
@@ -209,7 +66,6 @@ export async function validate(context, options = {}) {
|
|
|
209
66
|
color = colorFor(count, isWarning);
|
|
210
67
|
}
|
|
211
68
|
else {
|
|
212
|
-
// For non-error items (like "Valid"), green if count > 0, red if 0
|
|
213
69
|
color = count > 0 ? green : red;
|
|
214
70
|
}
|
|
215
71
|
const countStr = String(count).padStart(3);
|
|
@@ -266,15 +122,12 @@ export async function validate(context, options = {}) {
|
|
|
266
122
|
}
|
|
267
123
|
if (result.ruleViolations.length > 0) {
|
|
268
124
|
console.log(`\nRule violations:`);
|
|
269
|
-
// Group by category
|
|
270
125
|
const byCategory = new Map();
|
|
271
126
|
for (const v of result.ruleViolations) {
|
|
272
|
-
if (!byCategory.has(v.category))
|
|
127
|
+
if (!byCategory.has(v.category))
|
|
273
128
|
byCategory.set(v.category, []);
|
|
274
|
-
}
|
|
275
129
|
byCategory.get(v.category).push(v);
|
|
276
130
|
}
|
|
277
|
-
// Report each category
|
|
278
131
|
for (const [category, violations] of byCategory) {
|
|
279
132
|
console.log(` ${category.toUpperCase()} (${violations.length}):`);
|
|
280
133
|
for (const v of violations) {
|
|
@@ -289,7 +142,6 @@ export async function validate(context, options = {}) {
|
|
|
289
142
|
}
|
|
290
143
|
}
|
|
291
144
|
console.log(`\n`);
|
|
292
|
-
// Return exit code (0 for success, 1 for validation failures)
|
|
293
145
|
if (result.nodeErrorCount > 0 ||
|
|
294
146
|
result.refErrors.length > 0 ||
|
|
295
147
|
result.duplicateErrors.length > 0 ||
|
|
@@ -314,7 +166,6 @@ export async function watchValidate(context) {
|
|
|
314
166
|
console.log(` Schema dirs: ${schemaDirs.join(', ')}`);
|
|
315
167
|
console.log(` Space: ${spacePath}`);
|
|
316
168
|
console.log(` Press Ctrl+C to stop\n`);
|
|
317
|
-
// Save cursor position after header (for clearing later)
|
|
318
169
|
process.stdout.write('\x1b[s');
|
|
319
170
|
let exitCode = 0;
|
|
320
171
|
const innerValidate = async () => {
|
|
@@ -336,7 +187,6 @@ export async function watchValidate(context) {
|
|
|
336
187
|
},
|
|
337
188
|
});
|
|
338
189
|
const handleFileChange = async (filePath, action) => {
|
|
339
|
-
// Restore cursor to header position and clear everything below
|
|
340
190
|
process.stdout.write('\x1b[u\x1b[0J');
|
|
341
191
|
console.log(`🔄 ${filePath} ${action}, re-validating...\n`);
|
|
342
192
|
await innerValidate();
|
|
@@ -347,7 +197,6 @@ export async function watchValidate(context) {
|
|
|
347
197
|
watcher.on('error', (error) => {
|
|
348
198
|
console.error(`Watcher error: ${error}`);
|
|
349
199
|
});
|
|
350
|
-
// Keep process alive
|
|
351
200
|
return new Promise((_, reject) => {
|
|
352
201
|
process.on('SIGINT', () => {
|
|
353
202
|
console.log('\n\n👋 Stopping watch mode...');
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
4
|
import Ajv from 'ajv';
|
|
5
|
-
import
|
|
5
|
+
import JSON5 from 'json5';
|
|
6
6
|
import { ENV_CONFIG_VAR, XDG_CONFIG_DIR } from './constants';
|
|
7
7
|
import { normalizePluginName } from './plugins/util';
|
|
8
8
|
import { bundledSchemasDir } from './schema/schema';
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { show } from './commands/show';
|
|
|
11
11
|
import { listSpaces } from './commands/spaces';
|
|
12
12
|
import { templateSync } from './commands/template-sync';
|
|
13
13
|
import { validate, watchValidate } from './commands/validate';
|
|
14
|
-
import {
|
|
14
|
+
import { validateFileCommand } from './commands/validate-file';
|
|
15
15
|
import { getSpaceConfigDir, loadConfig, resolveSchema, setConfigPath } from './config';
|
|
16
16
|
import { CLI_NAME } from './constants';
|
|
17
17
|
import { miroSync } from './integrations/miro/sync';
|
|
@@ -53,7 +53,7 @@ program
|
|
|
53
53
|
.argument('<path>', 'Path to the file to validate')
|
|
54
54
|
.option('--json', 'Output results as JSON (machine-readable, for hooks)')
|
|
55
55
|
.action(async (filePath, options) => {
|
|
56
|
-
const exitCode = await
|
|
56
|
+
const exitCode = await validateFileCommand(filePath, { json: options.json });
|
|
57
57
|
process.exit(exitCode);
|
|
58
58
|
});
|
|
59
59
|
program
|
|
@@ -1,48 +1,58 @@
|
|
|
1
|
+
import type { FromSchema } from 'json-schema-to-ts';
|
|
1
2
|
import type { StructuredContextPlugin } from '../util';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
declare const TYPE_INFERENCE_CONFIG_SCHEMA: {
|
|
4
|
+
readonly type: "object";
|
|
5
|
+
readonly properties: {
|
|
6
|
+
readonly mode: {
|
|
7
|
+
readonly type: "string";
|
|
8
|
+
readonly enum: readonly ["folder-name", "off"];
|
|
9
|
+
};
|
|
10
|
+
readonly folderMap: {
|
|
11
|
+
readonly type: "object";
|
|
12
|
+
readonly additionalProperties: {
|
|
13
|
+
readonly type: "string";
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
readonly additionalProperties: false;
|
|
11
18
|
};
|
|
12
19
|
export declare const MARKDOWN_CONFIG_SCHEMA: {
|
|
13
|
-
type:
|
|
14
|
-
properties: {
|
|
15
|
-
templateDir: {
|
|
16
|
-
type: string;
|
|
17
|
-
format:
|
|
20
|
+
readonly type: "object";
|
|
21
|
+
readonly properties: {
|
|
22
|
+
readonly templateDir: {
|
|
23
|
+
readonly type: "string";
|
|
24
|
+
readonly format: "path";
|
|
18
25
|
};
|
|
19
|
-
fieldMap: {
|
|
20
|
-
type:
|
|
21
|
-
additionalProperties: {
|
|
22
|
-
type: string;
|
|
26
|
+
readonly fieldMap: {
|
|
27
|
+
readonly type: "object";
|
|
28
|
+
readonly additionalProperties: {
|
|
29
|
+
readonly type: "string";
|
|
23
30
|
};
|
|
24
31
|
};
|
|
25
|
-
templatePrefix: {
|
|
26
|
-
type: string;
|
|
32
|
+
readonly templatePrefix: {
|
|
33
|
+
readonly type: "string";
|
|
27
34
|
};
|
|
28
|
-
typeInference: {
|
|
29
|
-
type:
|
|
30
|
-
properties: {
|
|
31
|
-
mode: {
|
|
32
|
-
type: string;
|
|
33
|
-
enum:
|
|
35
|
+
readonly typeInference: {
|
|
36
|
+
readonly type: "object";
|
|
37
|
+
readonly properties: {
|
|
38
|
+
readonly mode: {
|
|
39
|
+
readonly type: "string";
|
|
40
|
+
readonly enum: readonly ["folder-name", "off"];
|
|
34
41
|
};
|
|
35
|
-
folderMap: {
|
|
36
|
-
type:
|
|
37
|
-
additionalProperties: {
|
|
38
|
-
type: string;
|
|
42
|
+
readonly folderMap: {
|
|
43
|
+
readonly type: "object";
|
|
44
|
+
readonly additionalProperties: {
|
|
45
|
+
readonly type: "string";
|
|
39
46
|
};
|
|
40
47
|
};
|
|
41
48
|
};
|
|
42
|
-
additionalProperties:
|
|
49
|
+
readonly additionalProperties: false;
|
|
43
50
|
};
|
|
44
51
|
};
|
|
45
|
-
additionalProperties:
|
|
52
|
+
readonly additionalProperties: false;
|
|
46
53
|
};
|
|
54
|
+
export type MarkdownPluginConfig = FromSchema<typeof MARKDOWN_CONFIG_SCHEMA>;
|
|
55
|
+
export type TypeInferenceConfig = FromSchema<typeof TYPE_INFERENCE_CONFIG_SCHEMA>;
|
|
47
56
|
export declare function getMarkdownConfig(plugins?: Record<string, Record<string, unknown>>): MarkdownPluginConfig;
|
|
48
57
|
export declare const markdownPlugin: StructuredContextPlugin;
|
|
58
|
+
export {};
|
|
@@ -4,20 +4,21 @@ import { readSpaceDirectory, readSpaceOnAPage } from './read-space';
|
|
|
4
4
|
import { renderBullets } from './render-bullets';
|
|
5
5
|
import { renderMermaid } from './render-mermaid';
|
|
6
6
|
import { templateSync } from './template-sync';
|
|
7
|
+
const TYPE_INFERENCE_CONFIG_SCHEMA = {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
mode: { type: 'string', enum: ['folder-name', 'off'] },
|
|
11
|
+
folderMap: { type: 'object', additionalProperties: { type: 'string' } },
|
|
12
|
+
},
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
};
|
|
7
15
|
export const MARKDOWN_CONFIG_SCHEMA = {
|
|
8
16
|
type: 'object',
|
|
9
17
|
properties: {
|
|
10
18
|
templateDir: { type: 'string', format: 'path' }, // format is hint to config loader to resolve relative directories
|
|
11
19
|
fieldMap: { type: 'object', additionalProperties: { type: 'string' } },
|
|
12
20
|
templatePrefix: { type: 'string' },
|
|
13
|
-
typeInference:
|
|
14
|
-
type: 'object',
|
|
15
|
-
properties: {
|
|
16
|
-
mode: { type: 'string', enum: ['folder-name', 'off'] },
|
|
17
|
-
folderMap: { type: 'object', additionalProperties: { type: 'string' } },
|
|
18
|
-
},
|
|
19
|
-
additionalProperties: false,
|
|
20
|
-
},
|
|
21
|
+
typeInference: TYPE_INFERENCE_CONFIG_SCHEMA,
|
|
21
22
|
},
|
|
22
23
|
additionalProperties: false,
|
|
23
24
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BaseNode, Relationship, SchemaMetadata } from '../../
|
|
1
|
+
import type { BaseNode, Relationship, SchemaMetadata } from '../../api';
|
|
2
2
|
/** Type values that identify a space_on_a_page container (not themselves space nodes). */
|
|
3
3
|
export declare const ON_A_PAGE_TYPES: string[];
|
|
4
4
|
type StackEntry = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { basename, join, resolve } from 'node:path';
|
|
3
|
-
import
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
4
|
import matter from 'gray-matter';
|
|
5
5
|
import { extractSchemaTypeNames } from '../../schema/schema';
|
|
6
6
|
import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded';
|
|
@@ -42,7 +42,7 @@ export async function readSpaceDirectory(context, options) {
|
|
|
42
42
|
const absoluteTemplateDir = templateDir ? resolve(templateDir) : undefined;
|
|
43
43
|
const typeInferenceCfg = mdCfg.typeInference;
|
|
44
44
|
const knownTypes = typeInferenceCfg?.mode !== 'off' ? extractSchemaTypeNames(context.schema, context.schemaRefRegistry) : undefined;
|
|
45
|
-
const files = await
|
|
45
|
+
const files = await fg('**/*.md', { cwd: directory, followSymbolicLinks: true });
|
|
46
46
|
const nodes = [];
|
|
47
47
|
const parseIssues = [];
|
|
48
48
|
for (const file of files) {
|
|
@@ -70,7 +70,7 @@ export async function readSpaceDirectory(context, options) {
|
|
|
70
70
|
continue;
|
|
71
71
|
}
|
|
72
72
|
if (!parsed.data || Object.keys(parsed.data).length === 0) {
|
|
73
|
-
parseIssues.push({ file, severity: 'warning', type: '
|
|
73
|
+
parseIssues.push({ file, severity: 'warning', type: 'no-type', message: 'No front-matter or type specified' });
|
|
74
74
|
continue;
|
|
75
75
|
}
|
|
76
76
|
const data = coerceDates(applyFieldMap(parsed.data, fieldMap));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AnySchemaObject } from 'ajv';
|
|
2
|
-
import type { HierarchyLevel, Relationship, SchemaWithMetadata } from '../../
|
|
2
|
+
import type { HierarchyLevel, Relationship, SchemaWithMetadata } from '../../api';
|
|
3
3
|
import type { PluginContext, TemplateSyncOptions } from '../util';
|
|
4
4
|
export interface TypeVariant {
|
|
5
5
|
required: string[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
4
|
import matter from 'gray-matter';
|
|
5
5
|
import yaml from 'js-yaml';
|
|
6
6
|
import { mergeVariantProperties, resolveRef } from '../../schema/schema-refs';
|
|
@@ -190,7 +190,7 @@ export async function templateSync(context, options) {
|
|
|
190
190
|
const { schema, schemaRefRegistry } = context;
|
|
191
191
|
const typeVariants = getTypeVariants(schema, schemaRefRegistry);
|
|
192
192
|
const matchedTypes = new Set();
|
|
193
|
-
const files = await
|
|
193
|
+
const files = await fg('*.md', { cwd: templateDir, absolute: true });
|
|
194
194
|
const dryRun = options.dryRun ?? false;
|
|
195
195
|
let filesModified = 0;
|
|
196
196
|
let filesCreated = 0;
|
package/dist/schema/schema.js
CHANGED
|
@@ -3,7 +3,7 @@ import { basename, dirname, join, resolve } from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
5
5
|
import Ajv, {} from 'ajv';
|
|
6
|
-
import
|
|
6
|
+
import JSON5 from 'json5';
|
|
7
7
|
import { DIALECT_META_SCHEMA, METADATA_SCHEMA, SCHEMA_META_ID, } from './metadata-contract';
|
|
8
8
|
import { isObject, mergeVariantProperties, resolveJsonPointer } from './schema-refs';
|
|
9
9
|
const packageDir = dirname(fileURLToPath(import.meta.url));
|
package/dist/types.d.ts
CHANGED
|
@@ -91,10 +91,10 @@ export type ParseIssue = {
|
|
|
91
91
|
file: string;
|
|
92
92
|
/** error: prevented the file from producing nodes; warning: file was silently excluded. */
|
|
93
93
|
severity: 'error' | 'warning';
|
|
94
|
-
/**
|
|
94
|
+
/** no-type: no type field;
|
|
95
95
|
* parse: parse failure (syntax issue or unexpected content);
|
|
96
96
|
* plugins may choose to provide additional types. */
|
|
97
|
-
type: '
|
|
97
|
+
type: 'no-type' | 'parse' | string;
|
|
98
98
|
/** Human-readable detail. */
|
|
99
99
|
message?: string;
|
|
100
100
|
};
|
|
@@ -155,3 +155,21 @@ export type SpaceContext = {
|
|
|
155
155
|
/** Directory of the config file that defines this space. Used for resolving relative paths in plugin configs. */
|
|
156
156
|
configDir: string;
|
|
157
157
|
};
|
|
158
|
+
export interface FileValidationResult {
|
|
159
|
+
file: string;
|
|
160
|
+
label: string;
|
|
161
|
+
space: string;
|
|
162
|
+
/** Errors keyed by composite id (e.g. `schema:/status:enum:active`, `rule:my-rule-id`). */
|
|
163
|
+
errors: Record<string, {
|
|
164
|
+
kind: string;
|
|
165
|
+
message: string;
|
|
166
|
+
}>;
|
|
167
|
+
errorCount: number;
|
|
168
|
+
inSpace: true;
|
|
169
|
+
}
|
|
170
|
+
export interface FileNotInSpaceResult {
|
|
171
|
+
file: string;
|
|
172
|
+
inSpace: false;
|
|
173
|
+
message: string;
|
|
174
|
+
}
|
|
175
|
+
export type ValidateFileOutput = FileValidationResult | FileNotInSpaceResult;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ErrorObject } from 'ajv';
|
|
2
|
+
import type { Config } from './config';
|
|
3
|
+
import { extractEntityInfo } from './schema/schema';
|
|
4
|
+
import type { GraphViolation, ParseIssue, RuleViolation, SchemaWithMetadata, SpaceContext, SpaceNode, ValidateFileOutput } from './types';
|
|
5
|
+
export interface FormattedError {
|
|
6
|
+
message: string;
|
|
7
|
+
dedupeKey: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ValidationResult {
|
|
10
|
+
validCount: number;
|
|
11
|
+
nodeErrorCount: number;
|
|
12
|
+
nodeErrors: Array<{
|
|
13
|
+
file: string;
|
|
14
|
+
errors: ErrorObject[];
|
|
15
|
+
nodeData: Record<string, unknown>;
|
|
16
|
+
}>;
|
|
17
|
+
refErrors: Array<{
|
|
18
|
+
file: string;
|
|
19
|
+
parent: string;
|
|
20
|
+
error: string;
|
|
21
|
+
}>;
|
|
22
|
+
duplicateErrors: Array<{
|
|
23
|
+
title: string;
|
|
24
|
+
files: string[];
|
|
25
|
+
}>;
|
|
26
|
+
ruleViolations: RuleViolation[];
|
|
27
|
+
hierarchyViolations: GraphViolation[];
|
|
28
|
+
orphans: SpaceNode[];
|
|
29
|
+
parseIssues: ParseIssue[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Format AJV errors for better readability.
|
|
33
|
+
* Groups related errors and extracts helpful context like allowed values.
|
|
34
|
+
*/
|
|
35
|
+
export declare function formatErrors(errors: ErrorObject[], schema: SchemaWithMetadata, schemaRefRegistry: Parameters<typeof extractEntityInfo>[1], nodeData: Record<string, unknown>): FormattedError[];
|
|
36
|
+
/**
|
|
37
|
+
* Run full validation on a space and return a structured result.
|
|
38
|
+
* Does not output to console or call process.exit.
|
|
39
|
+
*/
|
|
40
|
+
export declare function validateSpace(context: SpaceContext): Promise<ValidationResult>;
|
|
41
|
+
/**
|
|
42
|
+
* Validate a single file within its space.
|
|
43
|
+
*
|
|
44
|
+
* Reads the full space (required for graph correctness) but filters all reported
|
|
45
|
+
* errors to only those attributable to the target file.
|
|
46
|
+
*
|
|
47
|
+
* Returns a result object — does not output to console or call process.exit.
|
|
48
|
+
* If the file is not in any configured space, returns a result with `inSpace: false`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function validateFile(filePath: string, config: Config): Promise<ValidateFileOutput>;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
2
|
+
import { getSpaceConfigDir, resolveSchema } from './config';
|
|
3
|
+
import { readSpace } from './read/read-space';
|
|
4
|
+
import { extractEntityInfo, loadSchema } from './schema/schema';
|
|
5
|
+
import { validateGraph } from './schema/validate-graph';
|
|
6
|
+
import { validateRules } from './schema/validate-rules';
|
|
7
|
+
import { buildSpaceGraph } from './space-graph';
|
|
8
|
+
/**
|
|
9
|
+
* Format AJV errors for better readability.
|
|
10
|
+
* Groups related errors and extracts helpful context like allowed values.
|
|
11
|
+
*/
|
|
12
|
+
export function formatErrors(errors, schema, schemaRefRegistry, nodeData) {
|
|
13
|
+
const formatted = [];
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
// Group errors by instancePath
|
|
16
|
+
const byPath = new Map();
|
|
17
|
+
for (const err of errors) {
|
|
18
|
+
const path = err.instancePath || 'root';
|
|
19
|
+
if (!byPath.has(path)) {
|
|
20
|
+
byPath.set(path, []);
|
|
21
|
+
}
|
|
22
|
+
byPath.get(path).push(err);
|
|
23
|
+
}
|
|
24
|
+
for (const [path, pathErrors] of byPath) {
|
|
25
|
+
// Check if this is a oneOf failure at root - extract valid types from schema
|
|
26
|
+
const isRootOneOf = path === 'root' || path === '/type';
|
|
27
|
+
let hasOneOfContext = false;
|
|
28
|
+
if (isRootOneOf && pathErrors.length > 1) {
|
|
29
|
+
hasOneOfContext = Array.isArray(schema.oneOf);
|
|
30
|
+
if (hasOneOfContext) {
|
|
31
|
+
const entities = extractEntityInfo(schema, schemaRefRegistry);
|
|
32
|
+
const validTypes = entities.map((e) => e.type).sort();
|
|
33
|
+
if (validTypes.length > 0) {
|
|
34
|
+
const actualValue = nodeData.type;
|
|
35
|
+
// Only show type error if the actual type is NOT in the valid types list
|
|
36
|
+
if (actualValue !== undefined && !validTypes.includes(String(actualValue))) {
|
|
37
|
+
const message = `Invalid type "${actualValue}". Valid types are: ${validTypes.join(', ')}`;
|
|
38
|
+
const key = `type:${validTypes.join(',')}`;
|
|
39
|
+
if (!seen.has(key)) {
|
|
40
|
+
seen.add(key);
|
|
41
|
+
formatted.push({ message, dedupeKey: key });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Handle individual errors
|
|
48
|
+
for (const err of pathErrors) {
|
|
49
|
+
// Skip individual type const/enum errors when in oneOf context (we handle it above)
|
|
50
|
+
if (hasOneOfContext &&
|
|
51
|
+
(err.keyword === 'const' || err.keyword === 'enum') &&
|
|
52
|
+
(err.instancePath === '' || err.instancePath === '/type')) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const parts = err.instancePath.split('/').filter(Boolean);
|
|
56
|
+
const fieldName = parts.length > 0 ? parts[parts.length - 1] : 'root';
|
|
57
|
+
let message = err.message;
|
|
58
|
+
let key = `${err.instancePath}:${err.keyword}`;
|
|
59
|
+
// Enhance const errors
|
|
60
|
+
if (err.keyword === 'const' && err.params?.allowedValue !== undefined) {
|
|
61
|
+
const actual = err.data !== undefined ? `"${err.data}"` : 'missing value';
|
|
62
|
+
const expected = `"${err.params.allowedValue}"`;
|
|
63
|
+
message = `${fieldName}: expected ${expected}, got ${actual}`;
|
|
64
|
+
key = `${err.instancePath}:const:${err.params.allowedValue}`;
|
|
65
|
+
}
|
|
66
|
+
// Enhance enum errors
|
|
67
|
+
else if (err.keyword === 'enum' && err.params?.allowedValues && Array.isArray(err.params.allowedValues)) {
|
|
68
|
+
let actual = err.data !== undefined ? `"${err.data}"` : null;
|
|
69
|
+
// If err.data is undefined, try to get the value from nodeData using the field name
|
|
70
|
+
if (actual === null && fieldName !== 'root') {
|
|
71
|
+
const actualValue = nodeData[fieldName];
|
|
72
|
+
if (actualValue !== undefined) {
|
|
73
|
+
actual = `"${actualValue}"`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (actual === null) {
|
|
77
|
+
actual = 'missing value';
|
|
78
|
+
}
|
|
79
|
+
const allowed = err.params.allowedValues.map((v) => `"${v}"`).join(', ');
|
|
80
|
+
message = `${fieldName}: ${actual} is not valid. Allowed: ${allowed}`;
|
|
81
|
+
key = `${err.instancePath}:enum:${err.params.allowedValues.join(',')}`;
|
|
82
|
+
}
|
|
83
|
+
// Generic message with path
|
|
84
|
+
else {
|
|
85
|
+
message = `${fieldName}: ${err.message}`;
|
|
86
|
+
}
|
|
87
|
+
if (!seen.has(key)) {
|
|
88
|
+
seen.add(key);
|
|
89
|
+
formatted.push({ message, dedupeKey: key });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return formatted;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Run full validation on a space and return a structured result.
|
|
97
|
+
* Does not output to console or call process.exit.
|
|
98
|
+
*/
|
|
99
|
+
export async function validateSpace(context) {
|
|
100
|
+
const { schema, schemaRefRegistry, schemaValidator } = context;
|
|
101
|
+
const metadata = schema.metadata;
|
|
102
|
+
const readResult = await readSpace(context);
|
|
103
|
+
const { nodes, parseIssues } = readResult;
|
|
104
|
+
// Pre-extract valid types for early type validation
|
|
105
|
+
const validTypes = Array.isArray(schema.oneOf) ? extractEntityInfo(schema, schemaRefRegistry).map((e) => e.type) : [];
|
|
106
|
+
const result = {
|
|
107
|
+
validCount: 0,
|
|
108
|
+
nodeErrorCount: 0,
|
|
109
|
+
nodeErrors: [],
|
|
110
|
+
refErrors: [],
|
|
111
|
+
duplicateErrors: [],
|
|
112
|
+
ruleViolations: [],
|
|
113
|
+
hierarchyViolations: [],
|
|
114
|
+
orphans: [],
|
|
115
|
+
parseIssues,
|
|
116
|
+
};
|
|
117
|
+
for (const node of nodes) {
|
|
118
|
+
// Early type validation - check before full AJV validation to prevent cascading errors
|
|
119
|
+
const nodeType = node.schemaData.type;
|
|
120
|
+
if (nodeType !== undefined && validTypes.length > 0 && !validTypes.includes(nodeType)) {
|
|
121
|
+
result.nodeErrorCount++;
|
|
122
|
+
result.nodeErrors.push({
|
|
123
|
+
file: node.label,
|
|
124
|
+
errors: [
|
|
125
|
+
{
|
|
126
|
+
instancePath: '/type',
|
|
127
|
+
keyword: 'enum',
|
|
128
|
+
message: `Invalid type "${nodeType}". Valid types are: ${validTypes.sort().join(', ')}`,
|
|
129
|
+
params: { allowedValues: validTypes },
|
|
130
|
+
schemaPath: '#/oneOf',
|
|
131
|
+
data: nodeType,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
nodeData: node.schemaData,
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const valid = schemaValidator(node.schemaData);
|
|
139
|
+
if (valid) {
|
|
140
|
+
result.validCount++;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
result.nodeErrorCount++;
|
|
144
|
+
result.nodeErrors.push({
|
|
145
|
+
file: node.label,
|
|
146
|
+
errors: schemaValidator.errors || [],
|
|
147
|
+
nodeData: node.schemaData,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Detect duplicate node keys (titles)
|
|
152
|
+
const titleToFiles = new Map();
|
|
153
|
+
for (const node of nodes) {
|
|
154
|
+
const title = node.title;
|
|
155
|
+
if (!titleToFiles.has(title)) {
|
|
156
|
+
titleToFiles.set(title, []);
|
|
157
|
+
}
|
|
158
|
+
titleToFiles.get(title).push(node.label);
|
|
159
|
+
}
|
|
160
|
+
for (const [title, files] of titleToFiles) {
|
|
161
|
+
if (files.length > 1) {
|
|
162
|
+
result.duplicateErrors.push({ title, files });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Validate all hierarchy constraints (field references and structure)
|
|
166
|
+
const hierarchyValidation = validateGraph(nodes, metadata, readResult.unresolvedRefs);
|
|
167
|
+
result.refErrors.push(...hierarchyValidation.refErrors);
|
|
168
|
+
result.hierarchyViolations = [...hierarchyValidation.violations];
|
|
169
|
+
// Calculate orphan count (informational, not a validation error)
|
|
170
|
+
if (metadata.hierarchy) {
|
|
171
|
+
result.orphans = [...buildSpaceGraph(nodes, metadata.hierarchy.levels).orphans];
|
|
172
|
+
}
|
|
173
|
+
// Load and execute rules validation if schema defines rules
|
|
174
|
+
if (metadata.rules) {
|
|
175
|
+
result.ruleViolations = await validateRules(nodes, metadata.rules);
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
/** Find which space a file belongs to by checking directory containment. */
|
|
180
|
+
function resolveFileSpace(filePath, config) {
|
|
181
|
+
const absFile = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
|
|
182
|
+
for (const space of config.spaces) {
|
|
183
|
+
const absSpace = isAbsolute(space.path) ? space.path : resolve(process.cwd(), space.path);
|
|
184
|
+
// Trailing slash prevents prefix-match false positives (e.g. /foo matching /foobar/)
|
|
185
|
+
const spaceDir = absSpace.endsWith('/') ? absSpace : `${absSpace}/`;
|
|
186
|
+
if (absFile.startsWith(spaceDir) || absFile === absSpace) {
|
|
187
|
+
return { space, label: relative(absSpace, absFile) };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
function buildContextForSpace(space, config) {
|
|
193
|
+
const resolvedSchemaPath = resolveSchema(config, space);
|
|
194
|
+
const { schema, schemaRefRegistry, schemaValidator } = loadSchema(resolvedSchemaPath);
|
|
195
|
+
const configDir = getSpaceConfigDir(space.name);
|
|
196
|
+
return { space, config, resolvedSchemaPath, schema, schemaRefRegistry, schemaValidator, configDir };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Validate a single file within its space.
|
|
200
|
+
*
|
|
201
|
+
* Reads the full space (required for graph correctness) but filters all reported
|
|
202
|
+
* errors to only those attributable to the target file.
|
|
203
|
+
*
|
|
204
|
+
* Returns a result object — does not output to console or call process.exit.
|
|
205
|
+
* If the file is not in any configured space, returns a result with `inSpace: false`.
|
|
206
|
+
*/
|
|
207
|
+
export async function validateFile(filePath, config) {
|
|
208
|
+
const resolved = resolveFileSpace(filePath, config);
|
|
209
|
+
if (!resolved) {
|
|
210
|
+
return {
|
|
211
|
+
file: filePath,
|
|
212
|
+
inSpace: false,
|
|
213
|
+
message: 'File does not belong to any configured space.',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const { space, label } = resolved;
|
|
217
|
+
const context = buildContextForSpace(space, config);
|
|
218
|
+
const { schema, schemaRefRegistry, schemaValidator } = context;
|
|
219
|
+
const metadata = schema.metadata;
|
|
220
|
+
const readResult = await readSpace(context);
|
|
221
|
+
const { nodes } = readResult;
|
|
222
|
+
// Pre-extract valid types for early type validation
|
|
223
|
+
const validTypes = Array.isArray(schema.oneOf) ? extractEntityInfo(schema, schemaRefRegistry).map((e) => e.type) : [];
|
|
224
|
+
const errors = {};
|
|
225
|
+
// Schema validation errors for this node
|
|
226
|
+
for (const node of nodes) {
|
|
227
|
+
if (node.label !== label)
|
|
228
|
+
continue;
|
|
229
|
+
// Early type validation - check before full AJV validation to prevent cascading errors
|
|
230
|
+
const nodeType = node.schemaData.type;
|
|
231
|
+
if (nodeType !== undefined && validTypes.length > 0 && !validTypes.includes(nodeType)) {
|
|
232
|
+
errors[`schema:type:${validTypes.join(',')}`] = {
|
|
233
|
+
kind: 'schema',
|
|
234
|
+
message: `Invalid type "${nodeType}". Valid types are: ${validTypes.sort().join(', ')}`,
|
|
235
|
+
};
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const valid = schemaValidator(node.schemaData);
|
|
239
|
+
if (!valid) {
|
|
240
|
+
const formatted = formatErrors(schemaValidator.errors ?? [], schema, schemaRefRegistry, node.schemaData);
|
|
241
|
+
for (const { message, dedupeKey } of formatted) {
|
|
242
|
+
errors[`schema:${dedupeKey}`] = { kind: 'schema', message };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Duplicate key errors — include if this file is one of the duplicates
|
|
247
|
+
const titleToFiles = new Map();
|
|
248
|
+
for (const node of nodes) {
|
|
249
|
+
if (!titleToFiles.has(node.title))
|
|
250
|
+
titleToFiles.set(node.title, []);
|
|
251
|
+
titleToFiles.get(node.title).push(node.label);
|
|
252
|
+
}
|
|
253
|
+
for (const [title, files] of titleToFiles) {
|
|
254
|
+
if (files.length > 1 && files.includes(label)) {
|
|
255
|
+
const others = files.filter((f) => f !== label);
|
|
256
|
+
errors[`duplicate:${title}`] = {
|
|
257
|
+
kind: 'duplicate',
|
|
258
|
+
message: `Duplicate title "${title}" also exists in: ${others.join(', ')}`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Broken links and hierarchy violations from this file
|
|
263
|
+
const hierarchyValidation = validateGraph(nodes, metadata, readResult.unresolvedRefs);
|
|
264
|
+
for (const { file, parent, error } of hierarchyValidation.refErrors) {
|
|
265
|
+
if (file === label) {
|
|
266
|
+
errors[`broken-link:${parent}`] = { kind: 'broken-link', message: `${parent} → ${error}` };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const v of hierarchyValidation.violations) {
|
|
270
|
+
if (v.file === label) {
|
|
271
|
+
errors[`hierarchy:${v.description}`] = { kind: 'hierarchy', message: v.description };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Rule violations for this node
|
|
275
|
+
if (metadata.rules) {
|
|
276
|
+
const ruleViolations = await validateRules(nodes, metadata.rules);
|
|
277
|
+
for (const v of ruleViolations) {
|
|
278
|
+
if (v.file === label) {
|
|
279
|
+
errors[`rule:${v.ruleId}`] = { kind: 'rule', message: `[${v.ruleId}] ${v.description}` };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
file: isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath),
|
|
285
|
+
label,
|
|
286
|
+
space: space.name,
|
|
287
|
+
errors,
|
|
288
|
+
errorCount: Object.keys(errors).length,
|
|
289
|
+
inSpace: true,
|
|
290
|
+
};
|
|
291
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "structured-context",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Keep your content clean and your thinking clear with assistive tools for structured knowledge",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -18,9 +18,13 @@
|
|
|
18
18
|
],
|
|
19
19
|
"exports": {
|
|
20
20
|
".": "./dist/index.js",
|
|
21
|
-
"./
|
|
22
|
-
"types": "./dist/
|
|
23
|
-
"default": "./dist/
|
|
21
|
+
"./api": {
|
|
22
|
+
"types": "./dist/api.d.ts",
|
|
23
|
+
"default": "./dist/api.js"
|
|
24
|
+
},
|
|
25
|
+
"./types": {
|
|
26
|
+
"types": "./dist/types.d.ts",
|
|
27
|
+
"default": "./dist/types.js"
|
|
24
28
|
}
|
|
25
29
|
},
|
|
26
30
|
"scripts": {
|
|
@@ -57,8 +61,10 @@
|
|
|
57
61
|
"ajv": "^8.18.0",
|
|
58
62
|
"chokidar": "^5.0.0",
|
|
59
63
|
"commander": "^14.0.3",
|
|
64
|
+
"fast-glob": "^3.3.3",
|
|
60
65
|
"gray-matter": "^4.0.3",
|
|
61
66
|
"js-yaml": "^4.1.1",
|
|
67
|
+
"json5": "^2.2.3",
|
|
62
68
|
"jsonata": "^2.1.0",
|
|
63
69
|
"mdast-util-to-string": "^4.0.0",
|
|
64
70
|
"remark-gfm": "^4.0.1",
|
package/dist/plugin-api.d.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Public API for external structured-context plugins.
|
|
3
|
-
*
|
|
4
|
-
* Import from this module to get the types needed to implement a StructuredContextPlugin:
|
|
5
|
-
*
|
|
6
|
-
* import type { StructuredContextPlugin, PluginContext, ParseResult } from 'structured-context/plugin-api';
|
|
7
|
-
*/
|
|
8
|
-
export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
|
|
9
|
-
export type { ParseHook, ParseResult, PluginContext, RenderFormat, RenderHook, RenderOptions, StructuredContextPlugin, TemplateSyncHook, TemplateSyncOptions, } from './plugins/util';
|
|
10
|
-
export type { SharedEmbeddingFields } from './schema/metadata-contract';
|
|
11
|
-
export type { SpaceGraph } from './space-graph';
|
|
12
|
-
export type { BaseNode, EdgeDefinition, HierarchyLevel, ParseIssue, Relationship, SchemaMetadata, SchemaWithMetadata, SpaceContext, SpaceNode, UnresolvedRef, } from './types';
|
package/dist/plugin-api.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Public API for external structured-context plugins.
|
|
3
|
-
*
|
|
4
|
-
* Import from this module to get the types needed to implement a StructuredContextPlugin:
|
|
5
|
-
*
|
|
6
|
-
* import type { StructuredContextPlugin, PluginContext, ParseResult } from 'structured-context/plugin-api';
|
|
7
|
-
*/
|