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.
Files changed (94) hide show
  1. package/README.md +29 -216
  2. package/dist/api.d.ts +17 -17
  3. package/dist/api.js +12 -12
  4. package/dist/commands/docs.js +1 -0
  5. package/dist/commands/providers.d.ts +4 -0
  6. package/dist/commands/providers.js +52 -0
  7. package/dist/commands/schemas.js +21 -2
  8. package/dist/commands/validate-file.d.ts +3 -0
  9. package/dist/commands/validate-file.js +17 -2
  10. package/dist/commands/validate.d.ts +4 -0
  11. package/dist/commands/validate.js +42 -3
  12. package/dist/config.d.ts +7 -0
  13. package/dist/config.js +26 -3
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +59 -18
  16. package/dist/judge/context.d.ts +20 -0
  17. package/dist/judge/context.js +105 -0
  18. package/dist/judge/factory.d.ts +44 -0
  19. package/dist/judge/factory.js +148 -0
  20. package/dist/judge/index.d.ts +13 -0
  21. package/dist/judge/index.js +8 -0
  22. package/dist/judge/judge-node.d.ts +27 -0
  23. package/dist/judge/judge-node.js +122 -0
  24. package/dist/judge/prompt.d.ts +10 -0
  25. package/dist/judge/prompt.js +92 -0
  26. package/dist/judge/provider/anthropic.d.ts +17 -0
  27. package/dist/judge/provider/anthropic.js +43 -0
  28. package/dist/judge/provider/fake.d.ts +26 -0
  29. package/dist/judge/provider/fake.js +56 -0
  30. package/dist/judge/provider/jev.d.ts +37 -0
  31. package/dist/judge/provider/jev.js +169 -0
  32. package/dist/judge/provider/openai-decisions.d.ts +21 -0
  33. package/dist/judge/provider/openai-decisions.js +84 -0
  34. package/dist/judge/provider/openai.d.ts +20 -0
  35. package/dist/judge/provider/openai.js +53 -0
  36. package/dist/judge/provider/per-rule.d.ts +10 -0
  37. package/dist/judge/provider/per-rule.js +18 -0
  38. package/dist/judge/trim.d.ts +27 -0
  39. package/dist/judge/trim.js +62 -0
  40. package/dist/judge/types.d.ts +92 -0
  41. package/dist/judge/types.js +9 -0
  42. package/dist/judge/verdicts.d.ts +24 -0
  43. package/dist/judge/verdicts.js +82 -0
  44. package/dist/load-space-graph.d.ts +3 -3
  45. package/dist/load-space-graph.js +4 -4
  46. package/dist/plugins/loader.js +1 -1
  47. package/dist/plugins/markdown/parse-embedded.d.ts +2 -0
  48. package/dist/plugins/markdown/parse-embedded.js +161 -24
  49. package/dist/plugins/markdown/read-space.js +4 -9
  50. package/dist/plugins/markdown/render-directory.js +5 -5
  51. package/dist/plugins/markdown/template-sync.js +3 -4
  52. package/dist/plugins/markdown/util.d.ts +26 -5
  53. package/dist/plugins/markdown/util.js +56 -15
  54. package/dist/render/render.d.ts +1 -1
  55. package/dist/render/render.js +1 -1
  56. package/dist/schema/evaluate-rule.d.ts +33 -2
  57. package/dist/schema/evaluate-rule.js +58 -4
  58. package/dist/schema/gating.d.ts +69 -0
  59. package/dist/schema/gating.js +253 -0
  60. package/dist/schema/metadata-contract.d.ts +625 -12
  61. package/dist/schema/metadata-contract.js +110 -4
  62. package/dist/schema/rule-filter.d.ts +12 -0
  63. package/dist/schema/rule-filter.js +46 -0
  64. package/dist/schema/rule-severity.d.ts +13 -0
  65. package/dist/schema/rule-severity.js +33 -0
  66. package/dist/schema/schema.js +31 -1
  67. package/dist/schema/validate-rules.d.ts +17 -8
  68. package/dist/schema/validate-rules.js +91 -48
  69. package/dist/schemas/_knowledge_wiki.json +4 -4
  70. package/dist/schemas/_okf.json +172 -0
  71. package/dist/schemas/_ost_strict.json +15 -6
  72. package/dist/schemas/_sctx_base.json +1 -1
  73. package/dist/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
  74. package/dist/schemas/generated/latest/_structured_context_schema_meta.json +177 -6
  75. package/dist/schemas/okf.json +8 -0
  76. package/dist/space-context.d.ts +2 -2
  77. package/dist/space-context.js +3 -3
  78. package/dist/space-graph.d.ts +1 -1
  79. package/dist/testing.d.ts +1 -1
  80. package/dist/types.d.ts +11 -4
  81. package/dist/validate.d.ts +59 -9
  82. package/dist/validate.js +225 -120
  83. package/docs/concepts.md +52 -6
  84. package/docs/config.md +70 -1
  85. package/docs/rules.md +140 -49
  86. package/docs/schemas.md +43 -18
  87. package/package.json +6 -5
  88. package/schemas/_knowledge_wiki.json +4 -4
  89. package/schemas/_okf.json +172 -0
  90. package/schemas/_ost_strict.json +15 -6
  91. package/schemas/_sctx_base.json +1 -1
  92. package/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
  93. package/schemas/generated/latest/_structured_context_schema_meta.json +177 -6
  94. 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 { extractEntityInfo } from './schema/schema';
