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 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/structured-context
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 { file, errors: ajvErrors, nodeData } of result.nodeErrors) {
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(file, `schema:${dedupeKey}`, 'schema', message);
31
+ addError(nodeId, `schema:${dedupeKey}`, 'schema', message);
32
32
  }
33
33
  }
34
- for (const { file, parent, error } of result.refErrors) {
35
- addError(file, `broken-link:${parent}`, 'broken-link', `${parent} → ${error}`);
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, files } of result.duplicateErrors) {
38
- for (const file of files) {
39
- const others = files.filter((f) => f !== file);
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.file && v.severity === 'error') {
45
- addError(v.file, `rule:${v.ruleId}`, 'rule', ruleViolationMessage(v), v.severity);
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.file && v.severity === 'error').length;
46
+ const globalRuleErrorCount = result.ruleViolations.filter((v) => !v.nodeId && v.severity === 'error').length;
49
47
  for (const v of result.hierarchyViolations) {
50
- addError(v.file, `hierarchy:${v.description}`, 'hierarchy', v.description);
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 { file, target } of result.unresolvedContentLinks) {
104
- if (!byFile.has(file))
105
- byFile.set(file, []);
106
- byFile.get(file).push(target);
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 [file, targets] of byFile) {
109
- console.log(` ${file}:`);
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(({ file, line, errors, nodeData }) => {
125
- const loc = line !== undefined ? `:${line}` : '';
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(({ file, line, parent, error }) => {
136
- const loc = line !== undefined ? `:${line}` : '';
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, files }) => {
138
+ result.duplicateErrors.forEach(({ id, sources }) => {
143
139
  console.log(` "${id}":`);
144
- for (const f of files) {
145
- console.log(` ${f}`);
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.file ? `${v.file}: ` : ''}${v.description} [${v.category}: ${v.ruleId}]`);
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.file}: ${v.description}`);
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.file} (${gate.nodeId}) — ${cause} gated ${gate.gatedFiles.length} node(s):`);
179
- for (const file of gate.gatedFiles)
180
- console.log(` ${file}`);
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 { file } of result.skippedByDepth)
186
- console.log(` ${file}`);
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.gatedFiles.length, 0);
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
- return (page ? `${page.id}#${title}` : title);
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, file: string, error: string): RuleViolation;
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 (`file` empty and `node` a sentinel for a global rule). A failure
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>, file?: string): Promise<ExpressionRuleResult>;
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, file, error) {
107
+ export function expressionErrorViolation(rule, nodeId, error) {
108
108
  return {
109
- file,
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 (`file` empty and `node` a sentinel for a global rule). A failure
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, file = node.id) {
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, file, outcome.error) };
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
- file,
133
+ ...(nodeId !== null && { nodeId }),
134
134
  ruleId: rule.id,
135
135
  category: rule.category,
136
136
  severity: ruleSeverity(rule),
@@ -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
- /** Labels of nodes whose rule evaluation was suppressed as a result of this gate. */
36
- gatedFiles: string[];
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[];
@@ -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 = { file: seed.file, nodeId: id, reason: seed.reason, ruleId: seed.ruleId, gatedFiles: [] };
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
- file: node.id,
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).gatedFiles.push(node.id);
230
+ gateOrigins.get(ancestorOrigin).gatedNodeIds.push(node.id);
231
231
  }
232
232
  else if (isSelfBlocked) {
233
- const origin = originFor(node.id, { file: selfSeed.file, reason: selfSeed.reason });
234
- origin.gatedFiles.push(node.id);
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, { file: node.id, reason: 'rule', ruleId: blockingRuleId });
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.gatedFiles.length > 0),
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
- file: string;
6
- line?: number;
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
- file: u.source?.file ?? u.id,
9
- ...(u.source?.line !== undefined ? { line: u.source.line } : {}),
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
- file: parentNode.id,
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
- file: node.id,
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
- file: node.id,
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
- file: node.id,
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
- /** Node id, or empty string for global-scope rules. */
182
- file: string;
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
- file: string;
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;
@@ -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
- file: string;
16
- line?: number;
15
+ nodeId: NodeId;
16
+ source?: SourceLocation;
17
17
  errors: ErrorObject[];
18
18
  nodeData: Record<string, unknown>;
19
19
  }>;
20
20
  refErrors: Array<{
21
- file: string;
22
- line?: number;
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
- files: string[];
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
- file: string;
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
- file: string;
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
- // Detect duplicate node ids. Display titles may repeat.
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 { file: node.source?.file ?? node.id, id: node.id, reason: 'schema' };
220
+ return { id: node.id, reason: 'schema' };
211
221
  }),
212
- ...result.hierarchyViolations.map((v) => ({ file: v.file, id: v.file, reason: 'hierarchy' })),
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({ file: node.id, target: link.target });
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({ file: node.id, title: node.title });
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
- file: node.source?.file ?? node.id,
271
- ...(node.source?.line !== undefined ? { line: node.source.line } : {}),
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 key errors — include if this file is one of the duplicates
330
- for (const { id, files } of result.duplicateErrors) {
331
- if (!files.includes(label))
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}" also exists in: ${others.join(', ')}`,
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 { file, parent, error } of result.refErrors) {
341
- if (file === label) {
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.file === label) {
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.file === label) {
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.gatedFiles.includes(label))
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.file}`] = {
375
+ warnings[`gated:${gate.nodeId}`] = {
367
376
  kind: 'gated',
368
- message: `Rule checks ${includeGated ? 'evaluated anyway' : 'not evaluated'}: gated by ${cause} on ${gate.file}`,
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.file === label) {
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 list of files gated
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.17.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",