structured-context 0.17.1 → 0.19.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.
@@ -10,8 +10,8 @@ export interface DumpOptions {
10
10
  /**
11
11
  * Build the dump payload: every parsed node, or only nodes matched by a filter.
12
12
  *
13
- * Unfiltered dumps include schema-invalid nodes, since dump is a debugging aid.
14
- * Filtered dumps match show/render: the filter runs over the graph of schema-valid nodes.
13
+ * Unfiltered dumps include nodes of an unknown type, since dump is a debugging aid.
14
+ * Filtered dumps match show/render: the filter runs over the graph of nodes with a known type.
15
15
  */
16
16
  export declare function buildDump(context: SpaceContext, options?: DumpOptions): Promise<ReadSpaceResult>;
17
17
  export declare function dump(context: SpaceContext, options?: DumpOptions): Promise<void>;
@@ -4,8 +4,8 @@ import { exportSpaceGraph } from '../space-graph-data';
4
4
  /**
5
5
  * Build the dump payload: every parsed node, or only nodes matched by a filter.
6
6
  *
7
- * Unfiltered dumps include schema-invalid nodes, since dump is a debugging aid.
8
- * Filtered dumps match show/render: the filter runs over the graph of schema-valid nodes.
7
+ * Unfiltered dumps include nodes of an unknown type, since dump is a debugging aid.
8
+ * Filtered dumps match show/render: the filter runs over the graph of nodes with a known type.
9
9
  */
10
10
  export async function buildDump(context, options = {}) {
11
11
  const readResult = await readSpace(context);
@@ -5,9 +5,8 @@ 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 { formatSource, ruleViolationMessage, validateSpace } from '../validate';
9
9
  export async function validate(context, options = {}) {
10
- const { schema, schemaRefRegistry } = context;
11
10
  const result = await validateSpace(context, {
12
11
  depth: options.depth,
13
12
  includeGated: options.includeGated,
@@ -25,29 +24,26 @@ export async function validate(context, options = {}) {
25
24
  errorsByFile[file] = {};
26
25
  errorsByFile[file][key] = severity ? { kind, message, severity } : { kind, message };
27
26
  };
28
- for (const { file, errors: ajvErrors, nodeData } of result.nodeErrors) {
29
- const formatted = formatErrors(ajvErrors, schema, schemaRefRegistry, nodeData);
30
- for (const { message, dedupeKey } of formatted) {
31
- addError(file, `schema:${dedupeKey}`, 'schema', message);
27
+ for (const { nodeId, issues } of result.nodeErrors) {
28
+ for (const { message, dedupeKey } of issues) {
29
+ addError(nodeId, `schema:${dedupeKey}`, 'schema', message);
32
30
  }
33
31
  }
34
- for (const { file, parent, error } of result.refErrors) {
35
- addError(file, `broken-link:${parent}`, 'broken-link', `${parent} → ${error}`);
32
+ for (const { nodeId, parent, error } of result.refErrors) {
33
+ addError(nodeId, `broken-link:${parent}`, 'broken-link', `${parent} → ${error}`);
36
34
  }
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
- }
35
+ for (const { id, sources } of result.duplicateErrors) {
36
+ const where = sources.length > 0 ? `: ${sources.map(formatSource).join(', ')}` : '';
37
+ addError(id, `duplicate:${id}`, 'duplicate', `Duplicate id "${id}" is used by ${sources.length} nodes${where}`);
42
38
  }
43
39
  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);
40
+ if (v.nodeId && v.severity === 'error') {
41
+ addError(v.nodeId, `rule:${v.ruleId}`, 'rule', ruleViolationMessage(v), v.severity);
46
42
  }
47
43
  }
48
- const globalRuleErrorCount = result.ruleViolations.filter((v) => !v.file && v.severity === 'error').length;
44
+ const globalRuleErrorCount = result.ruleViolations.filter((v) => !v.nodeId && v.severity === 'error').length;
49
45
  for (const v of result.hierarchyViolations) {
50
- addError(v.file, `hierarchy:${v.description}`, 'hierarchy', v.description);
46
+ addError(v.nodeId, `hierarchy:${v.description}`, 'hierarchy', v.description);
51
47
  }
52
48
  const parseErrorCount = result.parseIssues.filter((i) => i.severity === 'error').length;
53
49
  const errorCount = Object.values(errorsByFile).reduce((sum, errs) => sum + Object.keys(errs).length, 0) +
@@ -100,13 +96,13 @@ export async function validate(context, options = {}) {
100
96
  if (result.unresolvedContentLinks.length > 0) {
101
97
  console.log(`\nUnresolved content links (may be outside-space or broken):`);
102
98
  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);
99
+ for (const { nodeId, target } of result.unresolvedContentLinks) {
100
+ if (!byFile.has(nodeId))
101
+ byFile.set(nodeId, []);
102
+ byFile.get(nodeId).push(target);
107
103
  }
108
- for (const [file, targets] of byFile) {
109
- console.log(` ${file}:`);
104
+ for (const [nodeId, targets] of byFile) {
105
+ console.log(` ${nodeId}:`);
110
106
  for (const target of targets)
111
107
  console.log(` ${target}`);
112
108
  }
@@ -121,28 +117,25 @@ export async function validate(context, options = {}) {
121
117
  }
122
118
  if (result.nodeErrors.length > 0) {
123
119
  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}:`);
127
- const formatted = formatErrors(errors, schema, schemaRefRegistry, nodeData);
128
- formatted.forEach(({ message }) => {
120
+ result.nodeErrors.forEach(({ nodeId, source, issues }) => {
121
+ console.log(`\n ${source ? formatSource(source) : nodeId}:`);
122
+ issues.forEach(({ message }) => {
129
123
  console.log(` ${message}`);
130
124
  });
131
125
  });
132
126
  }
133
127
  if (result.refErrors.length > 0) {
134
128
  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}`);
129
+ result.refErrors.forEach(({ nodeId, source, parent, error }) => {
130
+ console.log(` ${source ? formatSource(source) : nodeId}: ${parent} → ${error}`);
138
131
  });
139
132
  }
140
133
  if (result.duplicateErrors.length > 0) {
141
134
  console.log(`\nDuplicate ids (same id on multiple nodes):`);
142
- result.duplicateErrors.forEach(({ id, files }) => {
135
+ result.duplicateErrors.forEach(({ id, sources }) => {
143
136
  console.log(` "${id}":`);
144
- for (const f of files) {
145
- console.log(` ${f}`);
137
+ for (const source of sources) {
138
+ console.log(` ${formatSource(source)}`);
146
139
  }
147
140
  });
148
141
  }
@@ -155,7 +148,7 @@ export async function validate(context, options = {}) {
155
148
  continue;
156
149
  console.log(` ${severityColor[severity]}${severity.toUpperCase()}${reset} (${violations.length}):`);
157
150
  for (const v of violations) {
158
- console.log(` ${v.file ? `${v.file}: ` : ''}${v.description} [${v.category}: ${v.ruleId}]`);
151
+ console.log(` ${v.nodeId ? `${v.nodeId}: ` : ''}${v.description} [${v.category}: ${v.ruleId}]`);
159
152
  if (v.evidence) {
160
153
  console.log(` Evidence: ${v.evidence}`);
161
154
  }
@@ -168,22 +161,22 @@ export async function validate(context, options = {}) {
168
161
  if (result.hierarchyViolations.length > 0) {
169
162
  console.log(`\nHierarchy violations:`);
170
163
  for (const v of result.hierarchyViolations) {
171
- console.log(` ${v.file}: ${v.description}`);
164
+ console.log(` ${v.nodeId}: ${v.description}`);
172
165
  }
173
166
  }
174
167
  if (result.coverageGates.length > 0) {
175
168
  console.log(`\nCoverage — rule checks gated by cascade (use --include-gated to evaluate anyway):`);
176
169
  for (const gate of result.coverageGates) {
177
170
  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}`);
171
+ console.log(` ${gate.nodeId} — ${cause} gated ${gate.gatedNodeIds.length} node(s):`);
172
+ for (const nodeId of gate.gatedNodeIds)
173
+ console.log(` ${nodeId}`);
181
174
  }
182
175
  }
183
176
  if (result.skippedByDepth.length > 0) {
184
177
  console.log(`\nSkipped by depth limit (--depth ${result.depthLimit}):`);
185
- for (const { file } of result.skippedByDepth)
186
- console.log(` ${file}`);
178
+ for (const { nodeId } of result.skippedByDepth)
179
+ console.log(` ${nodeId}`);
187
180
  }
188
181
  if (result.contextTrimmed.nodeCount > 0) {
189
182
  console.log(`\nContext trimmed for ${result.contextTrimmed.nodeCount} node(s) across rules ${result.contextTrimmed.ruleIds.join(', ')}.`);
@@ -206,7 +199,7 @@ export async function validate(context, options = {}) {
206
199
  if (result.depthLimit !== undefined) {
207
200
  console.log(fmt(' Skipped by depth limit', result.skippedByDepth.length, true, true));
208
201
  }
209
- const gatedFileCount = result.coverageGates.reduce((sum, g) => sum + g.gatedFiles.length, 0);
202
+ const gatedFileCount = result.coverageGates.reduce((sum, g) => sum + g.gatedNodeIds.length, 0);
210
203
  console.log(fmt(' Rule checks gated (cascade)', gatedFileCount, true, true));
211
204
  console.log(`\n`);
212
205
  if (result.nodeErrorCount > 0 ||
@@ -10,14 +10,14 @@ export interface AssembleSpaceGraphOptions {
10
10
  readResult?: ReadSpaceResult;
11
11
  }
12
12
  /**
13
- * Assemble a SpaceGraph from a space context: read the space, drop nodes that
14
- * fail schema validation, build the graph for the selected hierarchy (the main
15
- * hierarchy by default), and apply an optional view/filter expression.
13
+ * Assemble a SpaceGraph from a space context: read the space, drop nodes whose type the schema
14
+ * does not define, build the graph for the selected hierarchy (the main hierarchy by default),
15
+ * and apply an optional view/filter expression.
16
16
  *
17
- * Nodes that fail schema validation are silently excluded so the graph is
18
- * well-formed enough to render and traverse. This is NOT a validation pass —
19
- * schema-valid nodes may still have broken references, rule violations, or
20
- * duplicates. Use validateSpace for a full validation report.
17
+ * A node with schema field errors (for example an invalid enum value) stays in the graph, since
18
+ * it still has a type and a place in the hierarchy. Only nodes that cannot be placed are left
19
+ * out. This is NOT a validation pass — nodes may have field errors, broken references, rule
20
+ * violations, or duplicates. Use validateSpace for a full validation report.
21
21
  *
22
22
  * Shared by the render pipeline and the public loadSpaceGraph API so their
23
23
  * graph-assembly behaviour cannot drift.
@@ -1,25 +1,29 @@
1
1
  import { filterNodes } from './filter/filter-nodes';
2
2
  import { readSpace } from './read/read-space';
3
+ import { extractSchemaTypeNames } from './schema/schema';
3
4
  import { createSpaceContext } from './space-context';
4
5
  import { buildSpaceGraph, selectHierarchy } from './space-graph';
5
6
  /**
6
- * Assemble a SpaceGraph from a space context: read the space, drop nodes that
7
- * fail schema validation, build the graph for the selected hierarchy (the main
8
- * hierarchy by default), and apply an optional view/filter expression.
7
+ * Assemble a SpaceGraph from a space context: read the space, drop nodes whose type the schema
8
+ * does not define, build the graph for the selected hierarchy (the main hierarchy by default),
9
+ * and apply an optional view/filter expression.
9
10
  *
10
- * Nodes that fail schema validation are silently excluded so the graph is
11
- * well-formed enough to render and traverse. This is NOT a validation pass —
12
- * schema-valid nodes may still have broken references, rule violations, or
13
- * duplicates. Use validateSpace for a full validation report.
11
+ * A node with schema field errors (for example an invalid enum value) stays in the graph, since
12
+ * it still has a type and a place in the hierarchy. Only nodes that cannot be placed are left
13
+ * out. This is NOT a validation pass — nodes may have field errors, broken references, rule
14
+ * violations, or duplicates. Use validateSpace for a full validation report.
14
15
  *
15
16
  * Shared by the render pipeline and the public loadSpaceGraph API so their
16
17
  * graph-assembly behaviour cannot drift.
17
18
  */
18
19
  export async function assembleSpaceGraph(context, options = {}) {
19
20
  const { nodes: allNodes } = options.readResult ?? (await readSpace(context));
20
- const { schemaValidator } = context;
21
- const validNodes = allNodes.filter((node) => schemaValidator(node.schemaData));
22
- let graph = buildSpaceGraph(validNodes, selectHierarchy(context.schema.metadata, options.hierarchy));
21
+ const { schema, schemaRefRegistry } = context;
22
+ const schemaTypes = Array.isArray(schema.oneOf) ? extractSchemaTypeNames(schema, schemaRefRegistry) : undefined;
23
+ const placeableNodes = schemaTypes
24
+ ? allNodes.filter((node) => schemaTypes.has(String(node.schemaData.type)))
25
+ : allNodes;
26
+ let graph = buildSpaceGraph(placeableNodes, selectHierarchy(context.schema.metadata, options.hierarchy));
23
27
  if (options.filter) {
24
28
  const expression = context.space.views?.[options.filter]?.expression ?? options.filter;
25
29
  graph = await filterNodes(expression, graph);
@@ -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
  }
@@ -17,6 +17,12 @@ export type PluginSchemaSource = {
17
17
  * Throws if a lower-precedence schema collides with a reserved $id or violates namespace rules.
18
18
  */
19
19
  export declare function buildFullRegistry(schemaPath: string, pluginSchemas?: PluginSchemaSource[]): Map<string, AnySchemaObject>;
20
+ /**
21
+ * Validator for one node type: the schema's shared `allOf` plus only that type's `oneOf` branch.
22
+ * Errors then come only from the node's own type, not from every branch that did not match.
23
+ * Returns undefined when the schema has no such branch, so callers fall back to the full validator.
24
+ */
25
+ export declare function typeValidatorFor(fullValidator: ValidateFunction, type: string): ValidateFunction | undefined;
20
26
  export declare function createValidator(schemaPath: string, pluginSchemas?: PluginSchemaSource[]): ValidateFunction;
21
27
  export declare function resolveNodeType(type: string, typeAliases: Record<string, string> | undefined): string;
22
28
  export interface EntityInfo {
@@ -234,7 +234,68 @@ function compileValidator(targetSchema, schemaRefRegistry) {
234
234
  continue;
235
235
  ajv.addSchema(schema);
236
236
  }
237
- return ajv.compile(targetSchema);
237
+ const validator = ajv.compile(targetSchema);
238
+ typeValidators.set(validator, createTypeValidatorLookup(ajv, targetSchema, schemaRefRegistry));
239
+ return validator;
240
+ }
241
+ /** Root keywords that do not constrain a node: a root with any other keyword cannot be split into per-type checks. */
242
+ const NON_CONSTRAINING_ROOT_KEYWORDS = new Set([
243
+ '$schema',
244
+ '$id',
245
+ '$metadata',
246
+ '$defs',
247
+ 'title',
248
+ 'description',
249
+ 'allOf',
250
+ 'oneOf',
251
+ ]);
252
+ /** Per-type lookups, keyed by the full-schema validator they were compiled alongside. */
253
+ const typeValidators = new WeakMap();
254
+ /**
255
+ * Validator for one node type: the schema's shared `allOf` plus only that type's `oneOf` branch.
256
+ * Errors then come only from the node's own type, not from every branch that did not match.
257
+ * Returns undefined when the schema has no such branch, so callers fall back to the full validator.
258
+ */
259
+ export function typeValidatorFor(fullValidator, type) {
260
+ return typeValidators.get(fullValidator)?.(type);
261
+ }
262
+ /**
263
+ * Compiled on first use. The wrapper references the branches inside the already-compiled root,
264
+ * so a branch's relative `$ref`s still resolve against the root.
265
+ */
266
+ function createTypeValidatorLookup(ajv, rootSchema, schemaRefRegistry) {
267
+ const rootId = rootSchema.$id;
268
+ if (typeof rootId !== 'string' ||
269
+ !Array.isArray(rootSchema.oneOf) ||
270
+ !Object.keys(rootSchema).every((k) => NON_CONSTRAINING_ROOT_KEYWORDS.has(k))) {
271
+ return () => undefined;
272
+ }
273
+ const branchByType = new Map();
274
+ rootSchema.oneOf.forEach((branch, index) => {
275
+ const typeDef = mergeVariantProperties(branch, rootSchema, schemaRefRegistry).properties.type;
276
+ const types = typeDef?.const !== undefined ? [typeDef.const] : Array.isArray(typeDef?.enum) ? typeDef.enum : [];
277
+ for (const t of types)
278
+ if (!branchByType.has(String(t)))
279
+ branchByType.set(String(t), index);
280
+ });
281
+ const shared = Array.isArray(rootSchema.allOf)
282
+ ? rootSchema.allOf.map((_, i) => ({ $ref: `${rootId}#/allOf/${i}` }))
283
+ : [];
284
+ const compiled = new Map();
285
+ return (type) => {
286
+ const index = branchByType.get(type);
287
+ if (index === undefined)
288
+ return undefined;
289
+ let validator = compiled.get(index);
290
+ if (!validator) {
291
+ validator = ajv.compile({
292
+ $id: `${rootId}/__type/${index}`,
293
+ allOf: [...shared, { $ref: `${rootId}#/oneOf/${index}` }],
294
+ });
295
+ compiled.set(index, validator);
296
+ }
297
+ return validator;
298
+ };
238
299
  }
239
300
  export function createValidator(schemaPath, pluginSchemas) {
240
301
  return compileValidator(readRawSchema(schemaPath), buildFullRegistry(schemaPath, pluginSchemas));
@@ -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;
17
- errors: ErrorObject[];
18
- nodeData: Record<string, unknown>;
15
+ nodeId: NodeId;
16
+ source?: SourceLocation;
17
+ /** Formatted, deduplicated schema problems for the node, one per field problem. */
18
+ issues: FormattedError[];
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
@@ -2,7 +2,7 @@ import { isAbsolute, relative, resolve } from 'node:path';
2
2
  import { readSpace } from './read/read-space';
3
3
  import { computeHierarchyDepths, evaluateRulesWithGating } from './schema/gating';
4
4
  import { filterRules } from './schema/rule-filter';
5
- import { extractEntityInfo } from './schema/schema';
5
+ import { extractEntityInfo, typeValidatorFor } from './schema/schema';
6
6
  import { validateGraph } from './schema/validate-graph';
7
7
  import { validateRules } from './schema/validate-rules';
8
8
  import { createSpaceContext } from './space-context';
@@ -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`
@@ -155,46 +177,34 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
155
177
  contextTrimmed: { nodeCount: 0, ruleIds: [] },
156
178
  };
157
179
  for (const node of nodes) {
158
- // Early type validation - check before full AJV validation to prevent cascading errors
180
+ // Check the type first: an unknown type reports one error instead of one per oneOf branch.
159
181
  const nodeType = node.schemaData.type;
160
182
  if (nodeType !== undefined && validTypes.length > 0 && !validTypes.includes(nodeType)) {
161
183
  const sortedTypes = [...validTypes].sort();
162
184
  result.nodeFailures.push({
163
185
  node,
164
- invalidType: { validTypes },
165
- errors: [
186
+ issues: [
166
187
  {
167
- instancePath: '/type',
168
- keyword: 'enum',
169
188
  message: `Invalid type "${nodeType}". Valid types are: ${sortedTypes.join(', ')}`,
170
- params: { allowedValues: sortedTypes },
171
- schemaPath: '#/oneOf',
172
- data: nodeType,
189
+ dedupeKey: `type:${sortedTypes.join(',')}`,
173
190
  },
174
191
  ],
175
192
  });
176
193
  continue;
177
194
  }
178
- if (schemaValidator(node.schemaData)) {
195
+ // A valid type is checked against its own branch only, so errors come from that type alone.
196
+ const validate = (nodeType !== undefined && typeValidatorFor(schemaValidator, nodeType)) || schemaValidator;
197
+ if (validate(node.schemaData)) {
179
198
  result.validCount++;
180
199
  }
181
200
  else {
182
- result.nodeFailures.push({ node, errors: schemaValidator.errors || [] });
183
- }
184
- }
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 });
201
+ result.nodeFailures.push({
202
+ node,
203
+ issues: formatErrors(validate.errors || [], schema, schemaRefRegistry, node.schemaData),
204
+ });
196
205
  }
197
206
  }
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);
@@ -266,11 +276,10 @@ export async function validateSpace(context, options = {}) {
266
276
  return {
267
277
  validCount: result.validCount,
268
278
  nodeErrorCount: result.nodeFailures.length,
269
- nodeErrors: result.nodeFailures.map(({ node, errors }) => ({
270
- file: node.source?.file ?? node.id,
271
- ...(node.source?.line !== undefined ? { line: node.source.line } : {}),
272
- errors,
273
- nodeData: node.schemaData,
279
+ nodeErrors: result.nodeFailures.map(({ node, issues }) => ({
280
+ nodeId: node.id,
281
+ ...(node.source !== undefined && { source: node.source }),
282
+ issues,
274
283
  })),
275
284
  refErrors: result.refErrors,
276
285
  duplicateErrors: result.duplicateErrors,
@@ -302,54 +311,43 @@ export function resolveFileSpace(filePath, config) {
302
311
  * Select one file's entries from a space's validation result: issues keyed and formatted, errors split from warnings.
303
312
  * Pure — a file's entries are exactly its slice of the result.
304
313
  */
305
- function issuesForFile(result, label, context, includeGated) {
306
- const { schema, schemaRefRegistry } = context;
314
+ function issuesForFile(result, label, includeGated) {
307
315
  const errors = {};
308
316
  const warnings = {};
309
- for (const { node, errors: ajvErrors, invalidType } of result.nodeFailures) {
317
+ for (const { node, issues } of result.nodeFailures) {
310
318
  if (node.id !== label)
311
319
  continue;
312
320
  const position = {
313
321
  ...(node.source?.line !== undefined ? { line: node.source.line } : {}),
314
322
  ...(node.source?.column !== undefined ? { column: node.source.column } : {}),
315
323
  };
316
- if (invalidType) {
317
- errors[`schema:type:${invalidType.validTypes.join(',')}`] = {
318
- kind: 'schema',
319
- message: ajvErrors[0].message,
320
- ...position,
321
- };
322
- continue;
323
- }
324
- const formatted = formatErrors(ajvErrors, schema, schemaRefRegistry, node.schemaData);
325
- for (const { message, dedupeKey } of formatted) {
324
+ for (const { message, dedupeKey } of issues) {
326
325
  errors[`schema:${dedupeKey}`] = { kind: 'schema', message, ...position };
327
326
  }
328
327
  }
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))
328
+ // Duplicate id errors — include if this node's id is duplicated
329
+ for (const { id, sources } of result.duplicateErrors) {
330
+ if (id !== label)
332
331
  continue;
333
- const others = files.filter((f) => f !== label);
334
332
  errors[`duplicate:${id}`] = {
335
333
  kind: 'duplicate',
336
- message: `Duplicate id "${id}" also exists in: ${others.join(', ')}`,
334
+ message: `Duplicate id "${id}" is used by ${sources.length} nodes${sources.length > 0 ? `: ${sources.map(formatSource).join(', ')}` : ''}`,
337
335
  };
338
336
  }
339
337
  // Broken links and hierarchy violations from this file
340
- for (const { file, parent, error } of result.refErrors) {
341
- if (file === label) {
338
+ for (const { nodeId, parent, error } of result.refErrors) {
339
+ if (nodeId === label) {
342
340
  errors[`broken-link:${parent}`] = { kind: 'broken-link', message: `${parent} → ${error}` };
343
341
  }
344
342
  }
345
343
  for (const v of result.hierarchyViolations) {
346
- if (v.file === label) {
344
+ if (v.nodeId === label) {
347
345
  errors[`hierarchy:${v.description}`] = { kind: 'hierarchy', message: v.description };
348
346
  }
349
347
  }
350
348
  // Rule violations — only error severity counts as an error.
351
349
  for (const v of result.ruleViolations) {
352
- if (v.file === label) {
350
+ if (v.nodeId === label) {
353
351
  const target = v.severity === 'error' ? errors : warnings;
354
352
  target[`rule:${v.ruleId}`] = {
355
353
  kind: 'rule',
@@ -360,18 +358,18 @@ function issuesForFile(result, label, context, includeGated) {
360
358
  }
361
359
  // A gated file with no violations would otherwise look clean — say that its rules were not checked.
362
360
  for (const gate of result.coverageGates) {
363
- if (!gate.gatedFiles.includes(label))
361
+ if (!gate.gatedNodeIds.some((id) => id === label))
364
362
  continue;
365
363
  const cause = gate.reason === 'rule' ? `blocking rule "${gate.ruleId}"` : `${gate.reason} failure`;
366
- warnings[`gated:${gate.file}`] = {
364
+ warnings[`gated:${gate.nodeId}`] = {
367
365
  kind: 'gated',
368
- message: `Rule checks ${includeGated ? 'evaluated anyway' : 'not evaluated'}: gated by ${cause} on ${gate.file}`,
366
+ message: `Rule checks ${includeGated ? 'evaluated anyway' : 'not evaluated'}: gated by ${cause} on ${gate.nodeId}`,
369
367
  severity: 'info',
370
368
  };
371
369
  }
372
370
  // Unresolved content links (warnings — do not affect errorCount)
373
371
  for (const link of result.unresolvedContentLinks) {
374
- if (link.file === label) {
372
+ if (link.nodeId === label) {
375
373
  warnings[`content-link:${link.target}`] = {
376
374
  kind: 'content-link',
377
375
  message: `Unresolved content link: ${link.target}`,
@@ -400,7 +398,7 @@ export async function validateResolvedFile(filePath, label, context, options = {
400
398
  judge: options.judge,
401
399
  judgeTarget: label,
402
400
  });
403
- const { errors, warnings } = issuesForFile(result, label, context, options.includeGated);
401
+ const { errors, warnings } = issuesForFile(result, label, options.includeGated);
404
402
  return {
405
403
  file: isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath),
406
404
  label,
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.1",
3
+ "version": "0.19.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",