structured-context 0.21.0 → 0.22.1
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 +2 -2
- package/dist/api.d.ts +3 -2
- package/dist/api.js +2 -1
- package/dist/commands/validate-file.js +4 -3
- package/dist/commands/validate.js +8 -32
- package/dist/plugins/builtin-names.d.ts +8 -0
- package/dist/plugins/builtin-names.js +13 -0
- package/dist/plugins/loader.js +2 -2
- package/dist/plugins/markdown/extract-content-links.js +2 -5
- package/dist/plugins/markdown/index.js +3 -3
- package/dist/plugins/markdown/parse-embedded.d.ts +8 -1
- package/dist/plugins/markdown/parse-embedded.js +14 -9
- package/dist/plugins/markdown/parse-markdown.d.ts +3 -0
- package/dist/plugins/markdown/parse-markdown.js +9 -0
- package/dist/plugins/markdown/read-space.js +25 -16
- package/dist/plugins/mermaid/index.js +2 -2
- package/dist/plugins/miro/index.d.ts +1 -1
- package/dist/plugins/miro/index.js +2 -2
- package/dist/plugins/util.js +5 -0
- package/dist/read/resolve-graph-edges.js +8 -4
- package/dist/types.d.ts +12 -11
- package/dist/types.js +10 -0
- package/dist/validate.d.ts +15 -3
- package/dist/validate.js +71 -67
- package/docs/concepts.md +6 -2
- package/docs/rules.md +3 -3
- package/package.json +2 -1
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
|
|
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
|
@@ -35,6 +35,7 @@ export { bundledSchemasDir, loadSchema, type PluginSchemaSource, setBundledSchem
|
|
|
35
35
|
export { createSpaceContext } from './space-context';
|
|
36
36
|
export { buildSpaceGraph } from './space-graph';
|
|
37
37
|
export { exportSpaceGraph } from './space-graph-data';
|
|
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';
|
|
38
|
+
export type { BaseNode, ContentLink, EdgeDefinition, FileNotInSpaceResult, FileValidationIssue, FileValidationResult, Hierarchy, HierarchyLevel, ParseIssue, ReadSpaceResult, Relationship, ResolvedContentLink, RuleCategory, RuleSeverity, RuleViolation, SchemaMetadata, SchemaWithMetadata, SourceLocation, SpaceContext, UnresolvedRef, ValidateFileOutput, ValidationIssueKind, } from './types';
|
|
39
|
+
export { VALIDATION_ISSUE_KINDS } from './types';
|
|
39
40
|
export type { ValidationResult } from './validate';
|
|
40
|
-
export { validateFile, validateSpace } from './validate';
|
|
41
|
+
export { nodeIssues, validateFile, validateSpace } from './validate';
|
package/dist/api.js
CHANGED
|
@@ -32,4 +32,5 @@ export { bundledSchemasDir, loadSchema, setBundledSchemasDir } from './schema/sc
|
|
|
32
32
|
export { createSpaceContext } from './space-context';
|
|
33
33
|
export { buildSpaceGraph } from './space-graph';
|
|
34
34
|
export { exportSpaceGraph } from './space-graph-data';
|
|
35
|
-
export {
|
|
35
|
+
export { VALIDATION_ISSUE_KINDS } from './types';
|
|
36
|
+
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.
|
|
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.
|
|
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}
|
|
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,
|
|
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
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
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 =
|
|
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
|
-
|
|
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));
|
|
@@ -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
|
+
}
|
package/dist/plugins/loader.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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 {
|
|
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?.[
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
|
548
|
-
const { cleanText:
|
|
549
|
-
|
|
550
|
-
|
|
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,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 {
|
|
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
|
|
34
|
-
|
|
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), ...
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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 {
|
|
1
|
+
import { BUILTIN_PLUGIN_NAMES } from '../builtin-names';
|
|
2
2
|
import { renderMermaid } from './render-mermaid';
|
|
3
3
|
export const mermaidPlugin = {
|
|
4
|
-
name:
|
|
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 {
|
|
1
|
+
import type { StructuredContextPlugin } from '../../api';
|
|
2
2
|
export declare const miroPlugin: StructuredContextPlugin;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { BUILTIN_PLUGIN_NAMES } from '../builtin-names';
|
|
2
2
|
import { miroSync } from './sync';
|
|
3
3
|
export const miroPlugin = {
|
|
4
|
-
name:
|
|
4
|
+
name: BUILTIN_PLUGIN_NAMES.miro,
|
|
5
5
|
configSchema: {
|
|
6
6
|
type: 'object',
|
|
7
7
|
properties: {
|
package/dist/plugins/util.js
CHANGED
|
@@ -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
|
|
153
|
-
if (
|
|
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
|
}
|
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
|
};
|
|
@@ -247,11 +247,15 @@ export type SpaceContext = {
|
|
|
247
247
|
/** Directory of the config file that defines this space. Used for resolving relative paths in plugin configs. */
|
|
248
248
|
configDir: string;
|
|
249
249
|
};
|
|
250
|
+
/** The checks a validation issue can come from. */
|
|
251
|
+
export declare const VALIDATION_ISSUE_KINDS: readonly ["schema", "duplicate", "broken-link", "hierarchy", "rule", "gated", "content-link"];
|
|
252
|
+
export type ValidationIssueKind = (typeof VALIDATION_ISSUE_KINDS)[number];
|
|
250
253
|
export interface FileValidationIssue {
|
|
251
|
-
|
|
254
|
+
/** The check it came from. */
|
|
255
|
+
kind: ValidationIssueKind;
|
|
252
256
|
message: string;
|
|
253
|
-
/**
|
|
254
|
-
severity
|
|
257
|
+
/** How serious the issue is; consumers read it rather than infer it from `kind`. */
|
|
258
|
+
severity: RuleSeverity;
|
|
255
259
|
/** 1-indexed line number in source file, if available. */
|
|
256
260
|
line?: number;
|
|
257
261
|
/** 1-indexed column number in source file, if available. */
|
|
@@ -261,14 +265,11 @@ export interface FileValidationResult {
|
|
|
261
265
|
file: string;
|
|
262
266
|
label: string;
|
|
263
267
|
space: string;
|
|
264
|
-
/**
|
|
265
|
-
|
|
268
|
+
/** The file's issues of every severity, keyed by composite id (e.g. `schema:/status:enum:active`, `rule:my-rule-id`). */
|
|
269
|
+
issues: Record<string, FileValidationIssue>;
|
|
270
|
+
/** Issues at `error` severity: only these fail validation. */
|
|
266
271
|
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>;
|
|
272
|
+
/** Issues at `warning` or `info` severity. */
|
|
272
273
|
warningCount: number;
|
|
273
274
|
inSpace: true;
|
|
274
275
|
}
|
package/dist/types.js
CHANGED
package/dist/validate.d.ts
CHANGED
|
@@ -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
|
|
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[],
|
|
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,
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
*
|
|
318
|
-
*
|
|
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
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
...(
|
|
328
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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${
|
|
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
|
-
|
|
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
|
|
350
|
-
|
|
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
|
|
357
|
-
|
|
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
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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
|
|
408
|
+
const result = await validateNodes(context, readResult.nodes, readResult, {
|
|
406
409
|
...runOptions,
|
|
407
410
|
judgeTarget: label,
|
|
408
411
|
});
|
|
409
|
-
const {
|
|
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
|
-
|
|
415
|
-
errorCount
|
|
416
|
-
|
|
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)`, ``) 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>`
|
|
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`** — `
|
|
239
|
-
- **`sctx validate-file`** — `
|
|
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.
|
|
3
|
+
"version": "0.22.1",
|
|
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",
|