structured-context 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -216
- package/dist/api.d.ts +17 -17
- package/dist/api.js +12 -12
- package/dist/commands/docs.js +1 -0
- package/dist/commands/providers.d.ts +4 -0
- package/dist/commands/providers.js +52 -0
- package/dist/commands/schemas.js +21 -2
- package/dist/commands/validate-file.d.ts +3 -0
- package/dist/commands/validate-file.js +17 -2
- package/dist/commands/validate.d.ts +4 -0
- package/dist/commands/validate.js +42 -3
- package/dist/config.d.ts +7 -0
- package/dist/config.js +26 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +59 -18
- package/dist/judge/context.d.ts +20 -0
- package/dist/judge/context.js +105 -0
- package/dist/judge/factory.d.ts +44 -0
- package/dist/judge/factory.js +148 -0
- package/dist/judge/index.d.ts +13 -0
- package/dist/judge/index.js +8 -0
- package/dist/judge/judge-node.d.ts +27 -0
- package/dist/judge/judge-node.js +122 -0
- package/dist/judge/prompt.d.ts +10 -0
- package/dist/judge/prompt.js +92 -0
- package/dist/judge/provider/anthropic.d.ts +17 -0
- package/dist/judge/provider/anthropic.js +43 -0
- package/dist/judge/provider/fake.d.ts +26 -0
- package/dist/judge/provider/fake.js +56 -0
- package/dist/judge/provider/jev.d.ts +37 -0
- package/dist/judge/provider/jev.js +169 -0
- package/dist/judge/provider/openai-decisions.d.ts +21 -0
- package/dist/judge/provider/openai-decisions.js +84 -0
- package/dist/judge/provider/openai.d.ts +20 -0
- package/dist/judge/provider/openai.js +53 -0
- package/dist/judge/provider/per-rule.d.ts +10 -0
- package/dist/judge/provider/per-rule.js +18 -0
- package/dist/judge/trim.d.ts +27 -0
- package/dist/judge/trim.js +62 -0
- package/dist/judge/types.d.ts +92 -0
- package/dist/judge/types.js +9 -0
- package/dist/judge/verdicts.d.ts +24 -0
- package/dist/judge/verdicts.js +82 -0
- package/dist/load-space-graph.d.ts +3 -3
- package/dist/load-space-graph.js +4 -4
- package/dist/plugins/loader.js +1 -1
- package/dist/plugins/markdown/parse-embedded.d.ts +2 -0
- package/dist/plugins/markdown/parse-embedded.js +161 -24
- package/dist/plugins/markdown/read-space.js +4 -9
- package/dist/plugins/markdown/render-directory.js +5 -5
- package/dist/plugins/markdown/template-sync.js +3 -4
- package/dist/plugins/markdown/util.d.ts +26 -5
- package/dist/plugins/markdown/util.js +56 -15
- package/dist/render/render.d.ts +1 -1
- package/dist/render/render.js +1 -1
- package/dist/schema/evaluate-rule.d.ts +33 -2
- package/dist/schema/evaluate-rule.js +58 -4
- package/dist/schema/gating.d.ts +69 -0
- package/dist/schema/gating.js +253 -0
- package/dist/schema/metadata-contract.d.ts +625 -12
- package/dist/schema/metadata-contract.js +110 -4
- package/dist/schema/rule-filter.d.ts +12 -0
- package/dist/schema/rule-filter.js +46 -0
- package/dist/schema/rule-severity.d.ts +13 -0
- package/dist/schema/rule-severity.js +33 -0
- package/dist/schema/schema.js +31 -1
- package/dist/schema/validate-rules.d.ts +17 -8
- package/dist/schema/validate-rules.js +91 -48
- package/dist/schemas/_knowledge_wiki.json +4 -4
- package/dist/schemas/_okf.json +172 -0
- package/dist/schemas/_ost_strict.json +15 -6
- package/dist/schemas/_sctx_base.json +1 -1
- package/dist/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
- package/dist/schemas/generated/latest/_structured_context_schema_meta.json +177 -6
- package/dist/schemas/okf.json +8 -0
- package/dist/space-context.d.ts +2 -2
- package/dist/space-context.js +3 -3
- package/dist/space-graph.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/types.d.ts +11 -4
- package/dist/validate.d.ts +59 -9
- package/dist/validate.js +225 -120
- package/docs/concepts.md +52 -6
- package/docs/config.md +70 -1
- package/docs/rules.md +140 -49
- package/docs/schemas.md +43 -18
- package/package.json +6 -5
- package/schemas/_knowledge_wiki.json +4 -4
- package/schemas/_okf.json +172 -0
- package/schemas/_ost_strict.json +15 -6
- package/schemas/_sctx_base.json +1 -1
- package/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
- package/schemas/generated/latest/_structured_context_schema_meta.json +177 -6
- package/schemas/okf.json +8 -0
package/dist/validate.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve } from 'node:path';
|
|
2
|
-
import { readSpace } from './read/read-space';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
2
|
+
import { readSpace } from './read/read-space.js';
|
|
3
|
+
import { computeHierarchyDepths, evaluateRulesWithGating } from './schema/gating.js';
|
|
4
|
+
import { filterRules } from './schema/rule-filter.js';
|
|
5
|
+
import { extractEntityInfo } from './schema/schema.js';
|
|
6
|
+
import { validateGraph } from './schema/validate-graph.js';
|
|
7
|
+
import { validateRules } from './schema/validate-rules.js';
|
|
8
|
+
import { createSpaceContext } from './space-context.js';
|
|
9
|
+
import { buildSpaceGraph } from './space-graph.js';
|
|
8
10
|
/**
|
|
9
11
|
* Format AJV errors for better readability.
|
|
10
12
|
* Groups related errors and extracts helpful context like allowed values.
|
|
@@ -92,73 +94,101 @@ export function formatErrors(errors, schema, schemaRefRegistry, nodeData) {
|
|
|
92
94
|
}
|
|
93
95
|
return formatted;
|
|
94
96
|
}
|
|
97
|
+
/** One-line text for a rule violation: rule, description, and any evidence and verdict guidance. */
|
|
98
|
+
export function ruleViolationMessage(v) {
|
|
99
|
+
const evidence = v.evidence ? ` (Evidence: ${v.evidence})` : '';
|
|
100
|
+
const guidance = v.guidance ? ` Guidance: ${v.guidance}` : '';
|
|
101
|
+
return `[${v.ruleId}] ${v.description}${evidence}${guidance}`;
|
|
102
|
+
}
|
|
103
|
+
const summariseTrim = (report) => ({
|
|
104
|
+
nodeCount: report.nodes.size,
|
|
105
|
+
ruleIds: [...report.ruleIds].sort(),
|
|
106
|
+
});
|
|
95
107
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
108
|
+
* Evaluate a space's rules. With a main hierarchy, evaluation cascades: schema, hierarchy
|
|
109
|
+
* and gating-rule failures (the `seeds`) suppress rule evaluation for the failing node's subtree.
|
|
110
|
+
* Called by `validateNodes`, the core behind `validateSpace` and `validateFile`, so both report identical rule results for a node.
|
|
111
|
+
* Semantic rules run only when a `judge` is supplied.
|
|
98
112
|
*/
|
|
99
|
-
|
|
113
|
+
async function evaluateSpaceRules(nodes, metadata, seeds, options = {}) {
|
|
114
|
+
const rules = metadata.rules && filterRules(metadata.rules, options.ruleFilter);
|
|
115
|
+
if (!rules)
|
|
116
|
+
return { violations: [], gates: [] };
|
|
117
|
+
const provider = options.judge;
|
|
118
|
+
if (!metadata.hierarchy) {
|
|
119
|
+
const result = await validateRules(nodes, rules, {
|
|
120
|
+
judge: provider,
|
|
121
|
+
judgeTarget: options.judgeTarget,
|
|
122
|
+
trimReport: options.trimReport,
|
|
123
|
+
});
|
|
124
|
+
return { violations: result, gates: [] };
|
|
125
|
+
}
|
|
126
|
+
const graph = buildSpaceGraph(nodes, metadata.hierarchy);
|
|
127
|
+
return evaluateRulesWithGating(nodes, rules, graph, seeds, {
|
|
128
|
+
includeGated: options.includeGated,
|
|
129
|
+
provider,
|
|
130
|
+
judgeTarget: options.judgeTarget,
|
|
131
|
+
trimReport: options.trimReport,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The one validation pipeline: type and schema checks, duplicate titles, reference and hierarchy
|
|
136
|
+
* checks, cascade-gated rules, and unresolved content links. Returns every entry; `validateSpace`
|
|
137
|
+
* and `validateFile` are views over it, so a file's results match its entry in a full run.
|
|
138
|
+
* `judgeTarget` limits judging to one file (and the ancestors that can gate it).
|
|
139
|
+
*/
|
|
140
|
+
async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
|
|
100
141
|
const { schema, schemaRefRegistry, schemaValidator } = context;
|
|
101
142
|
const metadata = schema.metadata;
|
|
102
|
-
const readResult = options.readResult ?? (await readSpace(context));
|
|
103
|
-
const { nodes, parseIssues } = readResult;
|
|
104
143
|
// Pre-extract valid types for early type validation
|
|
105
144
|
const validTypes = Array.isArray(schema.oneOf) ? extractEntityInfo(schema, schemaRefRegistry).map((e) => e.type) : [];
|
|
106
145
|
const result = {
|
|
107
146
|
validCount: 0,
|
|
108
|
-
|
|
109
|
-
nodeErrors: [],
|
|
147
|
+
nodeFailures: [],
|
|
110
148
|
refErrors: [],
|
|
111
149
|
duplicateErrors: [],
|
|
112
150
|
ruleViolations: [],
|
|
113
151
|
hierarchyViolations: [],
|
|
114
152
|
orphans: [],
|
|
115
|
-
parseIssues,
|
|
116
153
|
unresolvedContentLinks: [],
|
|
154
|
+
coverageGates: [],
|
|
155
|
+
contextTrimmed: { nodeCount: 0, ruleIds: [] },
|
|
117
156
|
};
|
|
118
157
|
for (const node of nodes) {
|
|
119
158
|
// Early type validation - check before full AJV validation to prevent cascading errors
|
|
120
159
|
const nodeType = node.schemaData.type;
|
|
121
160
|
if (nodeType !== undefined && validTypes.length > 0 && !validTypes.includes(nodeType)) {
|
|
122
|
-
|
|
123
|
-
result.
|
|
124
|
-
|
|
125
|
-
|
|
161
|
+
const sortedTypes = [...validTypes].sort();
|
|
162
|
+
result.nodeFailures.push({
|
|
163
|
+
node,
|
|
164
|
+
invalidType: { validTypes },
|
|
126
165
|
errors: [
|
|
127
166
|
{
|
|
128
167
|
instancePath: '/type',
|
|
129
168
|
keyword: 'enum',
|
|
130
|
-
message: `Invalid type "${nodeType}". Valid types are: ${
|
|
131
|
-
params: { allowedValues:
|
|
169
|
+
message: `Invalid type "${nodeType}". Valid types are: ${sortedTypes.join(', ')}`,
|
|
170
|
+
params: { allowedValues: sortedTypes },
|
|
132
171
|
schemaPath: '#/oneOf',
|
|
133
172
|
data: nodeType,
|
|
134
173
|
},
|
|
135
174
|
],
|
|
136
|
-
nodeData: node.schemaData,
|
|
137
175
|
});
|
|
138
176
|
continue;
|
|
139
177
|
}
|
|
140
|
-
|
|
141
|
-
if (valid) {
|
|
178
|
+
if (schemaValidator(node.schemaData)) {
|
|
142
179
|
result.validCount++;
|
|
143
180
|
}
|
|
144
181
|
else {
|
|
145
|
-
result.
|
|
146
|
-
result.nodeErrors.push({
|
|
147
|
-
file: node.source?.file ?? node.label,
|
|
148
|
-
...(node.source?.line !== undefined ? { line: node.source.line } : {}),
|
|
149
|
-
errors: schemaValidator.errors || [],
|
|
150
|
-
nodeData: node.schemaData,
|
|
151
|
-
});
|
|
182
|
+
result.nodeFailures.push({ node, errors: schemaValidator.errors || [] });
|
|
152
183
|
}
|
|
153
184
|
}
|
|
154
185
|
// Detect duplicate node keys (titles)
|
|
155
186
|
const titleToFiles = new Map();
|
|
156
187
|
for (const node of nodes) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
titleToFiles.set(title, []);
|
|
188
|
+
if (!titleToFiles.has(node.title)) {
|
|
189
|
+
titleToFiles.set(node.title, []);
|
|
160
190
|
}
|
|
161
|
-
titleToFiles.get(title).push(node.label);
|
|
191
|
+
titleToFiles.get(node.title).push(node.label);
|
|
162
192
|
}
|
|
163
193
|
for (const [title, files] of titleToFiles) {
|
|
164
194
|
if (files.length > 1) {
|
|
@@ -166,16 +196,27 @@ export async function validateSpace(context, options = {}) {
|
|
|
166
196
|
}
|
|
167
197
|
}
|
|
168
198
|
// Validate all hierarchy constraints (field references and structure)
|
|
169
|
-
const hierarchyValidation = validateGraph(nodes, metadata,
|
|
199
|
+
const hierarchyValidation = validateGraph(nodes, metadata, unresolvedRefs);
|
|
170
200
|
result.refErrors.push(...hierarchyValidation.refErrors);
|
|
171
201
|
result.hierarchyViolations = [...hierarchyValidation.violations];
|
|
172
202
|
// Calculate orphans across every hierarchy (informational, not a validation error)
|
|
173
203
|
for (const hierarchy of metadata.hierarchies ?? []) {
|
|
174
204
|
result.orphans.push(...buildSpaceGraph(nodes, hierarchy).orphans);
|
|
175
205
|
}
|
|
176
|
-
// Load and execute rules validation if schema defines rules
|
|
206
|
+
// Load and execute rules validation if schema defines rules (with cascade gating, see docs/rules.md).
|
|
177
207
|
if (metadata.rules) {
|
|
178
|
-
|
|
208
|
+
const seeds = [
|
|
209
|
+
...result.nodeFailures.map(({ node }) => {
|
|
210
|
+
const file = node.source?.file ?? node.label;
|
|
211
|
+
return { file, title: String(node.schemaData.title ?? file), reason: 'schema' };
|
|
212
|
+
}),
|
|
213
|
+
...result.hierarchyViolations.map((v) => ({ file: v.file, title: v.nodeTitle, reason: 'hierarchy' })),
|
|
214
|
+
];
|
|
215
|
+
const trimReport = { nodes: new Set(), ruleIds: new Set() };
|
|
216
|
+
const { violations, gates } = await evaluateSpaceRules(nodes, metadata, seeds, { ...options, trimReport });
|
|
217
|
+
result.ruleViolations = violations;
|
|
218
|
+
result.coverageGates = gates;
|
|
219
|
+
result.contextTrimmed = summariseTrim(trimReport);
|
|
179
220
|
}
|
|
180
221
|
// Collect unresolved content links (warnings — not errors)
|
|
181
222
|
const seen = new Set();
|
|
@@ -192,8 +233,61 @@ export async function validateSpace(context, options = {}) {
|
|
|
192
233
|
}
|
|
193
234
|
return result;
|
|
194
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Run full validation on a space and return a structured result.
|
|
238
|
+
* Does not output to console or call process.exit.
|
|
239
|
+
*/
|
|
240
|
+
export async function validateSpace(context, options = {}) {
|
|
241
|
+
const metadata = context.schema.metadata;
|
|
242
|
+
const readResult = options.readResult ?? (await readSpace(context));
|
|
243
|
+
let nodes = readResult.nodes;
|
|
244
|
+
// Depth limiting: evaluate only hierarchy nodes within N breadth-first levels from the
|
|
245
|
+
// top. Non-hierarchy nodes are unaffected — depth scoping only applies to the hierarchy DAG.
|
|
246
|
+
const skippedByDepth = [];
|
|
247
|
+
if (options.depth !== undefined && metadata.hierarchy) {
|
|
248
|
+
const fullGraph = buildSpaceGraph(nodes, metadata.hierarchy);
|
|
249
|
+
const depths = computeHierarchyDepths(fullGraph);
|
|
250
|
+
const included = [];
|
|
251
|
+
for (const node of nodes) {
|
|
252
|
+
const nodeDepth = depths.get(node.title);
|
|
253
|
+
if (nodeDepth !== undefined && nodeDepth > options.depth) {
|
|
254
|
+
skippedByDepth.push({ file: node.label, title: node.title });
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
included.push(node);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
nodes = included;
|
|
261
|
+
}
|
|
262
|
+
const result = await validateNodes(context, nodes, readResult.unresolvedRefs, {
|
|
263
|
+
includeGated: options.includeGated,
|
|
264
|
+
ruleFilter: options.ruleFilter,
|
|
265
|
+
judge: options.judge,
|
|
266
|
+
});
|
|
267
|
+
return {
|
|
268
|
+
validCount: result.validCount,
|
|
269
|
+
nodeErrorCount: result.nodeFailures.length,
|
|
270
|
+
nodeErrors: result.nodeFailures.map(({ node, errors }) => ({
|
|
271
|
+
file: node.source?.file ?? node.label,
|
|
272
|
+
...(node.source?.line !== undefined ? { line: node.source.line } : {}),
|
|
273
|
+
errors,
|
|
274
|
+
nodeData: node.schemaData,
|
|
275
|
+
})),
|
|
276
|
+
refErrors: result.refErrors,
|
|
277
|
+
duplicateErrors: result.duplicateErrors,
|
|
278
|
+
ruleViolations: result.ruleViolations,
|
|
279
|
+
hierarchyViolations: result.hierarchyViolations,
|
|
280
|
+
orphans: result.orphans,
|
|
281
|
+
parseIssues: readResult.parseIssues,
|
|
282
|
+
unresolvedContentLinks: result.unresolvedContentLinks,
|
|
283
|
+
coverageGates: result.coverageGates,
|
|
284
|
+
contextTrimmed: result.contextTrimmed,
|
|
285
|
+
skippedByDepth,
|
|
286
|
+
depthLimit: options.depth,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
195
289
|
/** Find which space a file belongs to by checking directory containment. */
|
|
196
|
-
function resolveFileSpace(filePath, config) {
|
|
290
|
+
export function resolveFileSpace(filePath, config) {
|
|
197
291
|
const absFile = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
|
|
198
292
|
for (const space of config.spaces) {
|
|
199
293
|
const absSpace = isAbsolute(space.path) ? space.path : resolve(process.cwd(), space.path);
|
|
@@ -206,117 +300,112 @@ function resolveFileSpace(filePath, config) {
|
|
|
206
300
|
return null;
|
|
207
301
|
}
|
|
208
302
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* Reads the full space (required for graph correctness) but filters all reported
|
|
212
|
-
* errors to only those attributable to the target file.
|
|
213
|
-
*
|
|
214
|
-
* Returns a result object — does not output to console or call process.exit.
|
|
215
|
-
* If the file is not in any configured space, returns a result with `inSpace: false`.
|
|
303
|
+
* Select one file's entries from a space's validation result: issues keyed and formatted, errors split from warnings.
|
|
304
|
+
* Pure — a file's entries are exactly its slice of the result.
|
|
216
305
|
*/
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
if (!resolved) {
|
|
220
|
-
return {
|
|
221
|
-
file: filePath,
|
|
222
|
-
inSpace: false,
|
|
223
|
-
message: 'File does not belong to any configured space.',
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
const { space, label } = resolved;
|
|
227
|
-
const context = createSpaceContext(space.name, config, { configDir: options.configDir });
|
|
228
|
-
const { schema, schemaRefRegistry, schemaValidator } = context;
|
|
229
|
-
const metadata = schema.metadata;
|
|
230
|
-
const readResult = await readSpace(context);
|
|
231
|
-
const { nodes } = readResult;
|
|
232
|
-
// Pre-extract valid types for early type validation
|
|
233
|
-
const validTypes = Array.isArray(schema.oneOf) ? extractEntityInfo(schema, schemaRefRegistry).map((e) => e.type) : [];
|
|
306
|
+
function issuesForFile(result, label, context, includeGated) {
|
|
307
|
+
const { schema, schemaRefRegistry } = context;
|
|
234
308
|
const errors = {};
|
|
235
309
|
const warnings = {};
|
|
236
|
-
|
|
237
|
-
for (const node of nodes) {
|
|
310
|
+
for (const { node, errors: ajvErrors, invalidType } of result.nodeFailures) {
|
|
238
311
|
if (node.label !== label)
|
|
239
312
|
continue;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
313
|
+
const position = {
|
|
314
|
+
...(node.source?.line !== undefined ? { line: node.source.line } : {}),
|
|
315
|
+
...(node.source?.column !== undefined ? { column: node.source.column } : {}),
|
|
316
|
+
};
|
|
317
|
+
if (invalidType) {
|
|
318
|
+
errors[`schema:type:${invalidType.validTypes.join(',')}`] = {
|
|
244
319
|
kind: 'schema',
|
|
245
|
-
message:
|
|
246
|
-
...
|
|
247
|
-
...(node.source?.column !== undefined ? { column: node.source.column } : {}),
|
|
320
|
+
message: ajvErrors[0].message,
|
|
321
|
+
...position,
|
|
248
322
|
};
|
|
249
323
|
continue;
|
|
250
324
|
}
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
for (const { message, dedupeKey } of formatted) {
|
|
255
|
-
errors[`schema:${dedupeKey}`] = {
|
|
256
|
-
kind: 'schema',
|
|
257
|
-
message,
|
|
258
|
-
...(node.source?.line !== undefined ? { line: node.source.line } : {}),
|
|
259
|
-
...(node.source?.column !== undefined ? { column: node.source.column } : {}),
|
|
260
|
-
};
|
|
261
|
-
}
|
|
325
|
+
const formatted = formatErrors(ajvErrors, schema, schemaRefRegistry, node.schemaData);
|
|
326
|
+
for (const { message, dedupeKey } of formatted) {
|
|
327
|
+
errors[`schema:${dedupeKey}`] = { kind: 'schema', message, ...position };
|
|
262
328
|
}
|
|
263
329
|
}
|
|
264
330
|
// Duplicate key errors — include if this file is one of the duplicates
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const others = files.filter((f) => f !== label);
|
|
274
|
-
errors[`duplicate:${title}`] = {
|
|
275
|
-
kind: 'duplicate',
|
|
276
|
-
message: `Duplicate title "${title}" also exists in: ${others.join(', ')}`,
|
|
277
|
-
};
|
|
278
|
-
}
|
|
331
|
+
for (const { title, files } of result.duplicateErrors) {
|
|
332
|
+
if (!files.includes(label))
|
|
333
|
+
continue;
|
|
334
|
+
const others = files.filter((f) => f !== label);
|
|
335
|
+
errors[`duplicate:${title}`] = {
|
|
336
|
+
kind: 'duplicate',
|
|
337
|
+
message: `Duplicate title "${title}" also exists in: ${others.join(', ')}`,
|
|
338
|
+
};
|
|
279
339
|
}
|
|
280
340
|
// Broken links and hierarchy violations from this file
|
|
281
|
-
const
|
|
282
|
-
for (const { file, parent, error } of hierarchyValidation.refErrors) {
|
|
341
|
+
for (const { file, parent, error } of result.refErrors) {
|
|
283
342
|
if (file === label) {
|
|
284
343
|
errors[`broken-link:${parent}`] = { kind: 'broken-link', message: `${parent} → ${error}` };
|
|
285
344
|
}
|
|
286
345
|
}
|
|
287
|
-
for (const v of
|
|
346
|
+
for (const v of result.hierarchyViolations) {
|
|
288
347
|
if (v.file === label) {
|
|
289
348
|
errors[`hierarchy:${v.description}`] = { kind: 'hierarchy', message: v.description };
|
|
290
349
|
}
|
|
291
350
|
}
|
|
292
|
-
// Rule violations
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
351
|
+
// Rule violations — only error severity counts as an error.
|
|
352
|
+
for (const v of result.ruleViolations) {
|
|
353
|
+
if (v.file === label) {
|
|
354
|
+
const target = v.severity === 'error' ? errors : warnings;
|
|
355
|
+
target[`rule:${v.ruleId}`] = {
|
|
356
|
+
kind: 'rule',
|
|
357
|
+
message: ruleViolationMessage(v),
|
|
358
|
+
severity: v.severity,
|
|
359
|
+
};
|
|
300
360
|
}
|
|
301
361
|
}
|
|
302
|
-
//
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
362
|
+
// A gated file with no violations would otherwise look clean — say that its rules were not checked.
|
|
363
|
+
for (const gate of result.coverageGates) {
|
|
364
|
+
if (!gate.gatedFiles.includes(label))
|
|
365
|
+
continue;
|
|
366
|
+
const cause = gate.reason === 'rule' ? `blocking rule "${gate.ruleId}"` : `${gate.reason} failure`;
|
|
367
|
+
warnings[`gated:${gate.file}`] = {
|
|
368
|
+
kind: 'gated',
|
|
369
|
+
message: `Rule checks ${includeGated ? 'evaluated anyway' : 'not evaluated'}: gated by ${cause} on ${gate.file}`,
|
|
370
|
+
severity: 'info',
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
// Unresolved content links (warnings — do not affect errorCount)
|
|
374
|
+
for (const link of result.unresolvedContentLinks) {
|
|
375
|
+
if (link.file === label) {
|
|
376
|
+
warnings[`content-link:${link.target}`] = {
|
|
377
|
+
kind: 'content-link',
|
|
378
|
+
message: `Unresolved content link: ${link.target}`,
|
|
379
|
+
};
|
|
314
380
|
}
|
|
315
381
|
}
|
|
382
|
+
return { errors, warnings };
|
|
383
|
+
}
|
|
384
|
+
/** The result for a file that belongs to no configured space. */
|
|
385
|
+
export function fileNotInSpace(filePath) {
|
|
386
|
+
return {
|
|
387
|
+
file: filePath,
|
|
388
|
+
inSpace: false,
|
|
389
|
+
message: 'File does not belong to any configured space.',
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Validate a file in an already-resolved space. Checks the whole space (required for graph
|
|
394
|
+
* correctness), judging only the file, then selects the file's issues from the result.
|
|
395
|
+
*/
|
|
396
|
+
export async function validateResolvedFile(filePath, label, context, options = {}) {
|
|
397
|
+
const readResult = await readSpace(context);
|
|
398
|
+
const result = await validateNodes(context, readResult.nodes, readResult.unresolvedRefs, {
|
|
399
|
+
includeGated: options.includeGated,
|
|
400
|
+
ruleFilter: options.ruleFilter,
|
|
401
|
+
judge: options.judge,
|
|
402
|
+
judgeTarget: label,
|
|
403
|
+
});
|
|
404
|
+
const { errors, warnings } = issuesForFile(result, label, context, options.includeGated);
|
|
316
405
|
return {
|
|
317
406
|
file: isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath),
|
|
318
407
|
label,
|
|
319
|
-
space: space.name,
|
|
408
|
+
space: context.space.name,
|
|
320
409
|
errors,
|
|
321
410
|
errorCount: Object.keys(errors).length,
|
|
322
411
|
warnings,
|
|
@@ -324,3 +413,19 @@ export async function validateFile(filePath, config, options = {}) {
|
|
|
324
413
|
inSpace: true,
|
|
325
414
|
};
|
|
326
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Validate a single file within its space.
|
|
418
|
+
*
|
|
419
|
+
* Reads the full space (required for graph correctness) but filters all reported
|
|
420
|
+
* errors to only those attributable to the target file.
|
|
421
|
+
*
|
|
422
|
+
* Returns a result object — does not output to console or call process.exit.
|
|
423
|
+
* If the file is not in any configured space, returns a result with `inSpace: false`.
|
|
424
|
+
*/
|
|
425
|
+
export async function validateFile(filePath, config, options = {}) {
|
|
426
|
+
const resolved = resolveFileSpace(filePath, config);
|
|
427
|
+
if (!resolved)
|
|
428
|
+
return fileNotInSpace(filePath);
|
|
429
|
+
const context = createSpaceContext(resolved.space.name, config, { configDir: options.configDir });
|
|
430
|
+
return validateResolvedFile(filePath, resolved.label, context, options);
|
|
431
|
+
}
|
package/docs/concepts.md
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
title: "Concepts and Terminology"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Concepts and Terminology
|
|
2
6
|
|
|
3
7
|
This document is the canonical reference for concepts and terminology used in this project. It focuses on the meta-concepts the project supports, not the content of specific frameworks modelled in schemas. Before naming things in code, tests, comments, or documentation, check definitions here for consistency, and update them here when the project's "world view" changes, avoiding blurry terms as much as possible.
|
|
4
8
|
|
|
@@ -47,7 +51,7 @@ Each `.md` file with a `type` frontmatter field is a **typed page** — it repre
|
|
|
47
51
|
|
|
48
52
|
- **Heading with `[type:: x]`** or **anchor-implied type** (e.g. `## My Goal ^goal1`) → becomes a child node.
|
|
49
53
|
- **Relationship headings** (e.g. `### Assumptions`) → when matched to a relationship definition in the schema, signals that following content (list items or table rows) should be typed as that relationship's child type, without requiring explicit inline annotations.
|
|
50
|
-
- **Untyped headings** → update the depth stack for parent resolution but do not become nodes.
|
|
54
|
+
- **Untyped headings** → update the depth stack for parent resolution but do not become nodes. They stay in the content of the node whose section contains them; see [Node content](#node-content).
|
|
51
55
|
- **Typed bullet items** (`- [type:: solution] Title`) → become child nodes at any nesting depth.
|
|
52
56
|
- **Table rows** under a relationship heading → become child nodes of the relationship type.
|
|
53
57
|
- **YAML blocks** and **unbracketed `key:: value` paragraph fields** → merged into the current node's `schemaData`.
|
|
@@ -83,7 +87,7 @@ Key properties:
|
|
|
83
87
|
|
|
84
88
|
#### Preamble
|
|
85
89
|
|
|
86
|
-
**Preamble** is content in a `space on a page` document that appears before the first heading. It is parsed but discarded — not associated with any node
|
|
90
|
+
**Preamble** is content in a `space on a page` document that appears before the first heading. It is parsed but discarded — not associated with any node, and not part of any node's `content`.
|
|
87
91
|
|
|
88
92
|
---
|
|
89
93
|
|
|
@@ -95,6 +99,8 @@ Node types are defined by the schema in use and may vary across schemas. Example
|
|
|
95
99
|
|
|
96
100
|
Each node retains its physical `source` location (`file`, and `line` / `column` where available) so that validation diagnostics, edge errors, and downstream tooling pinpoint the exact location in the vault.
|
|
97
101
|
|
|
102
|
+
A node's `content` is the markdown source of its body; see [Node content](#node-content).
|
|
103
|
+
|
|
98
104
|
> `space_on_a_page` and `dashboard` are not `space node` types — they are `tooling types`.
|
|
99
105
|
|
|
100
106
|
### Embedded node
|
|
@@ -103,6 +109,20 @@ An **embedded node** is a `space node` defined *within* a containing document ra
|
|
|
103
109
|
|
|
104
110
|
A `typed page` may contain embedded nodes in its body. Those nodes become full members of the parsed node set, with `parent references` wired to their containing page or enclosing heading.
|
|
105
111
|
|
|
112
|
+
### Node content
|
|
113
|
+
|
|
114
|
+
A node's `content` is the markdown of its body, copied unchanged from the file: blank lines, list looseness, line endings and code fences all stay as written. The content of a list item node is dedented by the item's content indent, so it reads as markdown on its own. Leading blank lines and trailing whitespace are trimmed.
|
|
115
|
+
|
|
116
|
+
A node owns the source from its heading or list item up to the next heading that ends its section, with these parts removed:
|
|
117
|
+
|
|
118
|
+
- Its own heading line or list marker text.
|
|
119
|
+
- The sections of nested embedded nodes.
|
|
120
|
+
- Field syntax that supplies data: frontmatter and other YAML blocks, and lines that hold only `key:: value` or `[key:: value]` fields. A field written inside a sentence stays in the prose, and its value is still read as a field.
|
|
121
|
+
|
|
122
|
+
An untyped heading belongs to the content of the node whose section contains it. An untyped heading at the same level as an embedded node's heading, or at a higher level (fewer `#`), ends that node's section, so the heading and what follows it go to the enclosing node, or to the page. An untyped heading at a deeper level stays inside the section. Inline fields below an untyped heading are assigned the same way.
|
|
123
|
+
|
|
124
|
+
In a `space_on_a_page` file, content before the first heading (the preamble) is not assigned to any node.
|
|
125
|
+
|
|
106
126
|
### Type alias
|
|
107
127
|
|
|
108
128
|
A **type alias** is an alternative name accepted in the `type` field for a given `space node` type. Aliases allow teams to use their own vocabulary while still receiving schema validation. For example, a schema might accept `outcome` as an alias for `goal`.
|
|
@@ -113,7 +133,7 @@ A `space node`'s resolved type (`resolvedType`) is its canonical type after alia
|
|
|
113
133
|
|
|
114
134
|
## Typed page
|
|
115
135
|
|
|
116
|
-
A **typed page** is a markdown file whose frontmatter declares a `space node` type (e.g. `type: goal`). The file itself represents one node, and its body may additionally contain `embedded nodes`.
|
|
136
|
+
A **typed page** is a markdown file whose frontmatter declares a `space node` type (e.g. `type: goal`). The file itself represents one node, and its body may additionally contain `embedded nodes`. The page node's `content` is the body markdown outside any embedded node (the entire body when there are none).
|
|
117
137
|
|
|
118
138
|
Typed pages are distinct from `space on a page` files: a typed page *is* a `space node`; a `space_on_a_page` file is merely a container.
|
|
119
139
|
|
|
@@ -143,10 +163,34 @@ Rules may be:
|
|
|
143
163
|
|
|
144
164
|
Rules are distinct from schema validation: the schema checks structure; rules check meaning and quality.
|
|
145
165
|
|
|
166
|
+
A rule's `when` is its **applicability condition**: a test that decides whether the rule applies to a node. Its `expression` is the test the node must pass.
|
|
167
|
+
|
|
146
168
|
Each executable rule has a **category** (`validation`, `coherence`, `workflow`, `best-practice`) that supplies its default **severity** (`error`, `warning`, `info`). A rule may override its category's severity, but categories are the preferred grouping. Only `error` severity violations fail validation.
|
|
147
169
|
|
|
148
170
|
See [docs/rules.md](rules.md) for the rules reference, including JSONata expression syntax and the full `$metadata` field reference.
|
|
149
171
|
|
|
172
|
+
### Semantic rules
|
|
173
|
+
|
|
174
|
+
A **semantic rule** is a rule with a `semantic` part. It asks a model to evaluate a node, for example whether an opportunity is a customer need. The `semantic` object can carry a `rubric`, `verdicts`, a verdict `severity` map, a `scope` and `includeContent`. A rule can also have an `expression`, a JSONata test that runs before the model.
|
|
175
|
+
|
|
176
|
+
A **model provider** evaluates semantic rules: a decision model (Jev) or a chat model (Claude, OpenAI-compatible). It is a bundled provider or an entry in `modelProviders`. For each rule, the model gives one **verdict** from the rule's `semantic.verdicts`, for example `customer-need`, `vague` or `solution`. The rule's `semantic.severity` says which verdicts report a violation. Semantic rules cost money, so they are evaluated only when a run asks for it (`--semantic`); a rule's `expression` always runs. See [docs/semantic-rules.md](semantic-rules.md).
|
|
177
|
+
|
|
178
|
+
The **evaluated node** is the node a semantic rule is judged on. A rule's `semantic.scope` adds **related nodes** (parent, siblings, children, ancestors, descendants, or the whole branch) along the main hierarchy's edges, and `semantic.includeContent` chooses whose body is sent. See [Related nodes and content](semantic-rules.md#related-nodes-and-content).
|
|
179
|
+
|
|
180
|
+
A **node ruleset group** is the set of nodes that share the same semantic rules. It forms within one evaluation step of [cascade gating](#cascade-gating): one hierarchy depth, or the set of non-hierarchy nodes. Nodes are keyed on the rules left after `when` and `expression` filtering. A model provider on the `openrouter-decisions` or `typesafe-systemone` API evaluates one group in as few decision requests as it can.
|
|
181
|
+
|
|
182
|
+
A **decision request** is one call to a decisions API. It carries the state of one or more nodes and a list of decision questions.
|
|
183
|
+
|
|
184
|
+
A **decision question** is one choice question about one node under one semantic rule, inside a decision request.
|
|
185
|
+
|
|
186
|
+
The **per-node input cap** limits the input for one evaluated node and its related nodes. See [Input size and trimming](semantic-rules.md#input-size-and-trimming).
|
|
187
|
+
|
|
188
|
+
### Cascade gating
|
|
189
|
+
|
|
190
|
+
**Cascade gating** is the mechanism by which a schema failure, hierarchy structure violation, or blocking rule violation on a node suppresses rule evaluation for that node's `hierarchy` subtree. It exists so downstream checks — especially costly semantic (model-judged) rules — aren't run against inputs already known to be invalid.
|
|
191
|
+
|
|
192
|
+
A **coverage gate** records one instance of this: the gating node, what kind of failure triggered it (`schema`, `hierarchy`, or a specific rule `id`), and the set of nodes whose rules were suppressed as a result. `sctx validate` reports coverage gates so both humans and agents can see the validation "edge" — where evaluation stopped and why. See [docs/rules.md](rules.md#cascade-gating) for the full model, including the per-rule `gatesDescendants` field and the `--include-gated` / `--depth` CLI options.
|
|
193
|
+
|
|
150
194
|
---
|
|
151
195
|
|
|
152
196
|
## Tooling types
|
|
@@ -297,7 +341,7 @@ An **anchor** is a block anchor (e.g. `^goal1`) appended to a heading in a `type
|
|
|
297
341
|
|
|
298
342
|
## Filter expressions
|
|
299
343
|
|
|
300
|
-
A **filter expression** is a string that selects a subset of nodes from a space. Filter expressions are used with the `--filter` flag on the `show`
|
|
344
|
+
A **filter expression** is a string that selects a subset of nodes from a space. Filter expressions are used with the `--filter` flag on the `show` and `render` commands and in named filter views in config.
|
|
301
345
|
|
|
302
346
|
### Syntax
|
|
303
347
|
|
|
@@ -312,7 +356,7 @@ Keywords (`WHERE`, `SELECT`) are case-insensitive.
|
|
|
312
356
|
|
|
313
357
|
### Predicate context
|
|
314
358
|
|
|
315
|
-
The WHERE predicate is a [JSONata](https://docs.jsonata.org/overview) expression evaluated per node. Node fields (from `schemaData`) are accessible directly — e.g. `resolvedType`, `status
|
|
359
|
+
The WHERE predicate is a [JSONata](https://docs.jsonata.org/overview) expression evaluated per node. Node fields (from `schemaData`) are accessible directly — e.g. `resolvedType`, `status`. `label` (relative file path, e.g. `"solutions/My Solution.md"`) and `title` are always available regardless of schema. Additionally, two pre-computed traversal arrays are available:
|
|
316
360
|
|
|
317
361
|
| Field | Description |
|
|
318
362
|
|-------|-------------|
|
|
@@ -328,6 +372,8 @@ Each entry in `ancestors[]` or `descendants[]` includes all schema fields of the
|
|
|
328
372
|
| `_hierarchy` | `string` | Hierarchy name; present only for hierarchy edges |
|
|
329
373
|
| `_selfRef` | `boolean` | Whether the edge is a same-type (self-referential) link |
|
|
330
374
|
|
|
375
|
+
Dates and datetimes are the strings written in frontmatter, so compare them with `$toMillis()` rather than as strings (offsets and precision vary), e.g. `WHERE $toMillis(updated) > $toMillis('2026-03-31T00:00:00Z')`. See [Rules: dates and times](rules.md#dates-and-times).
|
|
376
|
+
|
|
331
377
|
### SELECT spec
|
|
332
378
|
|
|
333
379
|
The SELECT clause expands the result set by walking the graph from matched nodes. The spec is a comma-separated list of directives:
|