structured-context 0.17.0 → 0.18.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 +2 -2
- package/dist/commands/validate.js +33 -37
- package/dist/plugins/markdown/parse-embedded.js +8 -1
- package/dist/schema/evaluate-rule.d.ts +3 -3
- package/dist/schema/evaluate-rule.js +6 -6
- package/dist/schema/gating.d.ts +2 -6
- package/dist/schema/gating.js +8 -8
- package/dist/schema/validate-graph.d.ts +3 -3
- package/dist/schema/validate-graph.js +5 -5
- package/dist/schema/validate-rules.js +2 -2
- package/dist/types.d.ts +4 -3
- package/dist/validate.d.ts +16 -8
- package/dist/validate.js +41 -32
- package/docs/concepts.md +7 -1
- package/docs/rules.md +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -79,10 +79,10 @@ claude plugin install structured-context
|
|
|
79
79
|
|
|
80
80
|
Ask Claude to set up a space (or run `/setup-space`) in the folder that holds your notes. The plugin's hooks need Bun.
|
|
81
81
|
|
|
82
|
-
Skills can also be installed standalone without the plugin
|
|
82
|
+
Skills can also be installed standalone without the plugin, and kept current with `npx skills update`:
|
|
83
83
|
|
|
84
84
|
```
|
|
85
|
-
npx skills add https://github.com/mindsocket/structured-context/tree/main/plugin/skills
|
|
85
|
+
npx skills add https://github.com/mindsocket/structured-context/tree/main/plugin/skills
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
## Concepts
|
|
@@ -5,7 +5,7 @@ import { judgeForRun } from '../judge/factory';
|
|
|
5
5
|
import { resolveSpacePluginSchemas } from '../plugins/util';
|
|
6
6
|
import { parseRuleFilter } from '../schema/rule-filter';
|
|
7
7
|
import { bundledSchemasDir } from '../schema/schema';
|
|
8
|
-
import { formatErrors, ruleViolationMessage, validateSpace } from '../validate';
|
|
8
|
+
import { formatErrors, formatSource, ruleViolationMessage, validateSpace } from '../validate';
|
|
9
9
|
export async function validate(context, options = {}) {
|
|
10
10
|
const { schema, schemaRefRegistry } = context;
|
|
11
11
|
const result = await validateSpace(context, {
|
|
@@ -25,29 +25,27 @@ export async function validate(context, options = {}) {
|
|
|
25
25
|
errorsByFile[file] = {};
|
|
26
26
|
errorsByFile[file][key] = severity ? { kind, message, severity } : { kind, message };
|
|
27
27
|
};
|
|
28
|
-
for (const {
|
|
28
|
+
for (const { nodeId, errors: ajvErrors, nodeData } of result.nodeErrors) {
|
|
29
29
|
const formatted = formatErrors(ajvErrors, schema, schemaRefRegistry, nodeData);
|
|
30
30
|
for (const { message, dedupeKey } of formatted) {
|
|
31
|
-
addError(
|
|
31
|
+
addError(nodeId, `schema:${dedupeKey}`, 'schema', message);
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
-
for (const {
|
|
35
|
-
addError(
|
|
34
|
+
for (const { nodeId, parent, error } of result.refErrors) {
|
|
35
|
+
addError(nodeId, `broken-link:${parent}`, 'broken-link', `${parent} → ${error}`);
|
|
36
36
|
}
|
|
37
|
-
for (const { id,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
addError(file, `duplicate:${id}`, 'duplicate', `Duplicate id "${id}" also exists in: ${others.join(', ')}`);
|
|
41
|
-
}
|
|
37
|
+
for (const { id, sources } of result.duplicateErrors) {
|
|
38
|
+
const where = sources.length > 0 ? `: ${sources.map(formatSource).join(', ')}` : '';
|
|
39
|
+
addError(id, `duplicate:${id}`, 'duplicate', `Duplicate id "${id}" is used by ${sources.length} nodes${where}`);
|
|
42
40
|
}
|
|
43
41
|
for (const v of result.ruleViolations) {
|
|
44
|
-
if (v.
|
|
45
|
-
addError(v.
|
|
42
|
+
if (v.nodeId && v.severity === 'error') {
|
|
43
|
+
addError(v.nodeId, `rule:${v.ruleId}`, 'rule', ruleViolationMessage(v), v.severity);
|
|
46
44
|
}
|
|
47
45
|
}
|
|
48
|
-
const globalRuleErrorCount = result.ruleViolations.filter((v) => !v.
|
|
46
|
+
const globalRuleErrorCount = result.ruleViolations.filter((v) => !v.nodeId && v.severity === 'error').length;
|
|
49
47
|
for (const v of result.hierarchyViolations) {
|
|
50
|
-
addError(v.
|
|
48
|
+
addError(v.nodeId, `hierarchy:${v.description}`, 'hierarchy', v.description);
|
|
51
49
|
}
|
|
52
50
|
const parseErrorCount = result.parseIssues.filter((i) => i.severity === 'error').length;
|
|
53
51
|
const errorCount = Object.values(errorsByFile).reduce((sum, errs) => sum + Object.keys(errs).length, 0) +
|
|
@@ -100,13 +98,13 @@ export async function validate(context, options = {}) {
|
|
|
100
98
|
if (result.unresolvedContentLinks.length > 0) {
|
|
101
99
|
console.log(`\nUnresolved content links (may be outside-space or broken):`);
|
|
102
100
|
const byFile = new Map();
|
|
103
|
-
for (const {
|
|
104
|
-
if (!byFile.has(
|
|
105
|
-
byFile.set(
|
|
106
|
-
byFile.get(
|
|
101
|
+
for (const { nodeId, target } of result.unresolvedContentLinks) {
|
|
102
|
+
if (!byFile.has(nodeId))
|
|
103
|
+
byFile.set(nodeId, []);
|
|
104
|
+
byFile.get(nodeId).push(target);
|
|
107
105
|
}
|
|
108
|
-
for (const [
|
|
109
|
-
console.log(` ${
|
|
106
|
+
for (const [nodeId, targets] of byFile) {
|
|
107
|
+
console.log(` ${nodeId}:`);
|
|
110
108
|
for (const target of targets)
|
|
111
109
|
console.log(` ${target}`);
|
|
112
110
|
}
|
|
@@ -121,9 +119,8 @@ export async function validate(context, options = {}) {
|
|
|
121
119
|
}
|
|
122
120
|
if (result.nodeErrors.length > 0) {
|
|
123
121
|
console.log(`\nSchema validation errors:`);
|
|
124
|
-
result.nodeErrors.forEach(({
|
|
125
|
-
|
|
126
|
-
console.log(`\n ${file}${loc}:`);
|
|
122
|
+
result.nodeErrors.forEach(({ nodeId, source, errors, nodeData }) => {
|
|
123
|
+
console.log(`\n ${source ? formatSource(source) : nodeId}:`);
|
|
127
124
|
const formatted = formatErrors(errors, schema, schemaRefRegistry, nodeData);
|
|
128
125
|
formatted.forEach(({ message }) => {
|
|
129
126
|
console.log(` ${message}`);
|
|
@@ -132,17 +129,16 @@ export async function validate(context, options = {}) {
|
|
|
132
129
|
}
|
|
133
130
|
if (result.refErrors.length > 0) {
|
|
134
131
|
console.log(`\nBroken links:`);
|
|
135
|
-
result.refErrors.forEach(({
|
|
136
|
-
|
|
137
|
-
console.log(` ${file}${loc}: ${parent} → ${error}`);
|
|
132
|
+
result.refErrors.forEach(({ nodeId, source, parent, error }) => {
|
|
133
|
+
console.log(` ${source ? formatSource(source) : nodeId}: ${parent} → ${error}`);
|
|
138
134
|
});
|
|
139
135
|
}
|
|
140
136
|
if (result.duplicateErrors.length > 0) {
|
|
141
137
|
console.log(`\nDuplicate ids (same id on multiple nodes):`);
|
|
142
|
-
result.duplicateErrors.forEach(({ id,
|
|
138
|
+
result.duplicateErrors.forEach(({ id, sources }) => {
|
|
143
139
|
console.log(` "${id}":`);
|
|
144
|
-
for (const
|
|
145
|
-
console.log(` ${
|
|
140
|
+
for (const source of sources) {
|
|
141
|
+
console.log(` ${formatSource(source)}`);
|
|
146
142
|
}
|
|
147
143
|
});
|
|
148
144
|
}
|
|
@@ -155,7 +151,7 @@ export async function validate(context, options = {}) {
|
|
|
155
151
|
continue;
|
|
156
152
|
console.log(` ${severityColor[severity]}${severity.toUpperCase()}${reset} (${violations.length}):`);
|
|
157
153
|
for (const v of violations) {
|
|
158
|
-
console.log(` ${v.
|
|
154
|
+
console.log(` ${v.nodeId ? `${v.nodeId}: ` : ''}${v.description} [${v.category}: ${v.ruleId}]`);
|
|
159
155
|
if (v.evidence) {
|
|
160
156
|
console.log(` Evidence: ${v.evidence}`);
|
|
161
157
|
}
|
|
@@ -168,22 +164,22 @@ export async function validate(context, options = {}) {
|
|
|
168
164
|
if (result.hierarchyViolations.length > 0) {
|
|
169
165
|
console.log(`\nHierarchy violations:`);
|
|
170
166
|
for (const v of result.hierarchyViolations) {
|
|
171
|
-
console.log(` ${v.
|
|
167
|
+
console.log(` ${v.nodeId}: ${v.description}`);
|
|
172
168
|
}
|
|
173
169
|
}
|
|
174
170
|
if (result.coverageGates.length > 0) {
|
|
175
171
|
console.log(`\nCoverage — rule checks gated by cascade (use --include-gated to evaluate anyway):`);
|
|
176
172
|
for (const gate of result.coverageGates) {
|
|
177
173
|
const cause = gate.reason === 'rule' ? `blocking rule "${gate.ruleId}"` : `${gate.reason} failure`;
|
|
178
|
-
console.log(` ${gate.
|
|
179
|
-
for (const
|
|
180
|
-
console.log(` ${
|
|
174
|
+
console.log(` ${gate.nodeId} — ${cause} gated ${gate.gatedNodeIds.length} node(s):`);
|
|
175
|
+
for (const nodeId of gate.gatedNodeIds)
|
|
176
|
+
console.log(` ${nodeId}`);
|
|
181
177
|
}
|
|
182
178
|
}
|
|
183
179
|
if (result.skippedByDepth.length > 0) {
|
|
184
180
|
console.log(`\nSkipped by depth limit (--depth ${result.depthLimit}):`);
|
|
185
|
-
for (const {
|
|
186
|
-
console.log(` ${
|
|
181
|
+
for (const { nodeId } of result.skippedByDepth)
|
|
182
|
+
console.log(` ${nodeId}`);
|
|
187
183
|
}
|
|
188
184
|
if (result.contextTrimmed.nodeCount > 0) {
|
|
189
185
|
console.log(`\nContext trimmed for ${result.contextTrimmed.nodeCount} node(s) across rules ${result.contextTrimmed.ruleIds.join(', ')}.`);
|
|
@@ -206,7 +202,7 @@ export async function validate(context, options = {}) {
|
|
|
206
202
|
if (result.depthLimit !== undefined) {
|
|
207
203
|
console.log(fmt(' Skipped by depth limit', result.skippedByDepth.length, true, true));
|
|
208
204
|
}
|
|
209
|
-
const gatedFileCount = result.coverageGates.reduce((sum, g) => sum + g.
|
|
205
|
+
const gatedFileCount = result.coverageGates.reduce((sum, g) => sum + g.gatedNodeIds.length, 0);
|
|
210
206
|
console.log(fmt(' Rule checks gated (cascade)', gatedFileCount, true, true));
|
|
211
207
|
console.log(`\n`);
|
|
212
208
|
if (result.nodeErrorCount > 0 ||
|
|
@@ -442,8 +442,15 @@ export function extractEmbeddedNodes(body, options) {
|
|
|
442
442
|
}
|
|
443
443
|
return undefined;
|
|
444
444
|
}
|
|
445
|
+
// Ids must be unique, but headings on one page may repeat: the second and later get a ` (2)`, ` (3)` suffix.
|
|
446
|
+
const usedIds = new Set();
|
|
445
447
|
function makeId(title) {
|
|
446
|
-
|
|
448
|
+
const base = page ? `${page.id}#${title}` : title;
|
|
449
|
+
let id = base;
|
|
450
|
+
for (let n = 2; usedIds.has(id); n++)
|
|
451
|
+
id = `${base} (${n})`;
|
|
452
|
+
usedIds.add(id);
|
|
453
|
+
return id;
|
|
447
454
|
}
|
|
448
455
|
/**
|
|
449
456
|
* Walk the stack backwards to find the deepest typed node entry (nodeType !== '').
|
|
@@ -42,7 +42,7 @@ export declare function tryEvaluateExpression(expr: string, context: EvalContext
|
|
|
42
42
|
*/
|
|
43
43
|
export declare function buildEvalContext(node: SpaceNode, allNodes: SpaceNode[], nodeIndex: Map<NodeId, SpaceNode>): EvalContext;
|
|
44
44
|
/** The violation reported when a rule's `expression` or `when` cannot be evaluated: an authoring error, so always `error`. */
|
|
45
|
-
export declare function expressionErrorViolation(rule: Rule,
|
|
45
|
+
export declare function expressionErrorViolation(rule: Rule, nodeId: NodeId | null, error: string): RuleViolation;
|
|
46
46
|
/** The outcome of a rule's `expression` on one node. */
|
|
47
47
|
export type ExpressionRuleResult = {
|
|
48
48
|
passed: true;
|
|
@@ -54,8 +54,8 @@ export type ExpressionRuleResult = {
|
|
|
54
54
|
failed: boolean;
|
|
55
55
|
};
|
|
56
56
|
/**
|
|
57
|
-
* Evaluate a rule's `expression` against a node (`
|
|
57
|
+
* Evaluate a rule's `expression` against a node (`nodeId` null and `node` a sentinel for a global rule). A failure
|
|
58
58
|
* reports the rule's severity; a rule with a semantic part names the expression in the evidence, so the
|
|
59
59
|
* violation is not mistaken for a verdict. An evaluation error reports `error` and is not a `failed` result.
|
|
60
60
|
*/
|
|
61
|
-
export declare function evaluateRuleExpression(rule: Rule, node: SpaceNode, allNodes: SpaceNode[], nodeIndex: Map<NodeId, SpaceNode>,
|
|
61
|
+
export declare function evaluateRuleExpression(rule: Rule, node: SpaceNode, allNodes: SpaceNode[], nodeIndex: Map<NodeId, SpaceNode>, nodeId?: NodeId | null): Promise<ExpressionRuleResult>;
|
|
@@ -104,9 +104,9 @@ export function buildEvalContext(node, allNodes, nodeIndex) {
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
/** The violation reported when a rule's `expression` or `when` cannot be evaluated: an authoring error, so always `error`. */
|
|
107
|
-
export function expressionErrorViolation(rule,
|
|
107
|
+
export function expressionErrorViolation(rule, nodeId, error) {
|
|
108
108
|
return {
|
|
109
|
-
|
|
109
|
+
...(nodeId !== null && { nodeId }),
|
|
110
110
|
ruleId: rule.id,
|
|
111
111
|
category: rule.category,
|
|
112
112
|
severity: 'error',
|
|
@@ -114,15 +114,15 @@ export function expressionErrorViolation(rule, file, error) {
|
|
|
114
114
|
};
|
|
115
115
|
}
|
|
116
116
|
/**
|
|
117
|
-
* Evaluate a rule's `expression` against a node (`
|
|
117
|
+
* Evaluate a rule's `expression` against a node (`nodeId` null and `node` a sentinel for a global rule). A failure
|
|
118
118
|
* reports the rule's severity; a rule with a semantic part names the expression in the evidence, so the
|
|
119
119
|
* violation is not mistaken for a verdict. An evaluation error reports `error` and is not a `failed` result.
|
|
120
120
|
*/
|
|
121
|
-
export async function evaluateRuleExpression(rule, node, allNodes, nodeIndex,
|
|
121
|
+
export async function evaluateRuleExpression(rule, node, allNodes, nodeIndex, nodeId = node.id) {
|
|
122
122
|
const expression = rule.expression;
|
|
123
123
|
const outcome = await tryEvaluateExpression(expression, buildEvalContext(node, allNodes, nodeIndex));
|
|
124
124
|
if ('error' in outcome) {
|
|
125
|
-
return { passed: false, failed: false, violation: expressionErrorViolation(rule,
|
|
125
|
+
return { passed: false, failed: false, violation: expressionErrorViolation(rule, nodeId, outcome.error) };
|
|
126
126
|
}
|
|
127
127
|
if (outcome.value === true)
|
|
128
128
|
return { passed: true };
|
|
@@ -130,7 +130,7 @@ export async function evaluateRuleExpression(rule, node, allNodes, nodeIndex, fi
|
|
|
130
130
|
passed: false,
|
|
131
131
|
failed: true,
|
|
132
132
|
violation: {
|
|
133
|
-
|
|
133
|
+
...(nodeId !== null && { nodeId }),
|
|
134
134
|
ruleId: rule.id,
|
|
135
135
|
category: rule.category,
|
|
136
136
|
severity: ruleSeverity(rule),
|
package/dist/schema/gating.d.ts
CHANGED
|
@@ -12,8 +12,6 @@ export declare function ruleGatesDescendants(rule: Rule, severity?: RuleSeverity
|
|
|
12
12
|
* (schema validation or hierarchy structure) that failed on a specific node.
|
|
13
13
|
*/
|
|
14
14
|
export type GatingSeed = {
|
|
15
|
-
/** Id (file) of the failing node. */
|
|
16
|
-
file: string;
|
|
17
15
|
/** Id of the failing node. */
|
|
18
16
|
id: NodeId;
|
|
19
17
|
/** Which always-run layer produced the failure. */
|
|
@@ -24,16 +22,14 @@ export type GatingSeed = {
|
|
|
24
22
|
* rule evaluation for itself (if schema/hierarchy caused it) and/or its hierarchy subtree.
|
|
25
23
|
*/
|
|
26
24
|
export type CoverageGate = {
|
|
27
|
-
/** Id (file) of the node whose failure triggered this gate. */
|
|
28
|
-
file: string;
|
|
29
25
|
/** Id of the blocking node. */
|
|
30
26
|
nodeId: NodeId;
|
|
31
27
|
/** What kind of check triggered the gate. */
|
|
32
28
|
reason: 'schema' | 'hierarchy' | 'rule';
|
|
33
29
|
/** Rule id responsible, when `reason` is `'rule'`. */
|
|
34
30
|
ruleId?: string;
|
|
35
|
-
/**
|
|
36
|
-
|
|
31
|
+
/** Ids of nodes whose rule evaluation was suppressed as a result of this gate. */
|
|
32
|
+
gatedNodeIds: NodeId[];
|
|
37
33
|
};
|
|
38
34
|
export type GatedRuleEvaluation = {
|
|
39
35
|
violations: RuleViolation[];
|
package/dist/schema/gating.js
CHANGED
|
@@ -64,7 +64,7 @@ export async function evaluateRulesWithGating(nodes, rules, graph, seeds, option
|
|
|
64
64
|
function originFor(id, seed) {
|
|
65
65
|
let origin = gateOrigins.get(id);
|
|
66
66
|
if (!origin) {
|
|
67
|
-
origin = {
|
|
67
|
+
origin = { nodeId: id, reason: seed.reason, ruleId: seed.ruleId, gatedNodeIds: [] };
|
|
68
68
|
gateOrigins.set(id, origin);
|
|
69
69
|
}
|
|
70
70
|
return origin;
|
|
@@ -73,7 +73,7 @@ export async function evaluateRulesWithGating(nodes, rules, graph, seeds, option
|
|
|
73
73
|
const sentinel = nodes[0];
|
|
74
74
|
if (sentinel) {
|
|
75
75
|
for (const rule of globalRules) {
|
|
76
|
-
const result = await evaluateRuleExpression(rule, sentinel, nodes, nodeIndex,
|
|
76
|
+
const result = await evaluateRuleExpression(rule, sentinel, nodes, nodeIndex, null);
|
|
77
77
|
if (!result.passed)
|
|
78
78
|
violations.push(result.violation);
|
|
79
79
|
}
|
|
@@ -129,7 +129,7 @@ export async function evaluateRulesWithGating(nodes, rules, graph, seeds, option
|
|
|
129
129
|
function report(rule, detail = {}) {
|
|
130
130
|
const severity = detail.severity ?? ruleSeverities(rule)[0] ?? ruleSeverity(rule);
|
|
131
131
|
local.push({
|
|
132
|
-
|
|
132
|
+
nodeId: node.id,
|
|
133
133
|
ruleId: rule.id,
|
|
134
134
|
category: rule.category,
|
|
135
135
|
severity,
|
|
@@ -227,15 +227,15 @@ export async function evaluateRulesWithGating(nodes, rules, graph, seeds, option
|
|
|
227
227
|
const blockingRuleId = blockingById.get(node.id);
|
|
228
228
|
let childOrigin = ancestorOrigin;
|
|
229
229
|
if (isGatedByAncestor) {
|
|
230
|
-
gateOrigins.get(ancestorOrigin).
|
|
230
|
+
gateOrigins.get(ancestorOrigin).gatedNodeIds.push(node.id);
|
|
231
231
|
}
|
|
232
232
|
else if (isSelfBlocked) {
|
|
233
|
-
const origin = originFor(node.id, {
|
|
234
|
-
origin.
|
|
233
|
+
const origin = originFor(node.id, { reason: selfSeed.reason });
|
|
234
|
+
origin.gatedNodeIds.push(node.id);
|
|
235
235
|
childOrigin = node.id;
|
|
236
236
|
}
|
|
237
237
|
else if (blockingRuleId) {
|
|
238
|
-
originFor(node.id, {
|
|
238
|
+
originFor(node.id, { reason: 'rule', ruleId: blockingRuleId });
|
|
239
239
|
childOrigin = node.id;
|
|
240
240
|
}
|
|
241
241
|
else {
|
|
@@ -249,6 +249,6 @@ export async function evaluateRulesWithGating(nodes, rules, graph, seeds, option
|
|
|
249
249
|
}
|
|
250
250
|
return {
|
|
251
251
|
violations,
|
|
252
|
-
gates: [...gateOrigins.values()].filter((g) => g.
|
|
252
|
+
gates: [...gateOrigins.values()].filter((g) => g.gatedNodeIds.length > 0),
|
|
253
253
|
};
|
|
254
254
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { GraphViolation, SchemaMetadata, SpaceNode, UnresolvedRef } from '../types';
|
|
1
|
+
import type { GraphViolation, NodeId, SchemaMetadata, SourceLocation, SpaceNode, UnresolvedRef } from '../types';
|
|
2
2
|
export interface GraphValidationResult {
|
|
3
3
|
violations: GraphViolation[];
|
|
4
4
|
refErrors: Array<{
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
nodeId: NodeId;
|
|
6
|
+
source?: SourceLocation;
|
|
7
7
|
parent: string;
|
|
8
8
|
error: string;
|
|
9
9
|
}>;
|
|
@@ -5,8 +5,8 @@ import { resolveNodeType } from '../schema/schema';
|
|
|
5
5
|
*/
|
|
6
6
|
export function validateGraph(nodes, metadata, unresolvedRefs = []) {
|
|
7
7
|
const refErrors = unresolvedRefs.map((u) => ({
|
|
8
|
-
|
|
9
|
-
...(u.source
|
|
8
|
+
nodeId: u.id,
|
|
9
|
+
...(u.source !== undefined && { source: u.source }),
|
|
10
10
|
parent: u.ref,
|
|
11
11
|
error: u.message,
|
|
12
12
|
}));
|
|
@@ -87,7 +87,7 @@ export function validateHierarchyStructure(nodes, metadata) {
|
|
|
87
87
|
if (allowedChildTypes && !allowedChildTypes.has(nodeType)) {
|
|
88
88
|
const expected = [...allowedChildTypes].join(' or ');
|
|
89
89
|
violations.push({
|
|
90
|
-
|
|
90
|
+
nodeId: parentNode.id,
|
|
91
91
|
nodeType: parentType,
|
|
92
92
|
nodeTitle: parentTitle,
|
|
93
93
|
parentType: nodeType,
|
|
@@ -102,7 +102,7 @@ export function validateHierarchyStructure(nodes, metadata) {
|
|
|
102
102
|
if (allowedTypes && !allowedTypes.has(parentType)) {
|
|
103
103
|
const expected = [...allowedTypes].join(' or ');
|
|
104
104
|
violations.push({
|
|
105
|
-
|
|
105
|
+
nodeId: node.id,
|
|
106
106
|
nodeType,
|
|
107
107
|
nodeTitle,
|
|
108
108
|
parentType,
|
|
@@ -140,7 +140,7 @@ export function validateHierarchyStructure(nodes, metadata) {
|
|
|
140
140
|
}
|
|
141
141
|
if (!isValidHierarchy) {
|
|
142
142
|
violations.push({
|
|
143
|
-
|
|
143
|
+
nodeId: node.id,
|
|
144
144
|
nodeType,
|
|
145
145
|
nodeTitle,
|
|
146
146
|
parentType,
|
|
@@ -55,7 +55,7 @@ export async function validateRules(nodes, rules, options = {}) {
|
|
|
55
55
|
if (rule.global) {
|
|
56
56
|
if (!sentinel)
|
|
57
57
|
continue;
|
|
58
|
-
const result = await evaluateRuleExpression(rule, sentinel, nodes, nodeIndex,
|
|
58
|
+
const result = await evaluateRuleExpression(rule, sentinel, nodes, nodeIndex, null);
|
|
59
59
|
if (!result.passed)
|
|
60
60
|
violations.push(result.violation);
|
|
61
61
|
continue;
|
|
@@ -93,7 +93,7 @@ export async function validateRules(nodes, rules, options = {}) {
|
|
|
93
93
|
if (!j.violated)
|
|
94
94
|
continue;
|
|
95
95
|
found.push({
|
|
96
|
-
|
|
96
|
+
nodeId: node.id,
|
|
97
97
|
ruleId: j.rule.id,
|
|
98
98
|
category: j.rule.category,
|
|
99
99
|
severity: j.severity ?? ruleSeverities(j.rule)[0] ?? ruleSeverity(j.rule),
|
package/dist/types.d.ts
CHANGED
|
@@ -178,8 +178,8 @@ export type RuleSeverity = 'error' | 'warning' | 'info';
|
|
|
178
178
|
export type { CoverageGate, GatingSeed } from './schema/gating';
|
|
179
179
|
export type { Rule } from './schema/metadata-contract';
|
|
180
180
|
export type RuleViolation = {
|
|
181
|
-
/**
|
|
182
|
-
|
|
181
|
+
/** Id of the violating node. Absent for global-scope rules. */
|
|
182
|
+
nodeId?: NodeId;
|
|
183
183
|
ruleId: string;
|
|
184
184
|
category: RuleCategory;
|
|
185
185
|
severity: RuleSeverity;
|
|
@@ -192,7 +192,8 @@ export type RuleViolation = {
|
|
|
192
192
|
guidance?: string;
|
|
193
193
|
};
|
|
194
194
|
export type GraphViolation = {
|
|
195
|
-
|
|
195
|
+
/** Id of the node whose parent link breaks the hierarchy. */
|
|
196
|
+
nodeId: NodeId;
|
|
196
197
|
nodeType: string;
|
|
197
198
|
nodeTitle: string;
|
|
198
199
|
parentType: string;
|
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, SpaceContext, SpaceNode, ValidateFileOutput } from './types';
|
|
6
|
+
import type { CoverageGate, FileNotInSpaceResult, 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;
|
|
@@ -12,20 +12,21 @@ export interface ValidationResult {
|
|
|
12
12
|
validCount: number;
|
|
13
13
|
nodeErrorCount: number;
|
|
14
14
|
nodeErrors: Array<{
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
nodeId: NodeId;
|
|
16
|
+
source?: SourceLocation;
|
|
17
17
|
errors: ErrorObject[];
|
|
18
18
|
nodeData: Record<string, unknown>;
|
|
19
19
|
}>;
|
|
20
20
|
refErrors: Array<{
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
nodeId: NodeId;
|
|
22
|
+
source?: SourceLocation;
|
|
23
23
|
parent: string;
|
|
24
24
|
error: string;
|
|
25
25
|
}>;
|
|
26
|
+
/** Nodes that share an id; `sources` lists where each is defined (a node without a source adds no entry). */
|
|
26
27
|
duplicateErrors: Array<{
|
|
27
28
|
id: NodeId;
|
|
28
|
-
|
|
29
|
+
sources: SourceLocation[];
|
|
29
30
|
}>;
|
|
30
31
|
/** All rule violations with their effective severity. Only `error` severity fails validation. */
|
|
31
32
|
ruleViolations: RuleViolation[];
|
|
@@ -34,7 +35,7 @@ export interface ValidationResult {
|
|
|
34
35
|
parseIssues: ParseIssue[];
|
|
35
36
|
/** Content links that could not be resolved to a space node. Warning only — not counted in errors. */
|
|
36
37
|
unresolvedContentLinks: Array<{
|
|
37
|
-
|
|
38
|
+
nodeId: NodeId;
|
|
38
39
|
target: string;
|
|
39
40
|
}>;
|
|
40
41
|
/**
|
|
@@ -50,7 +51,7 @@ export interface ValidationResult {
|
|
|
50
51
|
};
|
|
51
52
|
/** Nodes excluded from validation entirely by a `depth` limit (breadth-first from hierarchy roots). */
|
|
52
53
|
skippedByDepth: Array<{
|
|
53
|
-
|
|
54
|
+
nodeId: NodeId;
|
|
54
55
|
title: string;
|
|
55
56
|
}>;
|
|
56
57
|
/** The depth limit applied, if any (undefined means the full space was validated). */
|
|
@@ -72,8 +73,15 @@ export interface ValidateSpaceOptions {
|
|
|
72
73
|
/** Evaluates `semantic` rules. Without one, a rule runs only its `expression`, and a semantic-only rule is skipped. */
|
|
73
74
|
judge?: SemanticJudgeProvider;
|
|
74
75
|
}
|
|
76
|
+
/** `file`, `file:line` or `file:line:column`, for messages. */
|
|
77
|
+
export declare function formatSource({ file, line, column }: SourceLocation): string;
|
|
75
78
|
/** One-line text for a rule violation: rule, description, and any evidence and verdict guidance. */
|
|
76
79
|
export declare function ruleViolationMessage(v: RuleViolation): string;
|
|
80
|
+
/**
|
|
81
|
+
* Nodes that share an id, with where each is defined. A plugin should keep ids unique, so this guards that contract.
|
|
82
|
+
* Display titles may repeat.
|
|
83
|
+
*/
|
|
84
|
+
export declare function findDuplicateIds(nodes: SpaceNode[]): ValidationResult['duplicateErrors'];
|
|
77
85
|
/**
|
|
78
86
|
* Run full validation on a space and return a structured result.
|
|
79
87
|
* Does not output to console or call process.exit.
|
package/dist/validate.js
CHANGED
|
@@ -94,6 +94,10 @@ export function formatErrors(errors, schema, schemaRefRegistry, nodeData) {
|
|
|
94
94
|
}
|
|
95
95
|
return formatted;
|
|
96
96
|
}
|
|
97
|
+
/** `file`, `file:line` or `file:line:column`, for messages. */
|
|
98
|
+
export function formatSource({ file, line, column }) {
|
|
99
|
+
return line === undefined ? file : `${file}:${line}${column === undefined ? '' : `:${column}`}`;
|
|
100
|
+
}
|
|
97
101
|
/** One-line text for a rule violation: rule, description, and any evidence and verdict guidance. */
|
|
98
102
|
export function ruleViolationMessage(v) {
|
|
99
103
|
const evidence = v.evidence ? ` (Evidence: ${v.evidence})` : '';
|
|
@@ -131,6 +135,24 @@ async function evaluateSpaceRules(nodes, metadata, seeds, options = {}) {
|
|
|
131
135
|
trimReport: options.trimReport,
|
|
132
136
|
});
|
|
133
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Nodes that share an id, with where each is defined. A plugin should keep ids unique, so this guards that contract.
|
|
140
|
+
* Display titles may repeat.
|
|
141
|
+
*/
|
|
142
|
+
export function findDuplicateIds(nodes) {
|
|
143
|
+
const idToNodes = new Map();
|
|
144
|
+
for (const node of nodes) {
|
|
145
|
+
if (!idToNodes.has(node.id))
|
|
146
|
+
idToNodes.set(node.id, []);
|
|
147
|
+
idToNodes.get(node.id).push(node);
|
|
148
|
+
}
|
|
149
|
+
const duplicates = [];
|
|
150
|
+
for (const [id, sameId] of idToNodes) {
|
|
151
|
+
if (sameId.length > 1)
|
|
152
|
+
duplicates.push({ id, sources: sameId.flatMap((n) => (n.source ? [n.source] : [])) });
|
|
153
|
+
}
|
|
154
|
+
return duplicates;
|
|
155
|
+
}
|
|
134
156
|
/**
|
|
135
157
|
* The one validation pipeline: type and schema checks, duplicate titles, reference and hierarchy
|
|
136
158
|
* checks, cascade-gated rules, and unresolved content links. Returns every entry; `validateSpace`
|
|
@@ -182,19 +204,7 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
|
|
|
182
204
|
result.nodeFailures.push({ node, errors: schemaValidator.errors || [] });
|
|
183
205
|
}
|
|
184
206
|
}
|
|
185
|
-
|
|
186
|
-
const idToFiles = new Map();
|
|
187
|
-
for (const node of nodes) {
|
|
188
|
-
if (!idToFiles.has(node.id)) {
|
|
189
|
-
idToFiles.set(node.id, []);
|
|
190
|
-
}
|
|
191
|
-
idToFiles.get(node.id).push(node.id);
|
|
192
|
-
}
|
|
193
|
-
for (const [id, files] of idToFiles) {
|
|
194
|
-
if (files.length > 1) {
|
|
195
|
-
result.duplicateErrors.push({ id, files });
|
|
196
|
-
}
|
|
197
|
-
}
|
|
207
|
+
result.duplicateErrors.push(...findDuplicateIds(nodes));
|
|
198
208
|
// Validate all hierarchy constraints (field references and structure)
|
|
199
209
|
const hierarchyValidation = validateGraph(nodes, metadata, unresolvedRefs);
|
|
200
210
|
result.refErrors.push(...hierarchyValidation.refErrors);
|
|
@@ -207,9 +217,9 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
|
|
|
207
217
|
if (metadata.rules) {
|
|
208
218
|
const seeds = [
|
|
209
219
|
...result.nodeFailures.map(({ node }) => {
|
|
210
|
-
return {
|
|
220
|
+
return { id: node.id, reason: 'schema' };
|
|
211
221
|
}),
|
|
212
|
-
...result.hierarchyViolations.map((v) => ({
|
|
222
|
+
...result.hierarchyViolations.map((v) => ({ id: v.nodeId, reason: 'hierarchy' })),
|
|
213
223
|
];
|
|
214
224
|
const trimReport = { nodes: new Set(), ruleIds: new Set() };
|
|
215
225
|
const { violations, gates } = await evaluateSpaceRules(nodes, metadata, seeds, { ...options, trimReport });
|
|
@@ -225,7 +235,7 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
|
|
|
225
235
|
const key = `${node.id}::${link.target}`;
|
|
226
236
|
if (!seen.has(key)) {
|
|
227
237
|
seen.add(key);
|
|
228
|
-
result.unresolvedContentLinks.push({
|
|
238
|
+
result.unresolvedContentLinks.push({ nodeId: node.id, target: link.target });
|
|
229
239
|
}
|
|
230
240
|
}
|
|
231
241
|
}
|
|
@@ -250,7 +260,7 @@ export async function validateSpace(context, options = {}) {
|
|
|
250
260
|
for (const node of nodes) {
|
|
251
261
|
const nodeDepth = depths.get(node.id);
|
|
252
262
|
if (nodeDepth !== undefined && nodeDepth > options.depth) {
|
|
253
|
-
skippedByDepth.push({
|
|
263
|
+
skippedByDepth.push({ nodeId: node.id, title: node.title });
|
|
254
264
|
}
|
|
255
265
|
else {
|
|
256
266
|
included.push(node);
|
|
@@ -267,8 +277,8 @@ export async function validateSpace(context, options = {}) {
|
|
|
267
277
|
validCount: result.validCount,
|
|
268
278
|
nodeErrorCount: result.nodeFailures.length,
|
|
269
279
|
nodeErrors: result.nodeFailures.map(({ node, errors }) => ({
|
|
270
|
-
|
|
271
|
-
...(node.source
|
|
280
|
+
nodeId: node.id,
|
|
281
|
+
...(node.source !== undefined && { source: node.source }),
|
|
272
282
|
errors,
|
|
273
283
|
nodeData: node.schemaData,
|
|
274
284
|
})),
|
|
@@ -326,30 +336,29 @@ function issuesForFile(result, label, context, includeGated) {
|
|
|
326
336
|
errors[`schema:${dedupeKey}`] = { kind: 'schema', message, ...position };
|
|
327
337
|
}
|
|
328
338
|
}
|
|
329
|
-
// Duplicate
|
|
330
|
-
for (const { id,
|
|
331
|
-
if (
|
|
339
|
+
// Duplicate id errors — include if this node's id is duplicated
|
|
340
|
+
for (const { id, sources } of result.duplicateErrors) {
|
|
341
|
+
if (id !== label)
|
|
332
342
|
continue;
|
|
333
|
-
const others = files.filter((f) => f !== label);
|
|
334
343
|
errors[`duplicate:${id}`] = {
|
|
335
344
|
kind: 'duplicate',
|
|
336
|
-
message: `Duplicate id "${id}"
|
|
345
|
+
message: `Duplicate id "${id}" is used by ${sources.length} nodes${sources.length > 0 ? `: ${sources.map(formatSource).join(', ')}` : ''}`,
|
|
337
346
|
};
|
|
338
347
|
}
|
|
339
348
|
// Broken links and hierarchy violations from this file
|
|
340
|
-
for (const {
|
|
341
|
-
if (
|
|
349
|
+
for (const { nodeId, parent, error } of result.refErrors) {
|
|
350
|
+
if (nodeId === label) {
|
|
342
351
|
errors[`broken-link:${parent}`] = { kind: 'broken-link', message: `${parent} → ${error}` };
|
|
343
352
|
}
|
|
344
353
|
}
|
|
345
354
|
for (const v of result.hierarchyViolations) {
|
|
346
|
-
if (v.
|
|
355
|
+
if (v.nodeId === label) {
|
|
347
356
|
errors[`hierarchy:${v.description}`] = { kind: 'hierarchy', message: v.description };
|
|
348
357
|
}
|
|
349
358
|
}
|
|
350
359
|
// Rule violations — only error severity counts as an error.
|
|
351
360
|
for (const v of result.ruleViolations) {
|
|
352
|
-
if (v.
|
|
361
|
+
if (v.nodeId === label) {
|
|
353
362
|
const target = v.severity === 'error' ? errors : warnings;
|
|
354
363
|
target[`rule:${v.ruleId}`] = {
|
|
355
364
|
kind: 'rule',
|
|
@@ -360,18 +369,18 @@ function issuesForFile(result, label, context, includeGated) {
|
|
|
360
369
|
}
|
|
361
370
|
// A gated file with no violations would otherwise look clean — say that its rules were not checked.
|
|
362
371
|
for (const gate of result.coverageGates) {
|
|
363
|
-
if (!gate.
|
|
372
|
+
if (!gate.gatedNodeIds.some((id) => id === label))
|
|
364
373
|
continue;
|
|
365
374
|
const cause = gate.reason === 'rule' ? `blocking rule "${gate.ruleId}"` : `${gate.reason} failure`;
|
|
366
|
-
warnings[`gated:${gate.
|
|
375
|
+
warnings[`gated:${gate.nodeId}`] = {
|
|
367
376
|
kind: 'gated',
|
|
368
|
-
message: `Rule checks ${includeGated ? 'evaluated anyway' : 'not evaluated'}: gated by ${cause} on ${gate.
|
|
377
|
+
message: `Rule checks ${includeGated ? 'evaluated anyway' : 'not evaluated'}: gated by ${cause} on ${gate.nodeId}`,
|
|
369
378
|
severity: 'info',
|
|
370
379
|
};
|
|
371
380
|
}
|
|
372
381
|
// Unresolved content links (warnings — do not affect errorCount)
|
|
373
382
|
for (const link of result.unresolvedContentLinks) {
|
|
374
|
-
if (link.
|
|
383
|
+
if (link.nodeId === label) {
|
|
375
384
|
warnings[`content-link:${link.target}`] = {
|
|
376
385
|
kind: 'content-link',
|
|
377
386
|
message: `Unresolved content link: ${link.target}`,
|
package/docs/concepts.md
CHANGED
|
@@ -111,12 +111,18 @@ Three values on every node do separate jobs:
|
|
|
111
111
|
|
|
112
112
|
| Value | Job | Unique? | Markdown source |
|
|
113
113
|
|---|---|---|---|
|
|
114
|
-
| `id` | Identifies the node: graph key, parent lookup, gating, duplicate detection, and the file shown in messages | Yes. Two nodes with the same `id` are a duplicate error | Path relative to the space root, with extension (`projects/alpha/Overview.md`). Embedded nodes use `<page id>#<heading
|
|
114
|
+
| `id` | Identifies the node: graph key, parent lookup, gating, duplicate detection, and the file shown in messages | Yes. Two nodes with the same `id` are a duplicate error | Path relative to the space root, with extension (`projects/alpha/Overview.md`). Embedded nodes use `<page id>#<heading>`, with ` (2)`, ` (3)` added to repeated headings on one page |
|
|
115
115
|
| `title` | Display name in `show`, rendered output and judge prompts | No | `title:` frontmatter, else the containing folder's name for a [folder index](#folder-index), else the file's basename |
|
|
116
116
|
| `linkTargets` | The names a wikilink may use to reach the node | Per name. A name shared by several nodes is reported as ambiguous where a link uses it | The explicit `title:`, the basename, and every path suffix without `.md` |
|
|
117
117
|
|
|
118
118
|
A wikilink is looked up in `linkTargets`, and the result is stored as the target node's `id`. Other values hold the rest of the picture: `source` is where the node is defined, `resolvedParents` lists parents by `id`, and `resolvedLinks` classifies content links. A parent field resolves exactly as a link does.
|
|
119
119
|
|
|
120
|
+
### Validation results and nodes
|
|
121
|
+
|
|
122
|
+
A validation result refers to a node by its `id`, in a `nodeId` field. Where the node is defined is a separate `source` (`file`, and `line` / `column` where available). A result may copy a node's identity and location, but not its content. A node can lack a `source` (for example when a plugin reads from a service rather than files), so treat `source` as optional.
|
|
123
|
+
|
|
124
|
+
Rule violations on a global rule have no `nodeId`.
|
|
125
|
+
|
|
120
126
|
### Folder index
|
|
121
127
|
|
|
122
128
|
A **folder index** is a page that stands for its folder: `Overview.md` or `index.md` inside `projects/alpha/`. Obsidian has no container pages, so a node with child pages is a folder plus an index page. Listing the index basenames in the markdown plugin's [`folderIndex`](config.md#folderindex) config gives each folder index the folder's name as its `title`. The derived title is for display only and never a link target.
|
package/docs/rules.md
CHANGED
|
@@ -193,7 +193,7 @@ A rule has one `gatesDescendants` setting. It applies to whichever part reported
|
|
|
193
193
|
### Coverage output
|
|
194
194
|
|
|
195
195
|
`sctx validate` reports gating as **coverage gates**: which node's violation (schema,
|
|
196
|
-
hierarchy, or a specific `ruleId`) suppressed rule evaluation, and the
|
|
196
|
+
hierarchy, or a specific `ruleId`) suppressed rule evaluation, and the ids of the nodes gated
|
|
197
197
|
as a result. This makes the validation "edge" visible — e.g. "this blocked goal means all
|
|
198
198
|
opportunities beneath it were not evaluated" — for both human reading and agent consumption
|
|
199
199
|
(JSON output via `--json`, under `coverageGates`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "structured-context",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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",
|
|
@@ -61,7 +61,6 @@
|
|
|
61
61
|
"@anthropic-ai/claude-agent-sdk": "^0.3.310",
|
|
62
62
|
"@biomejs/biome": "^2.5.15",
|
|
63
63
|
"@types/bun": "^1.4.4",
|
|
64
|
-
"json-schema-to-ts": "^3.1.1",
|
|
65
64
|
"lefthook": "^2.2.2"
|
|
66
65
|
},
|
|
67
66
|
"peerDependencies": {
|
|
@@ -74,6 +73,7 @@
|
|
|
74
73
|
"fast-glob": "^3.3.3",
|
|
75
74
|
"gray-matter": "^4.0.3",
|
|
76
75
|
"js-yaml": "^5.4.3",
|
|
76
|
+
"json-schema-to-ts": "^3.1.1",
|
|
77
77
|
"json5": "^2.2.3",
|
|
78
78
|
"jsonata": "^2.2.1",
|
|
79
79
|
"mdast-util-to-string": "^4.0.0",
|