4
- import { validateGraph } from './schema/validate-graph';
5
- import { validateRules } from './schema/validate-rules';
6
- import { createSpaceContext } from './space-context';
7
- import { buildSpaceGraph } from './space-graph';
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
- * Run full validation on a space and return a structured result.
97
- * Does not output to console or call process.exit.
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
- export async function validateSpace(context, options = {}) {
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
- nodeErrorCount: 0,
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
- result.nodeErrorCount++;
123
- result.nodeErrors.push({
124
- file: node.source?.file ?? node.label,
125
- ...(node.source?.line !== undefined ? { line: node.source.line } : {}),
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: ${validTypes.sort().join(', ')}`,
131
- params: { allowedValues: validTypes },
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
- const valid = schemaValidator(node.schemaData);
141
- if (valid) {
178
+ if (schemaValidator(node.schemaData)) {
142
179
  result.validCount++;
143
180
  }
144
181
  else {
145
- result.nodeErrorCount++;
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
- const title = node.title;
158
- if (!titleToFiles.has(title)) {
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, readResult.unresolvedRefs);
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
- result.ruleViolations = await validateRules(nodes, metadata.rules);
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
- * Validate a single file within its space.
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
- export async function validateFile(filePath, config, options = {}) {
218
- const resolved = resolveFileSpace(filePath, config);
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
- // Schema validation errors for this node
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
- // Early type validation - check before full AJV validation to prevent cascading errors
241
- const nodeType = node.schemaData.type;
242
- if (nodeType !== undefined && validTypes.length > 0 && !validTypes.includes(nodeType)) {
243
- errors[`schema:type:${validTypes.join(',')}`] = {
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: `Invalid type "${nodeType}". Valid types are: ${validTypes.sort().join(', ')}`,
246
- ...(node.source?.line !== undefined ? { line: node.source.line } : {}),
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 valid = schemaValidator(node.schemaData);
252
- if (!valid) {
253
- const formatted = formatErrors(schemaValidator.errors ?? [], schema, schemaRefRegistry, node.schemaData);
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 titleToFiles = new Map();
266
- for (const node of nodes) {
267
- if (!titleToFiles.has(node.title))
268
- titleToFiles.set(node.title, []);
269
- titleToFiles.get(node.title).push(node.label);
270
- }
271
- for (const [title, files] of titleToFiles) {
272
- if (files.length > 1 && files.includes(label)) {
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 hierarchyValidation = validateGraph(nodes, metadata, readResult.unresolvedRefs);
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 hierarchyValidation.violations) {
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 for this node — only error severity counts as an error
293
- if (metadata.rules) {
294
- const ruleViolations = await validateRules(nodes, metadata.rules);
295
- for (const v of ruleViolations) {
296
- if (v.file === label) {
297
- const target = v.severity === 'error' ? errors : warnings;
298
- target[`rule:${v.ruleId}`] = { kind: 'rule', message: `[${v.ruleId}] ${v.description}`, severity: v.severity };
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
- // Unresolved content links for this node (warnings — do not affect errorCount)
303
- const targetNode = nodes.find((n) => n.label === label);
304
- if (targetNode) {
305
- const seen = new Set();
306
- for (const link of targetNode.resolvedLinks) {
307
- if (link.location === 'internal' && !seen.has(link.target)) {
308
- seen.add(link.target);
309
- warnings[`content-link:${link.target}`] = {
310
- kind: 'content-link',
311
- message: `Unresolved content link: ${link.target}`,
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
- # Structured Context: Concepts and Terminology
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`. Body content appearing before any embedded headings (or the entire body when no embedded headings are present) is preserved as the page node's `content`.
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` command and in named filter views in config.
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`, `title`. Additionally, two pre-computed traversal arrays are available:
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: