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
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Config } from './config';
|
|
2
|
+
import type { SpaceContext } from './types';
|
|
3
|
+
/** Thrown by createSpaceContext when no space matches the given name. */
|
|
4
|
+
export declare class SpaceNotFoundError extends Error {
|
|
5
|
+
readonly spaceName: string;
|
|
6
|
+
constructor(spaceName: string);
|
|
7
|
+
}
|
|
8
|
+
export interface CreateSpaceContextOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Directory to anchor relative plugin-config paths against. Defaults to the source
|
|
11
|
+
* directory recorded by loadConfig (space.sourceDir). Required for a hand-assembled
|
|
12
|
+
* Config, which has no source file — pass e.g. process.cwd(), or the directory the
|
|
13
|
+
* paths are relative to.
|
|
14
|
+
*/
|
|
15
|
+
configDir?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build a SpaceContext for a named space from an already-loaded config.
|
|
19
|
+
*
|
|
20
|
+
* Library-safe: throws {@link SpaceNotFoundError} on an unknown space rather than
|
|
21
|
+
* writing to the console or exiting the process. CLI callers that want the
|
|
22
|
+
* exit-on-error behaviour should wrap this (see buildSpaceContext in index.ts).
|
|
23
|
+
*/
|
|
24
|
+
export declare function createSpaceContext(spaceName: string, config: Config, options?: CreateSpaceContextOptions): SpaceContext;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { resolveSchema } from './config';
|
|
2
|
+
import { loadSchema } from './schema/schema';
|
|
3
|
+
/** Thrown by createSpaceContext when no space matches the given name. */
|
|
4
|
+
export class SpaceNotFoundError extends Error {
|
|
5
|
+
spaceName;
|
|
6
|
+
constructor(spaceName) {
|
|
7
|
+
super(`Unknown space "${spaceName}"`);
|
|
8
|
+
this.spaceName = spaceName;
|
|
9
|
+
this.name = 'SpaceNotFoundError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Build a SpaceContext for a named space from an already-loaded config.
|
|
14
|
+
*
|
|
15
|
+
* Library-safe: throws {@link SpaceNotFoundError} on an unknown space rather than
|
|
16
|
+
* writing to the console or exiting the process. CLI callers that want the
|
|
17
|
+
* exit-on-error behaviour should wrap this (see buildSpaceContext in index.ts).
|
|
18
|
+
*/
|
|
19
|
+
export function createSpaceContext(spaceName, config, options = {}) {
|
|
20
|
+
const space = config.spaces.find((s) => s.name === spaceName);
|
|
21
|
+
if (!space) {
|
|
22
|
+
throw new SpaceNotFoundError(spaceName);
|
|
23
|
+
}
|
|
24
|
+
const configDir = options.configDir ?? space.sourceDir;
|
|
25
|
+
if (configDir === undefined) {
|
|
26
|
+
throw new Error(`Cannot resolve a config directory for space "${spaceName}": its Config was not loaded from a ` +
|
|
27
|
+
`file. Pass options.configDir (e.g. process.cwd()) to anchor relative plugin paths, or load ` +
|
|
28
|
+
`the config with loadConfig().`);
|
|
29
|
+
}
|
|
30
|
+
const resolvedSchemaPath = resolveSchema(config, space);
|
|
31
|
+
const { schema, schemaRefRegistry, schemaValidator } = loadSchema(resolvedSchemaPath);
|
|
32
|
+
return {
|
|
33
|
+
space,
|
|
34
|
+
config,
|
|
35
|
+
resolvedSchemaPath,
|
|
36
|
+
schema,
|
|
37
|
+
schemaRefRegistry,
|
|
38
|
+
schemaValidator,
|
|
39
|
+
configDir,
|
|
40
|
+
};
|
|
41
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -56,6 +56,42 @@ export type UnresolvedRef = {
|
|
|
56
56
|
/** Human-readable message matching validate-graph output format. */
|
|
57
57
|
message: string;
|
|
58
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Shared fields for all content link variants.
|
|
61
|
+
*/
|
|
62
|
+
type ContentLinkBase = {
|
|
63
|
+
/** Display text of the link (text content for markdown links, inner text or target for wikilinks). */
|
|
64
|
+
text: string;
|
|
65
|
+
/** Raw link target: URL for markdown links, wikilink path (without [[ ]]) for wikilinks. */
|
|
66
|
+
target: string;
|
|
67
|
+
/** Whether the link navigates to a resource or transcludes/embeds it. */
|
|
68
|
+
action: 'link' | 'embed';
|
|
69
|
+
/** Anchor fragment (heading or block ref, e.g. 'heading-title' or '^blockid') if present. */
|
|
70
|
+
anchor?: string;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* A link extracted from node content at parse time, before location classification.
|
|
74
|
+
* Populated by plugins that support content link extraction (e.g. the markdown plugin).
|
|
75
|
+
*/
|
|
76
|
+
export type ContentLink = ContentLinkBase & {
|
|
77
|
+
/** Link syntax — used during resolution to determine how to classify the target. */
|
|
78
|
+
linkSyntax: 'wikilink' | 'markdown';
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* A fully classified link from node content, after location resolution.
|
|
82
|
+
* Present on SpaceNode after graph resolution.
|
|
83
|
+
*/
|
|
84
|
+
export type ResolvedContentLink = ContentLinkBase & {
|
|
85
|
+
/**
|
|
86
|
+
* Resolved location classification:
|
|
87
|
+
* - 'node' — wikilink resolved to a known space node
|
|
88
|
+
* - 'internal' — wikilink or relative path, in the source system but not a space node
|
|
89
|
+
* - 'external' — http/https URL
|
|
90
|
+
* - 'system' — file:// URL
|
|
91
|
+
* - 'protocol' — other scheme (e.g. obsidian://)
|
|
92
|
+
*/
|
|
93
|
+
location: 'node' | 'internal' | 'external' | 'system' | 'protocol';
|
|
94
|
+
};
|
|
59
95
|
/**
|
|
60
96
|
* A node as produced by a parse plugin — raw type from content, no graph resolution applied.
|
|
61
97
|
* Core enriches this into a SpaceNode after parsing.
|
|
@@ -71,6 +107,11 @@ export type BaseNode = {
|
|
|
71
107
|
linkTargets: string[];
|
|
72
108
|
/** Raw type string from content, as written by the user. */
|
|
73
109
|
type: string;
|
|
110
|
+
/**
|
|
111
|
+
* Links extracted from content body and non-edge string fields at parse time.
|
|
112
|
+
* Populated by plugins that support content link extraction; absent if not populated.
|
|
113
|
+
*/
|
|
114
|
+
contentLinks?: ContentLink[];
|
|
74
115
|
};
|
|
75
116
|
/**
|
|
76
117
|
* A fully resolved node — enriched by core after plugin parsing.
|
|
@@ -85,16 +126,21 @@ export type SpaceNode = BaseNode & {
|
|
|
85
126
|
* Always present, empty if no parents resolved.
|
|
86
127
|
*/
|
|
87
128
|
resolvedParents: ResolvedParentRef[];
|
|
129
|
+
/**
|
|
130
|
+
* Links from content body and non-edge fields, with location classified after graph resolution.
|
|
131
|
+
* Always present, empty if the plugin did not populate rawContentLinks.
|
|
132
|
+
*/
|
|
133
|
+
resolvedLinks: ResolvedContentLink[];
|
|
88
134
|
};
|
|
89
135
|
export type ParseIssue = {
|
|
90
136
|
/** Source identifier of the file or item (filename or heading title). */
|
|
91
137
|
file: string;
|
|
92
138
|
/** error: prevented the file from producing nodes; warning: file was silently excluded. */
|
|
93
139
|
severity: 'error' | 'warning';
|
|
94
|
-
/**
|
|
140
|
+
/** no-type: no type field;
|
|
95
141
|
* parse: parse failure (syntax issue or unexpected content);
|
|
96
142
|
* plugins may choose to provide additional types. */
|
|
97
|
-
type: '
|
|
143
|
+
type: 'no-type' | 'parse' | string;
|
|
98
144
|
/** Human-readable detail. */
|
|
99
145
|
message?: string;
|
|
100
146
|
};
|
|
@@ -155,3 +201,27 @@ export type SpaceContext = {
|
|
|
155
201
|
/** Directory of the config file that defines this space. Used for resolving relative paths in plugin configs. */
|
|
156
202
|
configDir: string;
|
|
157
203
|
};
|
|
204
|
+
export interface FileValidationResult {
|
|
205
|
+
file: string;
|
|
206
|
+
label: string;
|
|
207
|
+
space: string;
|
|
208
|
+
/** Errors keyed by composite id (e.g. `schema:/status:enum:active`, `rule:my-rule-id`). */
|
|
209
|
+
errors: Record<string, {
|
|
210
|
+
kind: string;
|
|
211
|
+
message: string;
|
|
212
|
+
}>;
|
|
213
|
+
errorCount: number;
|
|
214
|
+
/** Warnings keyed by composite id. Do not affect exit code or errorCount. */
|
|
215
|
+
warnings: Record<string, {
|
|
216
|
+
kind: string;
|
|
217
|
+
message: string;
|
|
218
|
+
}>;
|
|
219
|
+
warningCount: number;
|
|
220
|
+
inSpace: true;
|
|
221
|
+
}
|
|
222
|
+
export interface FileNotInSpaceResult {
|
|
223
|
+
file: string;
|
|
224
|
+
inSpace: false;
|
|
225
|
+
message: string;
|
|
226
|
+
}
|
|
227
|
+
export type ValidateFileOutput = FileValidationResult | FileNotInSpaceResult;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ErrorObject } from 'ajv';
|
|
2
|
+
import type { Config } from './config';
|
|
3
|
+
import { extractEntityInfo } from './schema/schema';
|
|
4
|
+
import type { GraphViolation, ParseIssue, ReadSpaceResult, 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
|
+
/** Content links that could not be resolved to a space node. Warning only — not counted in errors. */
|
|
31
|
+
unresolvedContentLinks: Array<{
|
|
32
|
+
file: string;
|
|
33
|
+
target: string;
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Format AJV errors for better readability.
|
|
38
|
+
* Groups related errors and extracts helpful context like allowed values.
|
|
39
|
+
*/
|
|
40
|
+
export declare function formatErrors(errors: ErrorObject[], schema: SchemaWithMetadata, schemaRefRegistry: Parameters<typeof extractEntityInfo>[1], nodeData: Record<string, unknown>): FormattedError[];
|
|
41
|
+
/**
|
|
42
|
+
* Run full validation on a space and return a structured result.
|
|
43
|
+
* Does not output to console or call process.exit.
|
|
44
|
+
*/
|
|
45
|
+
export declare function validateSpace(context: SpaceContext, options?: {
|
|
46
|
+
readResult?: ReadSpaceResult;
|
|
47
|
+
}): Promise<ValidationResult>;
|
|
48
|
+
/**
|
|
49
|
+
* Validate a single file within its space.
|
|
50
|
+
*
|
|
51
|
+
* Reads the full space (required for graph correctness) but filters all reported
|
|
52
|
+
* errors to only those attributable to the target file.
|
|
53
|
+
*
|
|
54
|
+
* Returns a result object — does not output to console or call process.exit.
|
|
55
|
+
* If the file is not in any configured space, returns a result with `inSpace: false`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function validateFile(filePath: string, config: Config, options?: {
|
|
58
|
+
configDir?: string;
|
|
59
|
+
}): Promise<ValidateFileOutput>;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
2
|
+
import { readSpace } from './read/read-space';
|
|
3
|
+
import { extractEntityInfo } from './schema/schema';
|
|
4
|
+
import { validateGraph } from './schema/validate-graph';
|
|
5
|
+
import { validateRules } from './schema/validate-rules';
|
|
6
|
+
import { createSpaceContext } from './space-context';
|
|
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, options = {}) {
|
|
100
|
+
const { schema, schemaRefRegistry, schemaValidator } = context;
|
|
101
|
+
const metadata = schema.metadata;
|
|
102
|
+
const readResult = options.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
|
+
unresolvedContentLinks: [],
|
|
117
|
+
};
|
|
118
|
+
for (const node of nodes) {
|
|
119
|
+
// Early type validation - check before full AJV validation to prevent cascading errors
|
|
120
|
+
const nodeType = node.schemaData.type;
|
|
121
|
+
if (nodeType !== undefined && validTypes.length > 0 && !validTypes.includes(nodeType)) {
|
|
122
|
+
result.nodeErrorCount++;
|
|
123
|
+
result.nodeErrors.push({
|
|
124
|
+
file: node.label,
|
|
125
|
+
errors: [
|
|
126
|
+
{
|
|
127
|
+
instancePath: '/type',
|
|
128
|
+
keyword: 'enum',
|
|
129
|
+
message: `Invalid type "${nodeType}". Valid types are: ${validTypes.sort().join(', ')}`,
|
|
130
|
+
params: { allowedValues: validTypes },
|
|
131
|
+
schemaPath: '#/oneOf',
|
|
132
|
+
data: nodeType,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
nodeData: node.schemaData,
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const valid = schemaValidator(node.schemaData);
|
|
140
|
+
if (valid) {
|
|
141
|
+
result.validCount++;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
result.nodeErrorCount++;
|
|
145
|
+
result.nodeErrors.push({
|
|
146
|
+
file: node.label,
|
|
147
|
+
errors: schemaValidator.errors || [],
|
|
148
|
+
nodeData: node.schemaData,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Detect duplicate node keys (titles)
|
|
153
|
+
const titleToFiles = new Map();
|
|
154
|
+
for (const node of nodes) {
|
|
155
|
+
const title = node.title;
|
|
156
|
+
if (!titleToFiles.has(title)) {
|
|
157
|
+
titleToFiles.set(title, []);
|
|
158
|
+
}
|
|
159
|
+
titleToFiles.get(title).push(node.label);
|
|
160
|
+
}
|
|
161
|
+
for (const [title, files] of titleToFiles) {
|
|
162
|
+
if (files.length > 1) {
|
|
163
|
+
result.duplicateErrors.push({ title, files });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Validate all hierarchy constraints (field references and structure)
|
|
167
|
+
const hierarchyValidation = validateGraph(nodes, metadata, readResult.unresolvedRefs);
|
|
168
|
+
result.refErrors.push(...hierarchyValidation.refErrors);
|
|
169
|
+
result.hierarchyViolations = [...hierarchyValidation.violations];
|
|
170
|
+
// Calculate orphan count (informational, not a validation error)
|
|
171
|
+
if (metadata.hierarchy) {
|
|
172
|
+
result.orphans = [...buildSpaceGraph(nodes, metadata.hierarchy.levels).orphans];
|
|
173
|
+
}
|
|
174
|
+
// Load and execute rules validation if schema defines rules
|
|
175
|
+
if (metadata.rules) {
|
|
176
|
+
result.ruleViolations = await validateRules(nodes, metadata.rules);
|
|
177
|
+
}
|
|
178
|
+
// Collect unresolved content links (warnings — not errors)
|
|
179
|
+
const seen = new Set();
|
|
180
|
+
for (const node of nodes) {
|
|
181
|
+
for (const link of node.resolvedLinks) {
|
|
182
|
+
if (link.location === 'internal') {
|
|
183
|
+
const key = `${node.label}::${link.target}`;
|
|
184
|
+
if (!seen.has(key)) {
|
|
185
|
+
seen.add(key);
|
|
186
|
+
result.unresolvedContentLinks.push({ file: node.label, target: link.target });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
/** Find which space a file belongs to by checking directory containment. */
|
|
194
|
+
function resolveFileSpace(filePath, config) {
|
|
195
|
+
const absFile = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
|
|
196
|
+
for (const space of config.spaces) {
|
|
197
|
+
const absSpace = isAbsolute(space.path) ? space.path : resolve(process.cwd(), space.path);
|
|
198
|
+
// Trailing slash prevents prefix-match false positives (e.g. /foo matching /foobar/)
|
|
199
|
+
const spaceDir = absSpace.endsWith('/') ? absSpace : `${absSpace}/`;
|
|
200
|
+
if (absFile.startsWith(spaceDir) || absFile === absSpace) {
|
|
201
|
+
return { space, label: relative(absSpace, absFile) };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Validate a single file within its space.
|
|
208
|
+
*
|
|
209
|
+
* Reads the full space (required for graph correctness) but filters all reported
|
|
210
|
+
* errors to only those attributable to the target file.
|
|
211
|
+
*
|
|
212
|
+
* Returns a result object — does not output to console or call process.exit.
|
|
213
|
+
* If the file is not in any configured space, returns a result with `inSpace: false`.
|
|
214
|
+
*/
|
|
215
|
+
export async function validateFile(filePath, config, options = {}) {
|
|
216
|
+
const resolved = resolveFileSpace(filePath, config);
|
|
217
|
+
if (!resolved) {
|
|
218
|
+
return {
|
|
219
|
+
file: filePath,
|
|
220
|
+
inSpace: false,
|
|
221
|
+
message: 'File does not belong to any configured space.',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const { space, label } = resolved;
|
|
225
|
+
const context = createSpaceContext(space.name, config, { configDir: options.configDir });
|
|
226
|
+
const { schema, schemaRefRegistry, schemaValidator } = context;
|
|
227
|
+
const metadata = schema.metadata;
|
|
228
|
+
const readResult = await readSpace(context);
|
|
229
|
+
const { nodes } = readResult;
|
|
230
|
+
// Pre-extract valid types for early type validation
|
|
231
|
+
const validTypes = Array.isArray(schema.oneOf) ? extractEntityInfo(schema, schemaRefRegistry).map((e) => e.type) : [];
|
|
232
|
+
const errors = {};
|
|
233
|
+
// Schema validation errors for this node
|
|
234
|
+
for (const node of nodes) {
|
|
235
|
+
if (node.label !== label)
|
|
236
|
+
continue;
|
|
237
|
+
// Early type validation - check before full AJV validation to prevent cascading errors
|
|
238
|
+
const nodeType = node.schemaData.type;
|
|
239
|
+
if (nodeType !== undefined && validTypes.length > 0 && !validTypes.includes(nodeType)) {
|
|
240
|
+
errors[`schema:type:${validTypes.join(',')}`] = {
|
|
241
|
+
kind: 'schema',
|
|
242
|
+
message: `Invalid type "${nodeType}". Valid types are: ${validTypes.sort().join(', ')}`,
|
|
243
|
+
};
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const valid = schemaValidator(node.schemaData);
|
|
247
|
+
if (!valid) {
|
|
248
|
+
const formatted = formatErrors(schemaValidator.errors ?? [], schema, schemaRefRegistry, node.schemaData);
|
|
249
|
+
for (const { message, dedupeKey } of formatted) {
|
|
250
|
+
errors[`schema:${dedupeKey}`] = { kind: 'schema', message };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Duplicate key errors — include if this file is one of the duplicates
|
|
255
|
+
const titleToFiles = new Map();
|
|
256
|
+
for (const node of nodes) {
|
|
257
|
+
if (!titleToFiles.has(node.title))
|
|
258
|
+
titleToFiles.set(node.title, []);
|
|
259
|
+
titleToFiles.get(node.title).push(node.label);
|
|
260
|
+
}
|
|
261
|
+
for (const [title, files] of titleToFiles) {
|
|
262
|
+
if (files.length > 1 && files.includes(label)) {
|
|
263
|
+
const others = files.filter((f) => f !== label);
|
|
264
|
+
errors[`duplicate:${title}`] = {
|
|
265
|
+
kind: 'duplicate',
|
|
266
|
+
message: `Duplicate title "${title}" also exists in: ${others.join(', ')}`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Broken links and hierarchy violations from this file
|
|
271
|
+
const hierarchyValidation = validateGraph(nodes, metadata, readResult.unresolvedRefs);
|
|
272
|
+
for (const { file, parent, error } of hierarchyValidation.refErrors) {
|
|
273
|
+
if (file === label) {
|
|
274
|
+
errors[`broken-link:${parent}`] = { kind: 'broken-link', message: `${parent} → ${error}` };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const v of hierarchyValidation.violations) {
|
|
278
|
+
if (v.file === label) {
|
|
279
|
+
errors[`hierarchy:${v.description}`] = { kind: 'hierarchy', message: v.description };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Rule violations for this node
|
|
283
|
+
if (metadata.rules) {
|
|
284
|
+
const ruleViolations = await validateRules(nodes, metadata.rules);
|
|
285
|
+
for (const v of ruleViolations) {
|
|
286
|
+
if (v.file === label) {
|
|
287
|
+
errors[`rule:${v.ruleId}`] = { kind: 'rule', message: `[${v.ruleId}] ${v.description}` };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Unresolved content links for this node (warnings — do not affect errorCount)
|
|
292
|
+
const warnings = {};
|
|
293
|
+
const targetNode = nodes.find((n) => n.label === label);
|
|
294
|
+
if (targetNode) {
|
|
295
|
+
const seen = new Set();
|
|
296
|
+
for (const link of targetNode.resolvedLinks) {
|
|
297
|
+
if (link.location === 'internal' && !seen.has(link.target)) {
|
|
298
|
+
seen.add(link.target);
|
|
299
|
+
warnings[`content-link:${link.target}`] = {
|
|
300
|
+
kind: 'content-link',
|
|
301
|
+
message: `Unresolved content link: ${link.target}`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
file: isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath),
|
|
308
|
+
label,
|
|
309
|
+
space: space.name,
|
|
310
|
+
errors,
|
|
311
|
+
errorCount: Object.keys(errors).length,
|
|
312
|
+
warnings,
|
|
313
|
+
warningCount: Object.keys(warnings).length,
|
|
314
|
+
inSpace: true,
|
|
315
|
+
};
|
|
316
|
+
}
|
package/docs/concepts.md
CHANGED
|
@@ -254,6 +254,29 @@ Each entry is a `ResolvedParentRef` object:
|
|
|
254
254
|
|
|
255
255
|
The `source` label lets downstream consumers distinguish edge types without re-inspecting the schema. Validation routes `hierarchy` edges to structural checks (parent-type rules, skip-level detection) and `relationship` edges to field reference checks (type-match, missing-target). Tree rendering and rule evaluation use the full set.
|
|
256
256
|
|
|
257
|
+
### Content links
|
|
258
|
+
|
|
259
|
+
**Content links** (`resolvedLinks` on `SpaceNode`) are all links extracted from a node's content at parse time — both from frontmatter non-edge fields and from the body text. They are distinct from graph edges: they are never used to infer structure, but are available to consumers for rendering, auditing, or navigation.
|
|
260
|
+
|
|
261
|
+
Each entry is a `ResolvedContentLink` object:
|
|
262
|
+
|
|
263
|
+
| Field | Type | Description |
|
|
264
|
+
|---|---|---|
|
|
265
|
+
| `text` | `string` | Display text of the link |
|
|
266
|
+
| `target` | `string` | Raw link target (URL or wikilink path) |
|
|
267
|
+
| `action` | `'link' \| 'embed'` | Whether the link navigates or transcludes |
|
|
268
|
+
| `anchor` | `string?` | Fragment component (heading or block ref) if present |
|
|
269
|
+
| `location` | `'node' \| 'internal' \| 'external' \| 'system' \| 'protocol'` | Resolved location classification |
|
|
270
|
+
|
|
271
|
+
**Location classification:**
|
|
272
|
+
- `node` — wikilink resolved to a known space node
|
|
273
|
+
- `internal` — wikilink or relative path to an in-source target that is not a space node
|
|
274
|
+
- `external` — `http://` or `https://` URL
|
|
275
|
+
- `system` — `file://` URL
|
|
276
|
+
- `protocol` — any other scheme (e.g. `obsidian://`)
|
|
277
|
+
|
|
278
|
+
**Sources:** Both wikilink syntax (`[[Target]]`, `![[Image]]`) and markdown link syntax (`[text](url)`, ``) are extracted. Bare URLs in frontmatter string fields are also captured. Graph edge fields (hierarchy `parent`, relationship fields) are excluded — those are graph edges, not content links.
|
|
279
|
+
|
|
257
280
|
### Anchor
|
|
258
281
|
|
|
259
282
|
An **anchor** is a block anchor (e.g. `^goal1`) appended to a heading in a `typed page`, using Obsidian block anchor syntax. Anchors serve two purposes:
|
package/docs/config.md
CHANGED
|
@@ -28,6 +28,8 @@ A space is a named directory or single file registered in the config. Example:
|
|
|
28
28
|
|
|
29
29
|
**`includeSpacesFrom`** — import space definitions from other config files. Useful for aggregating spaces from multiple projects into a central config. Duplicate space names are not allowed.
|
|
30
30
|
|
|
31
|
+
A config must define at least one of `spaces` or `includeSpacesFrom`. When using `includeSpacesFrom` alone, `spaces` can be omitted entirely.
|
|
32
|
+
|
|
31
33
|
## Plugins
|
|
32
34
|
|
|
33
35
|
Use `plugins` to load parse plugins that read spaces from non-markdown sources. The built-in markdown plugin is always available without any declaration. Plugins are tried in order; the first to return a result wins. The `plugins` field is a map of plugin name to plugin config, and can be declared at the top level (applies to all spaces) or per-space (overrides the top level):
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "structured-context",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.11.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",
|
|
@@ -207,7 +207,7 @@
|
|
|
207
207
|
},
|
|
208
208
|
"note": {
|
|
209
209
|
"type": "object",
|
|
210
|
-
"description": "Personal notes, journal entries,
|
|
210
|
+
"description": "Personal notes, journal entries, talk notes, or rough thoughts not drawn from an external URL.",
|
|
211
211
|
"allOf": [{ "$ref": "sctx://_sctx_base#/$defs/baseNodeProps" }],
|
|
212
212
|
"properties": {
|
|
213
213
|
"type": { "const": "note" },
|
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
|
-
*/
|
|
File without changes
|
|
File without changes
|