structured-context 0.9.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +22 -3
  2. package/dist/api.d.ts +33 -0
  3. package/dist/api.js +26 -0
  4. package/dist/commands/diagram.js +1 -1
  5. package/dist/commands/dump.js +1 -1
  6. package/dist/commands/miro-sync.d.ts +3 -0
  7. package/dist/commands/miro-sync.js +8 -0
  8. package/dist/commands/schemas.js +3 -2
  9. package/dist/commands/spaces.js +0 -2
  10. package/dist/commands/validate-file.d.ts +5 -24
  11. package/dist/commands/validate-file.js +26 -108
  12. package/dist/commands/validate.d.ts +1 -12
  13. package/dist/commands/validate.js +22 -157
  14. package/dist/config.d.ts +8 -12
  15. package/dist/config.js +25 -23
  16. package/dist/index.js +15 -23
  17. package/dist/load-space-graph.d.ts +40 -0
  18. package/dist/load-space-graph.js +42 -0
  19. package/dist/plugins/index.js +3 -1
  20. package/dist/plugins/loader.js +2 -2
  21. package/dist/plugins/markdown/extract-content-links.d.ts +23 -0
  22. package/dist/plugins/markdown/extract-content-links.js +144 -0
  23. package/dist/plugins/markdown/index.d.ts +42 -32
  24. package/dist/plugins/markdown/index.js +10 -15
  25. package/dist/plugins/markdown/parse-embedded.d.ts +1 -1
  26. package/dist/plugins/markdown/parse-embedded.js +9 -0
  27. package/dist/plugins/markdown/read-space.js +6 -3
  28. package/dist/plugins/markdown/template-sync.d.ts +1 -1
  29. package/dist/plugins/markdown/template-sync.js +2 -2
  30. package/dist/plugins/mermaid/index.d.ts +2 -0
  31. package/dist/plugins/mermaid/index.js +14 -0
  32. package/dist/plugins/{markdown → mermaid}/render-mermaid.js +17 -16
  33. package/dist/{integrations → plugins}/miro/cache.d.ts +1 -1
  34. package/dist/plugins/miro/index.d.ts +2 -0
  35. package/dist/plugins/miro/index.js +27 -0
  36. package/dist/{integrations → plugins}/miro/layout.d.ts +2 -2
  37. package/dist/{integrations → plugins}/miro/layout.js +3 -16
  38. package/dist/{integrations → plugins}/miro/styles.d.ts +2 -2
  39. package/dist/plugins/miro/sync.d.ts +7 -0
  40. package/dist/{integrations → plugins}/miro/sync.js +23 -41
  41. package/dist/plugins/util.d.ts +9 -0
  42. package/dist/plugins/util.js +4 -0
  43. package/dist/read/resolve-graph-edges.js +32 -0
  44. package/dist/render/render.d.ts +1 -0
  45. package/dist/render/render.js +20 -16
  46. package/dist/schema/evaluate-rule.js +1 -0
  47. package/dist/schema/schema.d.ts +3 -1
  48. package/dist/schema/schema.js +7 -3
  49. package/dist/schemas/_knowledge_wiki.json +1 -1
  50. package/dist/space-context.d.ts +24 -0
  51. package/dist/space-context.js +41 -0
  52. package/dist/types.d.ts +72 -2
  53. package/dist/validate.d.ts +59 -0
  54. package/dist/validate.js +316 -0
  55. package/docs/concepts.md +23 -0
  56. package/docs/config.md +2 -0
  57. package/package.json +11 -5
  58. package/schemas/_knowledge_wiki.json +1 -1
  59. package/dist/integrations/miro/sync.d.ts +0 -8
  60. package/dist/plugin-api.d.ts +0 -12
  61. package/dist/plugin-api.js +0 -7
  62. /package/dist/plugins/{markdown → mermaid}/render-mermaid.d.ts +0 -0
  63. /package/dist/{integrations → plugins}/miro/cache.js +0 -0
  64. /package/dist/{integrations → plugins}/miro/client.d.ts +0 -0
  65. /package/dist/{integrations → plugins}/miro/client.js +0 -0
  66. /package/dist/{integrations → plugins}/miro/styles.js +0 -0
@@ -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
- /** empty: no content; no-type: no type field;
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: 'empty' | 'no-type' | 'parse' | string;
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>;
@@ -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)`, `![alt](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.9.3",
4
- "description": "Tools for working with Opportunity Solution Tree structures and other product management and strategy frameworks",
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
- "./plugin-api": {
22
- "types": "./dist/plugin-api.d.ts",
23
- "default": "./dist/plugin-api.js"
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, appointments, experiential records. Your own data about your journey — not sourced from external material.",
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" },
@@ -1,8 +0,0 @@
1
- import type { SpaceContext } from '../../types';
2
- interface SyncOptions {
3
- newFrame?: string;
4
- dryRun?: boolean;
5
- verbose?: boolean;
6
- }
7
- export declare function miroSync(context: SpaceContext, options: SyncOptions): Promise<void>;
8
- export {};
@@ -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';
@@ -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