structured-context 0.20.0 → 0.22.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 CHANGED
@@ -45,7 +45,7 @@ Content and structure
45
45
  Rule violations (info) 1
46
46
  Hierarchy violations 0
47
47
  Orphans (hierarchy nodes - no parent) 1
48
- Unresolved content links 0
48
+ Unresolved content links (info) 0
49
49
  Excluded during parsing 0
50
50
  ```
51
51
 
@@ -70,7 +70,7 @@ bunx structured-context --help
70
70
 
71
71
  ## Setup for AI Agents
72
72
 
73
- Claude Code plugin, providing validation hooks, slash commands, and agent skills. Install it with:
73
+ Claude Code plugin, providing validation hooks, slash commands, and agent skills. In Claude Code versions with mods, it also shows each turn's new issues beside the chat, adds the session's schemas to the system prompt, and gives Claude sctx tools. Install it with:
74
74
 
75
75
  ```
76
76
  claude plugin marketplace add mindsocket/structured-context
package/dist/api.d.ts CHANGED
@@ -32,9 +32,9 @@ export { buildTargetIndex, formatWikilink, type ParsedWikilink, parseWikilink, w
32
32
  export { listSchemas } from './schema/list-schemas';
33
33
  export type { SharedEmbeddingFields } from './schema/metadata-contract';
34
34
  export { bundledSchemasDir, loadSchema, type PluginSchemaSource, setBundledSchemasDir } from './schema/schema';
35
- export { createSpaceContext, SpaceNotFoundError } from './space-context';
35
+ export { createSpaceContext } from './space-context';
36
36
  export { buildSpaceGraph } from './space-graph';
37
37
  export { exportSpaceGraph } from './space-graph-data';
38
38
  export type { BaseNode, ContentLink, EdgeDefinition, FileNotInSpaceResult, FileValidationIssue, FileValidationResult, Hierarchy, HierarchyLevel, ParseIssue, ReadSpaceResult, Relationship, ResolvedContentLink, RuleCategory, RuleSeverity, RuleViolation, SchemaMetadata, SchemaWithMetadata, SourceLocation, SpaceContext, UnresolvedRef, ValidateFileOutput, } from './types';
39
39
  export type { ValidationResult } from './validate';
40
- export { validateFile, validateSpace } from './validate';
40
+ export { nodeIssues, validateFile, validateSpace } from './validate';
package/dist/api.js CHANGED
@@ -29,7 +29,7 @@ export { readSpace } from './read/read-space';
29
29
  export { buildTargetIndex, formatWikilink, parseWikilink, wikilinkToTarget, } from './read/wikilink-utils';
30
30
  export { listSchemas } from './schema/list-schemas';
31
31
  export { bundledSchemasDir, loadSchema, setBundledSchemasDir } from './schema/schema';
32
- export { createSpaceContext, SpaceNotFoundError } from './space-context';
32
+ export { createSpaceContext } from './space-context';
33
33
  export { buildSpaceGraph } from './space-graph';
34
34
  export { exportSpaceGraph } from './space-graph-data';
35
- export { validateFile, validateSpace } from './validate';
35
+ export { nodeIssues, validateFile, validateSpace } from './validate';
@@ -42,7 +42,7 @@ function printHumanReadable(result) {
42
42
  }
43
43
  if (result.errorCount > 0) {
44
44
  console.log(`\n${red}✗${reset} ${result.label} (space: ${result.space}) — ${result.errorCount} error(s)\n`);
45
- for (const issue of Object.values(result.errors)) {
45
+ for (const issue of Object.values(result.issues).filter((i) => i.severity === 'error')) {
46
46
  console.log(` [${issueLabel(issue)}] ${issue.message}`);
47
47
  }
48
48
  }
@@ -51,12 +51,13 @@ function printHumanReadable(result) {
51
51
  }
52
52
  if (result.warningCount > 0) {
53
53
  console.log(`\n ${yellow}${result.warningCount} warning(s):${reset}`);
54
- for (const issue of Object.values(result.warnings)) {
54
+ for (const issue of Object.values(result.issues).filter((i) => i.severity !== 'error')) {
55
55
  console.log(` ${yellow}[${issueLabel(issue)}]${reset} ${issue.message}`);
56
56
  }
57
57
  }
58
58
  console.log('');
59
59
  }
60
+ // A structural error (schema, hierarchy, broken link) is always an error, so its label leaves the severity out
60
61
  function issueLabel({ kind, severity }) {
61
- return severity ? `${kind}:${severity}` : kind;
62
+ return severity === 'error' && kind !== 'rule' ? kind : `${kind}:${severity}`;
62
63
  }
@@ -3,7 +3,7 @@ import chokidar from 'chokidar';
3
3
  import { getConfigSourceFiles } from '../config';
4
4
  import { resolveSpacePluginSchemas } from '../plugins/util';
5
5
  import { bundledSchemasDir } from '../schema/schema';
6
- import { formatSource, ruleViolationMessage, validateSpace } from '../validate';
6
+ import { formatSource, nodeIssues, validateSpace } from '../validate';
7
7
  export async function validate(context, options = {}) {
8
8
  const result = await validateSpace(context, options);
9
9
  const ruleCounts = { error: 0, warning: 0, info: 0 };
@@ -11,43 +11,19 @@ export async function validate(context, options = {}) {
11
11
  ruleCounts[v.severity]++;
12
12
  // JSON output mode
13
13
  if (options.json) {
14
- const errorsByFile = {};
15
- const addError = (file, key, kind, message, severity) => {
16
- if (!errorsByFile[file])
17
- errorsByFile[file] = {};
18
- errorsByFile[file][key] = severity ? { kind, message, severity } : { kind, message };
19
- };
20
- for (const { nodeId, issues } of result.nodeErrors) {
21
- for (const { message, dedupeKey } of issues) {
22
- addError(nodeId, `schema:${dedupeKey}`, 'schema', message);
23
- }
24
- }
25
- for (const { nodeId, parent, error } of result.refErrors) {
26
- addError(nodeId, `broken-link:${parent}`, 'broken-link', `${parent} → ${error}`);
27
- }
28
- for (const { id, sources } of result.duplicateErrors) {
29
- const where = sources.length > 0 ? `: ${sources.map(formatSource).join(', ')}` : '';
30
- addError(id, `duplicate:${id}`, 'duplicate', `Duplicate id "${id}" is used by ${sources.length} nodes${where}`);
31
- }
32
- for (const v of result.ruleViolations) {
33
- if (v.nodeId && v.severity === 'error') {
34
- addError(v.nodeId, `rule:${v.ruleId}`, 'rule', ruleViolationMessage(v), v.severity);
35
- }
36
- }
14
+ const issues = nodeIssues(result, options);
15
+ const nodeErrorCount = Object.values(issues)
16
+ .flatMap((byKey) => Object.values(byKey))
17
+ .filter((issue) => issue.severity === 'error').length;
37
18
  const globalRuleErrorCount = result.ruleViolations.filter((v) => !v.nodeId && v.severity === 'error').length;
38
- for (const v of result.hierarchyViolations) {
39
- addError(v.nodeId, `hierarchy:${v.description}`, 'hierarchy', v.description);
40
- }
41
19
  const parseErrorCount = result.parseIssues.filter((i) => i.severity === 'error').length;
42
- const errorCount = Object.values(errorsByFile).reduce((sum, errs) => sum + Object.keys(errs).length, 0) +
43
- parseErrorCount +
44
- globalRuleErrorCount;
20
+ const errorCount = nodeErrorCount + parseErrorCount + globalRuleErrorCount;
45
21
  console.log(JSON.stringify({
46
22
  space: context.space.name,
47
23
  valid: errorCount === 0,
48
24
  validCount: result.validCount,
49
25
  errorCount,
50
- errors: errorsByFile,
26
+ issues,
51
27
  ruleViolations: result.ruleViolations,
52
28
  orphanCount: result.orphans.length,
53
29
  parseIssues: result.parseIssues,
@@ -187,7 +163,7 @@ export async function validate(context, options = {}) {
187
163
  console.log(fmt(' Rule violations (info)', ruleCounts.info, true, true));
188
164
  console.log(fmt(' Hierarchy violations', result.hierarchyViolations.length, true));
189
165
  console.log(fmt(' Orphans (hierarchy nodes - no parent)', result.orphans.length, true, true));
190
- console.log(fmt(' Unresolved content links', result.unresolvedContentLinks.length, true, true));
166
+ console.log(fmt(' Unresolved content links (info)', result.unresolvedContentLinks.length, true, true));
191
167
  console.log(fmt(' Excluded during parsing', result.parseIssues.length, true, parseIssueErrorCount === 0));
192
168
  if (result.depthLimit !== undefined) {
193
169
  console.log(fmt(' Skipped by depth limit', result.skippedByDepth.length, true, true));
package/dist/errors.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Stable codes for configuration and environment problems. Hooks and agent skills map a code to a
3
3
  * fix, so the message wording can change without breaking them.
4
4
  */
5
- export type SctxErrorCode = 'E_CONFIG_NOT_FOUND' | 'E_CONFIG_INVALID' | 'E_INCLUDE_NOT_FOUND' | 'E_NO_SPACES' | 'E_SPACE_PATH_MISSING' | 'E_SCHEMA_MISSING' | 'E_SCHEMA_NOT_FOUND' | 'E_SCHEMA_INVALID' | 'E_SPACE_EXISTS' | 'E_TARGET_REQUIRED' | 'E_CONFIG_NEEDS_MANUAL_EDIT' | 'E_MODEL_PROVIDER_INVALID';
5
+ export type SctxErrorCode = 'E_CONFIG_NOT_FOUND' | 'E_CONFIG_INVALID' | 'E_INCLUDE_NOT_FOUND' | 'E_NO_SPACES' | 'E_SPACE_PATH_MISSING' | 'E_SCHEMA_MISSING' | 'E_SCHEMA_NOT_FOUND' | 'E_SCHEMA_INVALID' | 'E_SPACE_EXISTS' | 'E_SPACE_NOT_FOUND' | 'E_TARGET_REQUIRED' | 'E_CONFIG_NEEDS_MANUAL_EDIT' | 'E_MODEL_PROVIDER_INVALID';
6
6
  export declare class SctxError extends Error {
7
7
  readonly code: SctxErrorCode;
8
8
  constructor(code: SctxErrorCode, message: string);
package/dist/index.js CHANGED
@@ -17,23 +17,14 @@ import { validateFileCommand } from './commands/validate-file';
17
17
  import { loadConfig, setConfigPath } from './config';
18
18
  import { CLI_NAME } from './constants';
19
19
  import { SctxError } from './errors';
20
- import { createSpaceContext, SpaceNotFoundError } from './space-context';
20
+ import { createSpaceContext } from './space-context';
21
21
  // Once node:process is loaded (vfile, via unified, loads it), Bun's console.log drops piped output
22
22
  // past 64KB when the reader is slow. process.stdout.write delivers all of it before exit.
23
23
  console.log = (...args) => {
24
24
  process.stdout.write(`${format(...args)}\n`);
25
25
  };
26
26
  export function buildSpaceContext(spaceName) {
27
- try {
28
- return createSpaceContext(spaceName, loadConfig());
29
- }
30
- catch (err) {
31
- if (err instanceof SpaceNotFoundError) {
32
- console.error(`Error: ${err.message}`);
33
- process.exit(1);
34
- }
35
- throw err;
36
- }
27
+ return createSpaceContext(spaceName, loadConfig());
37
28
  }
38
29
  const require = createRequire(import.meta.url);
39
30
  const packageJson = require('../package.json');
@@ -0,0 +1,8 @@
1
+ /** Names of the plugins bundled with structured-context. Each built-in plugin takes its `name` from here. */
2
+ export declare const BUILTIN_PLUGIN_NAMES: {
3
+ readonly markdown: "sctx-markdown";
4
+ readonly mermaid: "sctx-mermaid";
5
+ readonly miro: "sctx-miro";
6
+ };
7
+ /** Whether a plugin name, with or without the prefix, names a built-in plugin. */
8
+ export declare function isBuiltinPluginName(name: string): boolean;
@@ -0,0 +1,13 @@
1
+ import { PLUGIN_PREFIX } from '../constants';
2
+ import { normalizePluginName } from './util';
3
+ /** Names of the plugins bundled with structured-context. Each built-in plugin takes its `name` from here. */
4
+ export const BUILTIN_PLUGIN_NAMES = {
5
+ markdown: `${PLUGIN_PREFIX}markdown`,
6
+ mermaid: `${PLUGIN_PREFIX}mermaid`,
7
+ miro: `${PLUGIN_PREFIX}miro`,
8
+ };
9
+ const builtinNames = new Set(Object.values(BUILTIN_PLUGIN_NAMES));
10
+ /** Whether a plugin name, with or without the prefix, names a built-in plugin. */
11
+ export function isBuiltinPluginName(name) {
12
+ return builtinNames.has(normalizePluginName(name));
13
+ }
@@ -3,6 +3,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
3
  import Ajv2020 from 'ajv/dist/2020';
4
4
  import { getConfigSourceFiles } from '../config';
5
5
  import { builtinPlugins } from '.';
6
+ import { isBuiltinPluginName } from './builtin-names';
6
7
  import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX, shortenPluginName, } from './util';
7
8
  /**
8
9
  * Walk a plugin's configSchema and resolve any string fields annotated with
@@ -70,14 +71,13 @@ export async function discoverPlugins() {
70
71
  * relative to configDir.
71
72
  */
72
73
  export async function loadPlugins(pluginMap, configDir) {
73
- const builtinsByName = new Map(builtinPlugins.map((p) => [p.name, p]));
74
74
  const ajv = new Ajv2020();
75
75
  ajv.addFormat('path', (value) => value.length > 0 && !value.includes('\0'));
76
76
  const loaded = [];
77
77
  // External plugins: entries in the map that are not built-in names
78
78
  for (const [rawName, rawConfig] of Object.entries(pluginMap)) {
79
79
  const name = normalizePluginName(rawName);
80
- if (builtinsByName.has(name))
80
+ if (isBuiltinPluginName(name))
81
81
  continue;
82
82
  if (!name.startsWith(PLUGIN_PREFIX)) {
83
83
  throw new Error(`Plugin name must start with "${PLUGIN_PREFIX}" (got "${rawName}")`);
@@ -1,8 +1,6 @@
1
1
  import { toString as mdastToString } from 'mdast-util-to-string';
2
- import remarkGfm from 'remark-gfm';
3
- import remarkParse from 'remark-parse';
4
- import { unified } from 'unified';
5
2
  import { parseWikilink } from '../../read/wikilink-utils';
3
+ import { parseMarkdown } from './parse-markdown';
6
4
  /**
7
5
  * Extract wikilinks (and Obsidian embed wikilinks) from a plain text string.
8
6
  * Matches [[target]], ![[target]], [[target#anchor]], [[target|alias]], etc.
@@ -77,8 +75,7 @@ export function extractLinksFromAstNode(node) {
77
75
  * Parses the full document and collects links from every node in the tree.
78
76
  */
79
77
  export function extractLinksFromBody(body) {
80
- const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
81
- return extractLinksFromAstNode(tree);
78
+ return extractLinksFromAstNode(parseMarkdown(body));
82
79
  }
83
80
  /**
84
81
  * Build the set of field names that serve as graph edges (hierarchy + relationship fields).
@@ -1,5 +1,5 @@
1
1
  import { statSync } from 'node:fs';
2
- import { PLUGIN_PREFIX } from '../util';
2
+ import { BUILTIN_PLUGIN_NAMES } from '../builtin-names';
3
3
  import { readSpaceDirectory, readSpaceOnAPage } from './read-space';
4
4
  import { renderBullets } from './render-bullets';
5
5
  import { renderMarkdownDirectory } from './render-directory';
@@ -24,7 +24,7 @@ export const MARKDOWN_CONFIG_SCHEMA = {
24
24
  additionalProperties: false,
25
25
  };
26
26
  export function getMarkdownConfig(plugins) {
27
- return (plugins?.[`${PLUGIN_PREFIX}markdown`] ?? {});
27
+ return (plugins?.[BUILTIN_PLUGIN_NAMES.markdown] ?? {});
28
28
  }
29
29
  async function parse(context) {
30
30
  if (statSync(context.space.path).isFile()) {
@@ -33,7 +33,7 @@ async function parse(context) {
33
33
  return await readSpaceDirectory(context);
34
34
  }
35
35
  export const markdownPlugin = {
36
- name: `${PLUGIN_PREFIX}markdown`,
36
+ name: BUILTIN_PLUGIN_NAMES.markdown,
37
37
  configSchema: MARKDOWN_CONFIG_SCHEMA,
38
38
  parse,
39
39
  templateSync,
@@ -1,3 +1,4 @@
1
+ import type { Root } from 'mdast';
1
2
  import type { BaseNode, Relationship, SchemaMetadata } from '../../api';
2
3
  /** Type values that identify a space_on_a_page container (not themselves space nodes). */
3
4
  export declare const ON_A_PAGE_TYPES: string[];
@@ -85,11 +86,17 @@ export interface ExtractEmbeddedOptions {
85
86
  file?: string;
86
87
  /** Line offset of the body within the source file (e.g. frontmatter lines). */
87
88
  lineOffset?: number;
89
+ /** The tree of `body`, when the caller has already parsed it. */
90
+ tree?: Root;
88
91
  }
89
92
  export interface ExtractEmbeddedResult {
90
93
  nodes: BaseNode[];
91
94
  preambleNodeCount: number;
92
- terminatedHeadings: string[];
95
+ /** Headings below a horizontal rule that would otherwise have become nodes. */
96
+ terminatedHeadings: {
97
+ title: string;
98
+ line?: number;
99
+ }[];
93
100
  preambleContent?: string;
94
101
  preambleFields?: Record<string, unknown>;
95
102
  }
@@ -1,8 +1,6 @@
1
1
  import { toString as mdastToString } from 'mdast-util-to-string';
2
- import remarkGfm from 'remark-gfm';
3
- import remarkParse from 'remark-parse';
4
- import { unified } from 'unified';
5
2
  import { extractLinksFromAstNode } from './extract-content-links';
3
+ import { parseMarkdown } from './parse-markdown';
6
4
  import { applyFieldMap, parseYaml } from './util';
7
5
  /** Type values that identify a space_on_a_page container (not themselves space nodes). */
8
6
  export const ON_A_PAGE_TYPES = ['ost_on_a_page', 'space_on_a_page'];
@@ -332,7 +330,7 @@ export function extractEmbeddedNodes(body, options) {
332
330
  type: 'space_on_a_page',
333
331
  contentLinks: [],
334
332
  };
335
- const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
333
+ const tree = options.tree ?? parseMarkdown(body);
336
334
  // In typed-page mode: stack starts with the page's own virtual entry (depth 0).
337
335
  // In space_on_a_page mode: stack starts empty (first heading has no parent).
338
336
  const stack = !isOnAPageMode && page !== undefined
@@ -543,11 +541,18 @@ export function extractEmbeddedNodes(body, options) {
543
541
  /** Take this whole block out of a node's content, keeping the blank lines before it. */
544
542
  const consumeBlock = (node) => consume(claims, node, spanStart, blockStart);
545
543
  if (parseState === 'done') {
546
- if (child.type === 'heading') {
547
- const rawTitle = mdastToString(child);
548
- const { cleanText: afterBracketed } = extractBracketedFields(rawTitle);
549
- const { cleanText: title } = extractAnchor(afterBracketed);
550
- terminatedHeadings.push(title);
544
+ if (child.type === 'heading' && child.depth <= 5) {
545
+ const { cleanText: afterBracketed, fields } = extractBracketedFields(mdastToString(child));
546
+ const { cleanText: title, anchor } = extractAnchor(afterBracketed);
547
+ // On a typed page, a plain heading is body text, so losing it below the rule loses no node.
548
+ const wouldBeNode = isOnAPageMode ||
549
+ !!applyFieldMap(fields, fieldMap).type ||
550
+ (!!anchor && !!anchorToNodeType(anchor, hierarchy, relationships)) ||
551
+ !!matchEmbedding(title, getParentContextType(child.depth));
552
+ if (wouldBeNode) {
553
+ const line = child.position ? child.position.start.line + (options.lineOffset ?? 0) : undefined;
554
+ terminatedHeadings.push({ title, ...(line !== undefined ? { line } : {}) });
555
+ }
551
556
  }
552
557
  continue;
553
558
  }
@@ -0,0 +1,3 @@
1
+ import type { Root } from 'mdast';
2
+ /** Parse markdown (with GFM) into an mdast tree. */
3
+ export declare function parseMarkdown(markdown: string): Root;
@@ -0,0 +1,9 @@
1
+ import remarkGfm from 'remark-gfm';
2
+ import remarkParse from 'remark-parse';
3
+ import { unified } from 'unified';
4
+ // Built once: a frozen processor parses any number of documents.
5
+ const processor = unified().use(remarkParse).use(remarkGfm).freeze();
6
+ /** Parse markdown (with GFM) into an mdast tree. */
7
+ export function parseMarkdown(markdown) {
8
+ return processor.parse(markdown);
9
+ }
@@ -3,9 +3,24 @@ import { basename, join, resolve } from 'node:path';
3
3
  import fg from 'fast-glob';
4
4
  import matter from 'gray-matter';
5
5
  import { extractSchemaTypeNames } from '../../schema/schema';
6
- import { extractLinksFromBody, extractLinksFromFrontmatter, getEdgeFieldNames } from './extract-content-links';
6
+ import { extractLinksFromAstNode, extractLinksFromFrontmatter, getEdgeFieldNames } from './extract-content-links';
7
7
  import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded';
8
+ import { parseMarkdown } from './parse-markdown';
8
9
  import { applyFieldMap, defaultPageTitle, inferTypeFromPath, MATTER_OPTIONS, pathLinkTargets } from './util';
10
+ /** One warning per file for the headings a horizontal rule cut off, at the first of them. */
11
+ function terminatedIssue(file, headings) {
12
+ const [first] = headings;
13
+ if (!first)
14
+ return undefined;
15
+ const more = headings.length > 1 ? ` and ${headings.length - 1} more` : '';
16
+ return {
17
+ file,
18
+ severity: 'warning',
19
+ type: 'terminated',
20
+ message: `Ignored heading "${first.title}"${more} below a horizontal rule (---).`,
21
+ ...(first.line !== undefined ? { line: first.line } : {}),
22
+ };
23
+ }
9
24
  export function readSpaceOnAPage(context) {
10
25
  const { space, resolvedSchemaPath, schema: { metadata }, } = context;
11
26
  const filePath = resolve(space.path);
@@ -30,12 +45,8 @@ export function readSpaceOnAPage(context) {
30
45
  file: fileName,
31
46
  lineOffset,
32
47
  });
33
- const parseIssues = terminatedHeadings.map((heading) => ({
34
- file: heading,
35
- severity: 'warning',
36
- type: 'terminated',
37
- message: 'Ignored headings detected beyond end of hierarchy.',
38
- }));
48
+ const terminated = terminatedIssue(fileName, terminatedHeadings);
49
+ const parseIssues = terminated ? [terminated] : [];
39
50
  return { nodes, parseIssues, diagnostics: { kind: 'page', preambleNodeCount } };
40
51
  }
41
52
  export async function readSpaceDirectory(context, options) {
@@ -103,6 +114,8 @@ export async function readSpaceDirectory(context, options) {
103
114
  const pathTargets = pathLinkTargets(file);
104
115
  const fmMatch = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
105
116
  const lineOffset = fmMatch ? (fmMatch[0].match(/\n/g) || []).length : 0;
117
+ // The page's links come from the whole body; embedded nodes take theirs from their own blocks of the same tree.
118
+ const tree = parseMarkdown(parsed.content);
106
119
  const pageNode = {
107
120
  id: file,
108
121
  title,
@@ -112,7 +125,7 @@ export async function readSpaceDirectory(context, options) {
112
125
  type: pageType,
113
126
  source: { file, line: 1 },
114
127
  ...(data.content !== undefined ? { content: data.content } : {}),
115
- contentLinks: [...extractLinksFromFrontmatter(data, edgeFields), ...extractLinksFromBody(parsed.content)],
128
+ contentLinks: [...extractLinksFromFrontmatter(data, edgeFields), ...extractLinksFromAstNode(tree)],
116
129
  };
117
130
  if (!ON_A_PAGE_TYPES.includes(pageType)) {
118
131
  const { nodes: embedded, preambleContent, preambleFields, terminatedHeadings, } = extractEmbeddedNodes(parsed.content, {
@@ -122,6 +135,7 @@ export async function readSpaceDirectory(context, options) {
122
135
  fieldMap,
123
136
  file,
124
137
  lineOffset,
138
+ tree,
125
139
  });
126
140
  if (preambleFields) {
127
141
  Object.assign(pageNode.schemaData, preambleFields);
@@ -132,14 +146,9 @@ export async function readSpaceDirectory(context, options) {
132
146
  }
133
147
  nodes.push(pageNode);
134
148
  nodes.push(...embedded);
135
- for (const heading of terminatedHeadings) {
136
- parseIssues.push({
137
- file: `${file} > ${heading}`,
138
- severity: 'warning',
139
- type: 'terminated',
140
- message: 'Ignored headings detected beyond end of hierarchy.',
141
- });
142
- }
149
+ const terminated = terminatedIssue(file, terminatedHeadings);
150
+ if (terminated)
151
+ parseIssues.push(terminated);
143
152
  }
144
153
  else {
145
154
  nodes.push(pageNode);
@@ -1,7 +1,7 @@
1
- import { PLUGIN_PREFIX } from '../util';
1
+ import { BUILTIN_PLUGIN_NAMES } from '../builtin-names';
2
2
  import { renderMermaid } from './render-mermaid';
3
3
  export const mermaidPlugin = {
4
- name: `${PLUGIN_PREFIX}mermaid`,
4
+ name: BUILTIN_PLUGIN_NAMES.mermaid,
5
5
  configSchema: { type: 'object', additionalProperties: false },
6
6
  render: {
7
7
  formats: [{ name: 'graph', description: 'Mermaid graph TD diagram' }],
@@ -1,2 +1,2 @@
1
- import { type StructuredContextPlugin } from '../../api';
1
+ import type { StructuredContextPlugin } from '../../api';
2
2
  export declare const miroPlugin: StructuredContextPlugin;
@@ -1,7 +1,7 @@
1
- import { PLUGIN_PREFIX } from '../../api';
1
+ import { BUILTIN_PLUGIN_NAMES } from '../builtin-names';
2
2
  import { miroSync } from './sync';
3
3
  export const miroPlugin = {
4
- name: `${PLUGIN_PREFIX}miro`,
4
+ name: BUILTIN_PLUGIN_NAMES.miro,
5
5
  configSchema: {
6
6
  type: 'object',
7
7
  properties: {
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { PLUGIN_PREFIX as _PLUGIN_PREFIX } from '../constants';
5
+ import { isBuiltinPluginName } from './builtin-names';
5
6
  export const PLUGIN_PREFIX = _PLUGIN_PREFIX;
6
7
  export const CONFIG_PLUGINS_DIR = 'plugins';
7
8
  /** Normalize a plugin name to its canonical prefixed form. */
@@ -111,6 +112,10 @@ export function resolveSpacePluginSchemas(space, config, configDir) {
111
112
  const pluginSources = [];
112
113
  const seenPlugins = new Set();
113
114
  const maybeAddPlugin = (name) => {
115
+ // Built-in plugins ship no schemas. Resolving one as a package can query the npm registry or find an unrelated
116
+ // package of the same short name (such as `markdown`).
117
+ if (isBuiltinPluginName(name))
118
+ return;
114
119
  const shortName = shortenPluginName(name);
115
120
  if (seenPlugins.has(shortName))
116
121
  return;
@@ -131,13 +131,18 @@ function resolveEdge(nodesByType, targetIndex, edge, kind, hierarchy, selfRef, u
131
131
  }
132
132
  }
133
133
  }
134
+ /**
135
+ * An RFC 3986 scheme (`mailto:`, `tel:`, `https://`), which need not be followed by `//`.
136
+ * Two characters at least, so a Windows drive letter (`C:\`) is not a scheme.
137
+ */
138
+ const URL_SCHEME = /^([a-zA-Z][a-zA-Z0-9+.-]+):/;
134
139
  /**
135
140
  * Classify a ContentLink into a ResolvedContentLink by determining its location.
136
141
  *
137
142
  * Wikilinks are resolved against the target index: if found, location is 'node',
138
143
  * otherwise 'internal' (in the source system but not a space node).
139
144
  * Markdown links are classified by URL scheme: external (http/https), system (file://),
140
- * protocol (other scheme), or internal (relative path / no scheme).
145
+ * protocol (any other scheme, such as mailto: or obsidian://), or internal (relative path / no scheme).
141
146
  */
142
147
  function classifyContentLink(link, targetIndex) {
143
148
  const { linkSyntax, target, text, action, anchor } = link;
@@ -149,9 +154,8 @@ function classifyContentLink(link, targetIndex) {
149
154
  return { ...base, location: inIndex ? 'node' : 'internal' };
150
155
  }
151
156
  // Markdown link: classify by URL scheme.
152
- const colonIdx = target.indexOf('://');
153
- if (colonIdx > 0) {
154
- const scheme = target.slice(0, colonIdx).toLowerCase();
157
+ const scheme = URL_SCHEME.exec(target)?.[1]?.toLowerCase();
158
+ if (scheme) {
155
159
  const location = scheme === 'https' || scheme === 'http' ? 'external' : scheme === 'file' ? 'system' : 'protocol';
156
160
  return { ...base, location };
157
161
  }
@@ -1,10 +1,5 @@
1
1
  import { type Config } from './config';
2
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
3
  export interface CreateSpaceContextOptions {
9
4
  /**
10
5
  * Directory to anchor relative plugin-config paths against. Defaults to the source
@@ -20,8 +15,7 @@ export interface CreateSpaceContextOptions {
20
15
  * Synchronously resolves schemas (including plugin-contributed schemas),
21
16
  * indexes partials into the registry, and compiles the schema validator.
22
17
  *
23
- * Library-safe: throws {@link SpaceNotFoundError} on an unknown space rather than
24
- * writing to the console or exiting the process. CLI callers that want the
25
- * exit-on-error behaviour should wrap this (see buildSpaceContext in index.ts).
18
+ * Library-safe: throws SctxError (E_SPACE_NOT_FOUND) on an unknown space rather than
19
+ * writing to the console or exiting the process.
26
20
  */
27
21
  export declare function createSpaceContext(spaceName: string, config: Config, options?: CreateSpaceContextOptions): SpaceContext;
@@ -2,29 +2,19 @@ import { resolveSchema } from './config';
2
2
  import { SctxError } from './errors';
3
3
  import { resolveSpacePluginSchemas } from './plugins/util';
4
4
  import { loadSchema } from './schema/schema';
5
- /** Thrown by createSpaceContext when no space matches the given name. */
6
- export class SpaceNotFoundError extends Error {
7
- spaceName;
8
- constructor(spaceName) {
9
- super(`Unknown space "${spaceName}"`);
10
- this.spaceName = spaceName;
11
- this.name = 'SpaceNotFoundError';
12
- }
13
- }
14
5
  /**
15
6
  * Build a SpaceContext for a named space from an already-loaded config.
16
7
  *
17
8
  * Synchronously resolves schemas (including plugin-contributed schemas),
18
9
  * indexes partials into the registry, and compiles the schema validator.
19
10
  *
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).
11
+ * Library-safe: throws SctxError (E_SPACE_NOT_FOUND) on an unknown space rather than
12
+ * writing to the console or exiting the process.
23
13
  */
24
14
  export function createSpaceContext(spaceName, config, options = {}) {
25
15
  const space = config.spaces.find((s) => s.name === spaceName);
26
16
  if (!space) {
27
- throw new SpaceNotFoundError(spaceName);
17
+ throw new SctxError('E_SPACE_NOT_FOUND', `Unknown space "${spaceName}"`);
28
18
  }
29
19
  const configDir = options.configDir ?? space.sourceDir;
30
20
  if (configDir === undefined) {
package/dist/types.d.ts CHANGED
@@ -104,7 +104,7 @@ export type ResolvedContentLink = ContentLinkBase & {
104
104
  * - 'internal' — wikilink or relative path, in the source system but not a space node
105
105
  * - 'external' — http/https URL
106
106
  * - 'system' — file:// URL
107
- * - 'protocol' — other scheme (e.g. obsidian://)
107
+ * - 'protocol' — other scheme (e.g. obsidian://, mailto:)
108
108
  */
109
109
  location: 'node' | 'internal' | 'external' | 'system' | 'protocol';
110
110
  };
@@ -248,10 +248,11 @@ export type SpaceContext = {
248
248
  configDir: string;
249
249
  };
250
250
  export interface FileValidationIssue {
251
+ /** The check it came from: `schema`, `duplicate`, `broken-link`, `hierarchy`, `rule`, `gated` or `content-link`. */
251
252
  kind: string;
252
253
  message: string;
253
- /** Present for rule violations (`kind: 'rule'`). */
254
- severity?: RuleSeverity;
254
+ /** How serious the issue is; consumers read it rather than infer it from `kind`. */
255
+ severity: RuleSeverity;
255
256
  /** 1-indexed line number in source file, if available. */
256
257
  line?: number;
257
258
  /** 1-indexed column number in source file, if available. */
@@ -261,14 +262,11 @@ export interface FileValidationResult {
261
262
  file: string;
262
263
  label: string;
263
264
  space: string;
264
- /** Errors keyed by composite id (e.g. `schema:/status:enum:active`, `rule:my-rule-id`). */
265
- errors: Record<string, FileValidationIssue>;
265
+ /** The file's issues of every severity, keyed by composite id (e.g. `schema:/status:enum:active`, `rule:my-rule-id`). */
266
+ issues: Record<string, FileValidationIssue>;
267
+ /** Issues at `error` severity: only these fail validation. */
266
268
  errorCount: number;
267
- /**
268
- * Warnings keyed by composite id, including `warning` and `info` severity rule violations.
269
- * Do not affect exit code or errorCount.
270
- */
271
- warnings: Record<string, FileValidationIssue>;
269
+ /** Issues at `warning` or `info` severity. */
272
270
  warningCount: number;
273
271
  inSpace: true;
274
272
  }
@@ -3,7 +3,7 @@ import type { Config, SpaceConfig } from './config';
3
3
  import type { SemanticJudgeProvider } from './judge/types';
4
4
  import { type RuleFilter } from './schema/rule-filter';
5
5
  import { extractEntityInfo } from './schema/schema';
6
- import type { CoverageGate, FileNotInSpaceResult, FileValidationResult, GraphViolation, NodeId, ParseIssue, ReadSpaceResult, RuleViolation, SchemaWithMetadata, SourceLocation, SpaceContext, SpaceNode, ValidateFileOutput } from './types';
6
+ import type { CoverageGate, FileNotInSpaceResult, FileValidationIssue, FileValidationResult, GraphViolation, NodeId, ParseIssue, ReadSpaceResult, RuleViolation, SchemaWithMetadata, SourceLocation, SpaceContext, SpaceNode, ValidateFileOutput } from './types';
7
7
  export interface FormattedError {
8
8
  message: string;
9
9
  dedupeKey: string;
@@ -33,10 +33,11 @@ export interface ValidationResult {
33
33
  hierarchyViolations: GraphViolation[];
34
34
  orphans: SpaceNode[];
35
35
  parseIssues: ParseIssue[];
36
- /** Content links that could not be resolved to a space node. Warning only — not counted in errors. */
36
+ /** Content links that could not be resolved to a space node, at `info` severity: not counted in errors. */
37
37
  unresolvedContentLinks: Array<{
38
38
  nodeId: NodeId;
39
39
  target: string;
40
+ severity: 'info';
40
41
  }>;
41
42
  /**
42
43
  * Cascade gates: nodes whose schema/hierarchy failure or blocking rule violation
@@ -119,7 +120,7 @@ export declare function findDuplicateIds(nodes: SpaceNode[]): ValidationResult['
119
120
  * and `validateFile` are views over it, so a file's results match its entry in a full run.
120
121
  * `judgeTarget` limits judging to one file (and the ancestors that can gate it).
121
122
  */
122
- export declare function validateNodes(context: SpaceContext, nodes: SpaceNode[], unresolvedRefs: ReadSpaceResult['unresolvedRefs'], options?: RunOptions): Promise<SpaceValidationResult>;
123
+ export declare function validateNodes(context: SpaceContext, nodes: SpaceNode[], read: Pick<ReadSpaceResult, 'unresolvedRefs' | 'parseIssues'>, options?: RunOptions): Promise<SpaceValidationResult>;
123
124
  /**
124
125
  * Run full validation on a space and return a structured result.
125
126
  * Does not output to console or call process.exit.
@@ -133,6 +134,17 @@ export declare function resolveFileSpace(filePath: string, config: Config): {
133
134
  export interface ValidateFileOptions extends CommonValidateOptions {
134
135
  configDir?: string;
135
136
  }
137
+ /** The parts of a validation result that `nodeIssues` reads. */
138
+ type NodeIssueSources = Pick<ValidationResult, 'nodeErrors' | 'refErrors' | 'duplicateErrors' | 'hierarchyViolations' | 'ruleViolations' | 'coverageGates' | 'unresolvedContentLinks'>;
139
+ /**
140
+ * Every result that belongs to a node, by node id, each keyed by a composite id (e.g.
141
+ * `schema:/status:enum:active`, `rule:my-rule-id`) and carrying its severity. Rule violations without
142
+ * a node (global scope) and parse issues are not per node, so they stay in their own parts of the result.
143
+ * `includeGated` words the note on a gated node as it was validated.
144
+ */
145
+ export declare function nodeIssues(result: NodeIssueSources, options?: {
146
+ includeGated?: boolean;
147
+ }): Record<string, Record<string, FileValidationIssue>>;
136
148
  /** The result for a file that belongs to no configured space. */
137
149
  export declare function fileNotInSpace(filePath: string): FileNotInSpaceResult;
138
150
  /**
package/dist/validate.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isAbsolute, relative, resolve } from 'node:path';
2
2
  import { judgeForRun } from './judge/factory';
3
+ import { pathLinkTargets } from './plugins/markdown/util';
3
4
  import { readSpace } from './read/read-space';
4
5
  import { computeHierarchyDepths, evaluateRulesWithGating } from './schema/gating';
5
6
  import { filterRules, parseRuleFilter } from './schema/rule-filter';
@@ -162,13 +163,25 @@ export function findDuplicateIds(nodes) {
162
163
  }
163
164
  return duplicates;
164
165
  }
166
+ /** Link targets of the files a parse excluded but read, such as notes without a type. */
167
+ function excludedFileTargets(parseIssues) {
168
+ const targets = new Set();
169
+ for (const issue of parseIssues) {
170
+ if (issue.type !== 'no-type' && issue.type !== 'parse')
171
+ continue;
172
+ for (const target of pathLinkTargets(issue.file))
173
+ targets.add(target);
174
+ }
175
+ return targets;
176
+ }
165
177
  /**
166
178
  * The one validation pipeline: type and schema checks, duplicate titles, reference and hierarchy
167
179
  * checks, cascade-gated rules, and unresolved content links. Returns every entry; `validateSpace`
168
180
  * and `validateFile` are views over it, so a file's results match its entry in a full run.
169
181
  * `judgeTarget` limits judging to one file (and the ancestors that can gate it).
170
182
  */
171
- export async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
183
+ export async function validateNodes(context, nodes, read, options = {}) {
184
+ const { unresolvedRefs } = read;
172
185
  const { schema, schemaRefRegistry, schemaValidator } = context;
173
186
  const metadata = schema.metadata;
174
187
  // Pre-extract valid types for early type validation
@@ -236,15 +249,17 @@ export async function validateNodes(context, nodes, unresolvedRefs, options = {}
236
249
  result.coverageGates = gates;
237
250
  result.contextTrimmed = summariseTrim(trimReport);
238
251
  }
239
- // Collect unresolved content links (warnings — not errors)
252
+ // Collect unresolved content links (info — not errors). A link to a file the parser read but
253
+ // excluded (e.g. no type) resolves; that file's own parse issue already says why it is not a node.
254
+ const excludedTargets = excludedFileTargets(read.parseIssues);
240
255
  const seen = new Set();
241
256
  for (const node of nodes) {
242
257
  for (const link of node.resolvedLinks) {
243
- if (link.location === 'internal') {
258
+ if (link.location === 'internal' && !excludedTargets.has(link.target)) {
244
259
  const key = `${node.id}::${link.target}`;
245
260
  if (!seen.has(key)) {
246
261
  seen.add(key);
247
- result.unresolvedContentLinks.push({ nodeId: node.id, target: link.target });
262
+ result.unresolvedContentLinks.push({ nodeId: node.id, target: link.target, severity: 'info' });
248
263
  }
249
264
  }
250
265
  }
@@ -278,15 +293,11 @@ export async function validateSpace(context, options = {}) {
278
293
  }
279
294
  nodes = included;
280
295
  }
281
- const result = await validateNodes(context, nodes, readResult.unresolvedRefs, runOptions);
296
+ const result = await validateNodes(context, nodes, readResult, runOptions);
282
297
  return {
283
298
  validCount: result.validCount,
284
299
  nodeErrorCount: result.nodeFailures.length,
285
- nodeErrors: result.nodeFailures.map(({ node, issues }) => ({
286
- nodeId: node.id,
287
- ...(node.source !== undefined && { source: node.source }),
288
- issues,
289
- })),
300
+ nodeErrors: toNodeErrors(result.nodeFailures),
290
301
  refErrors: result.refErrors,
291
302
  duplicateErrors: result.duplicateErrors,
292
303
  ruleViolations: result.ruleViolations,
@@ -314,75 +325,67 @@ export function resolveFileSpace(filePath, config) {
314
325
  return null;
315
326
  }
316
327
  /**
317
- * Select one file's entries from a space's validation result: issues keyed and formatted, errors split from warnings.
318
- * Pure — a file's entries are exactly its slice of the result.
328
+ * Every result that belongs to a node, by node id, each keyed by a composite id (e.g.
329
+ * `schema:/status:enum:active`, `rule:my-rule-id`) and carrying its severity. Rule violations without
330
+ * a node (global scope) and parse issues are not per node, so they stay in their own parts of the result.
331
+ * `includeGated` words the note on a gated node as it was validated.
319
332
  */
320
- function issuesForFile(result, label, includeGated) {
321
- const errors = {};
322
- const warnings = {};
323
- for (const { node, issues } of result.nodeFailures) {
324
- if (node.id !== label)
325
- continue;
333
+ export function nodeIssues(result, options = {}) {
334
+ const byNode = {};
335
+ const add = (nodeId, key, issue) => {
336
+ byNode[nodeId] ??= {};
337
+ byNode[nodeId][key] = issue;
338
+ };
339
+ for (const { nodeId, source, issues } of result.nodeErrors) {
326
340
  const position = {
327
- ...(node.source?.line !== undefined ? { line: node.source.line } : {}),
328
- ...(node.source?.column !== undefined ? { column: node.source.column } : {}),
341
+ ...(source?.line !== undefined ? { line: source.line } : {}),
342
+ ...(source?.column !== undefined ? { column: source.column } : {}),
329
343
  };
330
344
  for (const { message, dedupeKey } of issues) {
331
- errors[`schema:${dedupeKey}`] = { kind: 'schema', message, ...position };
345
+ add(nodeId, `schema:${dedupeKey}`, { kind: 'schema', message, severity: 'error', ...position });
332
346
  }
333
347
  }
334
- // Duplicate id errors — include if this node's id is duplicated
335
348
  for (const { id, sources } of result.duplicateErrors) {
336
- if (id !== label)
337
- continue;
338
- errors[`duplicate:${id}`] = {
349
+ const where = sources.length > 0 ? `: ${sources.map(formatSource).join(', ')}` : '';
350
+ add(id, `duplicate:${id}`, {
339
351
  kind: 'duplicate',
340
- message: `Duplicate id "${id}" is used by ${sources.length} nodes${sources.length > 0 ? `: ${sources.map(formatSource).join(', ')}` : ''}`,
341
- };
352
+ message: `Duplicate id "${id}" is used by ${sources.length} nodes${where}`,
353
+ severity: 'error',
354
+ });
342
355
  }
343
- // Broken links and hierarchy violations from this file
344
356
  for (const { nodeId, parent, error } of result.refErrors) {
345
- if (nodeId === label) {
346
- errors[`broken-link:${parent}`] = { kind: 'broken-link', message: `${parent} → ${error}` };
347
- }
357
+ add(nodeId, `broken-link:${parent}`, { kind: 'broken-link', message: `${parent} → ${error}`, severity: 'error' });
348
358
  }
349
- for (const v of result.hierarchyViolations) {
350
- if (v.nodeId === label) {
351
- errors[`hierarchy:${v.description}`] = { kind: 'hierarchy', message: v.description };
352
- }
359
+ for (const { nodeId, description } of result.hierarchyViolations) {
360
+ add(nodeId, `hierarchy:${description}`, { kind: 'hierarchy', message: description, severity: 'error' });
353
361
  }
354
- // Rule violations — only error severity counts as an error.
355
362
  for (const v of result.ruleViolations) {
356
- if (v.nodeId === label) {
357
- const target = v.severity === 'error' ? errors : warnings;
358
- target[`rule:${v.ruleId}`] = {
359
- kind: 'rule',
360
- message: ruleViolationMessage(v),
361
- severity: v.severity,
362
- };
363
- }
363
+ if (v.nodeId)
364
+ add(v.nodeId, `rule:${v.ruleId}`, { kind: 'rule', message: ruleViolationMessage(v), severity: v.severity });
364
365
  }
365
- // A gated file with no violations would otherwise look clean — say that its rules were not checked.
366
+ // A gated node with no violations would otherwise look clean: say that its rules were not checked
366
367
  for (const gate of result.coverageGates) {
367
- if (!gate.gatedNodeIds.some((id) => id === label))
368
- continue;
369
368
  const cause = gate.reason === 'rule' ? `blocking rule "${gate.ruleId}"` : `${gate.reason} failure`;
370
- warnings[`gated:${gate.nodeId}`] = {
371
- kind: 'gated',
372
- message: `Rule checks ${includeGated ? 'evaluated anyway' : 'not evaluated'}: gated by ${cause} on ${gate.nodeId}`,
373
- severity: 'info',
374
- };
369
+ const message = `Rule checks ${options.includeGated ? 'evaluated anyway' : 'not evaluated'}: gated by ${cause} on ${gate.nodeId}`;
370
+ for (const id of gate.gatedNodeIds)
371
+ add(id, `gated:${gate.nodeId}`, { kind: 'gated', message, severity: 'info' });
375
372
  }
376
- // Unresolved content links (warnings — do not affect errorCount)
377
- for (const link of result.unresolvedContentLinks) {
378
- if (link.nodeId === label) {
379
- warnings[`content-link:${link.target}`] = {
380
- kind: 'content-link',
381
- message: `Unresolved content link: ${link.target}`,
382
- };
383
- }
373
+ for (const { nodeId, target, severity } of result.unresolvedContentLinks) {
374
+ add(nodeId, `content-link:${target}`, {
375
+ kind: 'content-link',
376
+ message: `Unresolved content link: ${target}`,
377
+ severity,
378
+ });
384
379
  }
385
- return { errors, warnings };
380
+ return byNode;
381
+ }
382
+ /** `ValidationResult['nodeErrors']` from the failures `validateNodes` collects */
383
+ function toNodeErrors(failures) {
384
+ return failures.map(({ node, issues }) => ({
385
+ nodeId: node.id,
386
+ ...(node.source !== undefined && { source: node.source }),
387
+ issues,
388
+ }));
386
389
  }
387
390
  /** The result for a file that belongs to no configured space. */
388
391
  export function fileNotInSpace(filePath) {
@@ -402,19 +405,20 @@ export function validateResolvedFile(filePath, label, context, options = {}) {
402
405
  /** `validateResolvedFile` with the options already resolved, so a caller can supply its own judge. */
403
406
  export async function validateResolvedFileWith(filePath, label, context, runOptions) {
404
407
  const readResult = await readSpace(context);
405
- const result = await validateNodes(context, readResult.nodes, readResult.unresolvedRefs, {
408
+ const result = await validateNodes(context, readResult.nodes, readResult, {
406
409
  ...runOptions,
407
410
  judgeTarget: label,
408
411
  });
409
- const { errors, warnings } = issuesForFile(result, label, runOptions.includeGated);
412
+ const byNode = nodeIssues({ ...result, nodeErrors: toNodeErrors(result.nodeFailures) }, { includeGated: runOptions.includeGated });
413
+ const issues = byNode[label] ?? {};
414
+ const errorCount = Object.values(issues).filter((issue) => issue.severity === 'error').length;
410
415
  return {
411
416
  file: isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath),
412
417
  label,
413
418
  space: context.space.name,
414
- errors,
415
- errorCount: Object.keys(errors).length,
416
- warnings,
417
- warningCount: Object.keys(warnings).length,
419
+ issues,
420
+ errorCount,
421
+ warningCount: Object.keys(issues).length - errorCount,
418
422
  inSpace: true,
419
423
  };
420
424
  }
package/docs/concepts.md CHANGED
@@ -83,7 +83,7 @@ Key properties:
83
83
  - Heading hierarchy determines node depth and infers `space node` type (depth-based type inference).
84
84
  - Heading levels must not skip — each level must be exactly one deeper than its parent.
85
85
  - Typed bullets work the same as in typed pages.
86
- - A horizontal rule (`---`) terminates parsing; headings below it are ignored.
86
+ - A horizontal rule (`---`) terminates parsing; headings below it are ignored, with one warning for the file.
87
87
 
88
88
  #### Preamble
89
89
 
@@ -133,6 +133,8 @@ An **embedded node** is a `space node` defined *within* a containing document ra
133
133
 
134
134
  A `typed page` may contain embedded nodes in its body. Those nodes become full members of the parsed node set, with `parent references` wired to their containing page or enclosing heading.
135
135
 
136
+ A horizontal rule (`---`) ends parsing for embedded nodes; headings below it do not become nodes. Validation warns once per file, at the first ignored heading that would have been a node. In a `space_on_a_page` file, that is every heading below the rule.
137
+
136
138
  ### Node content
137
139
 
138
140
  A node's `content` is the markdown of its body, copied unchanged from the file: blank lines, list looseness, line endings and code fences all stay as written. The content of a list item node is dedented by the item's content indent, so it reads as markdown on its own. Leading blank lines and trailing whitespace are trimmed.
@@ -354,7 +356,9 @@ Each entry is a `ResolvedContentLink` object:
354
356
  - `internal` — wikilink or relative path to an in-source target that is not a space node
355
357
  - `external` — `http://` or `https://` URL
356
358
  - `system` — `file://` URL
357
- - `protocol` — any other scheme (e.g. `obsidian://`)
359
+ - `protocol` — any other scheme (e.g. `obsidian://`, `mailto:`, `tel:`). A bare email address is a `mailto:` link.
360
+
361
+ `validate` reports each `internal` link as an unresolved content link, at `info` severity, because a target outside the space is often valid elsewhere. A link to a file the space read but did not make a node, such as a note without a `type`, is not reported. That file's own parse warning says why it is not a node.
358
362
 
359
363
  **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.
360
364
 
package/docs/rules.md CHANGED
@@ -208,7 +208,7 @@ don't depend on an ancestor being valid.
208
208
 
209
209
  - Expressions are evaluated for the whole space; the output is restricted to the file.
210
210
  - Semantic parts are evaluated only for the file and its gating ancestors (see [Semantic rules](semantic-rules.md#how-semantic-evaluation-works)).
211
- - If the file is gated, its rules are skipped and a `gated:<file>` entry under `warnings` names the blocker, so a gated file doesn't read as clean. `--include-gated` evaluates the file's rules anyway.
211
+ - If the file is gated, its rules are skipped and a `gated:<file>` issue at `info` severity names the blocker, so a gated file doesn't read as clean. `--include-gated` evaluates the file's rules anyway.
212
212
 
213
213
  ## Controlling validation runs
214
214
 
@@ -235,8 +235,8 @@ the next). Excluded nodes are reported under `skippedByDepth`, separate from gat
235
235
  ## Output
236
236
 
237
237
  - **`sctx validate`** — the summary counts rule violations per severity, and details are grouped by severity with each line showing the rule's category and id.
238
- - **`sctx validate --json`** — `errors` includes only `error` severity rule violations (keyed `rule:<id>`, with a `severity` field). A `ruleViolations` array lists every violation with `file`, `ruleId`, `category`, `severity` and `description` (`file` is empty for global rules). A violation from a failed `expression` has the evidence `expression failed: <expression>` when the rule also has a `semantic` part, and no verdict.
239
- - **`sctx validate-file`** — `error` severity violations appear under `errors`; `warning` and `info` violations appear under `warnings`. Rule entries carry a `severity` field.
238
+ - **`sctx validate --json`** — `issues` holds each node's issues of every severity, keyed by node id and then by a composite id such as `rule:<id>`. Each issue has a `kind`, `message` and `severity`. A `ruleViolations` array lists every violation with `file`, `ruleId`, `category`, `severity` and `description` (`file` is empty for global rules). A violation from a failed `expression` has the evidence `expression failed: <expression>` when the rule also has a `semantic` part, and no verdict.
239
+ - **`sctx validate-file --json`** — `issues` holds the file's issues of every severity, each with a `severity` field. `errorCount` counts the `error` ones.
240
240
 
241
241
  Gated files: see [Single-file validation](#single-file-validation).
242
242
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "structured-context",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
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",
@@ -49,6 +49,7 @@
49
49
  "test:judge": "SCTX_LIVE_JUDGE_TESTS=1 bun test tests/judge/live.test.ts",
50
50
  "test:models": "SCTX_MODEL_VALIDATION=1 bun test tests/judge/model-validation.test.ts",
51
51
  "test:hook": "bun test hook-test/unit/",
52
+ "test:mod": "claude plugin test plugin",
52
53
  "test:hook:e2e": "bun test hook-test/hooks.test.ts",
53
54
  "test:all": "bun test",
54
55
  "lint": "biome check",