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.
- package/dist/commands/dump.d.ts +2 -2
- package/dist/commands/dump.js +2 -2
- package/dist/commands/validate.js +35 -42
- package/dist/load-space-graph.d.ts +7 -7
- package/dist/load-space-graph.js +14 -10
- 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/schema.d.ts +6 -0
- package/dist/schema/schema.js +62 -1
- 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 +18 -10
- package/dist/validate.js +58 -60
- package/docs/concepts.md +7 -1
- package/docs/rules.md +1 -1
- package/package.json +1 -1
package/dist/commands/dump.d.ts
CHANGED
|
@@ -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
|
|
14
|
-
* Filtered dumps match show/render: the filter runs over the graph of
|
|
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>;
|
package/dist/commands/dump.js
CHANGED
|
@@ -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
|
|
8
|
-
* Filtered dumps match show/render: the filter runs over the graph of
|
|
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 {
|
|
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 {
|
|
29
|
-
const
|
|
30
|
-
|
|
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 {
|
|
35
|
-
addError(
|
|
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,
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
|
45
|
-
addError(v.
|
|
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.
|
|
44
|
+
const globalRuleErrorCount = result.ruleViolations.filter((v) => !v.nodeId && v.severity === 'error').length;
|
|
49
45
|
for (const v of result.hierarchyViolations) {
|
|
50
|
-
addError(v.
|
|
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 {
|
|
104
|
-
if (!byFile.has(
|
|
105
|
-
byFile.set(
|
|
106
|
-
byFile.get(
|
|
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 [
|
|
109
|
-
console.log(` ${
|
|
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(({
|
|
125
|
-
|
|
126
|
-
|
|
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(({
|
|
136
|
-
|
|
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,
|
|
135
|
+
result.duplicateErrors.forEach(({ id, sources }) => {
|
|
143
136
|
console.log(` "${id}":`);
|
|
144
|
-
for (const
|
|
145
|
-
console.log(` ${
|
|
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.
|
|
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.
|
|
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.
|
|
179
|
-
for (const
|
|
180
|
-
console.log(` ${
|
|
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 {
|
|
186
|
-
console.log(` ${
|
|
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.
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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.
|
package/dist/load-space-graph.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
21
|
-
const
|
|
22
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/schema/schema.d.ts
CHANGED
|
@@ -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 {
|
package/dist/schema/schema.js
CHANGED
|
@@ -234,7 +234,68 @@ function compileValidator(targetSchema, schemaRefRegistry) {
|
|
|
234
234
|
continue;
|
|
235
235
|
ajv.addSchema(schema);
|
|
236
236
|
}
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
//
|
|
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
|
-
|
|
165
|
-
errors: [
|
|
186
|
+
issues: [
|
|
166
187
|
{
|
|
167
|
-
instancePath: '/type',
|
|
168
|
-
keyword: 'enum',
|
|
169
188
|
message: `Invalid type "${nodeType}". Valid types are: ${sortedTypes.join(', ')}`,
|
|
170
|
-
|
|
171
|
-
schemaPath: '#/oneOf',
|
|
172
|
-
data: nodeType,
|
|
189
|
+
dedupeKey: `type:${sortedTypes.join(',')}`,
|
|
173
190
|
},
|
|
174
191
|
],
|
|
175
192
|
});
|
|
176
193
|
continue;
|
|
177
194
|
}
|
|
178
|
-
|
|
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({
|
|
183
|
-
|
|
184
|
-
|
|
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 });
|
|
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 {
|
|
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);
|
|
@@ -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,
|
|
270
|
-
|
|
271
|
-
...(node.source
|
|
272
|
-
|
|
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,
|
|
306
|
-
const { schema, schemaRefRegistry } = context;
|
|
314
|
+
function issuesForFile(result, label, includeGated) {
|
|
307
315
|
const errors = {};
|
|
308
316
|
const warnings = {};
|
|
309
|
-
for (const { node,
|
|
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
|
-
|
|
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
|
|
330
|
-
for (const { id,
|
|
331
|
-
if (
|
|
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}"
|
|
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 {
|
|
341
|
-
if (
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
364
|
+
warnings[`gated:${gate.nodeId}`] = {
|
|
367
365
|
kind: 'gated',
|
|
368
|
-
message: `Rule checks ${includeGated ? 'evaluated anyway' : 'not evaluated'}: gated by ${cause} on ${gate.
|
|
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.
|
|
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,
|
|
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
|
|
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