structured-context 0.11.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 (138) hide show
  1. package/README.md +52 -198
  2. package/dist/api.d.ts +17 -15
  3. package/dist/api.js +12 -9
  4. package/dist/commands/diagram.d.ts +1 -0
  5. package/dist/commands/diagram.js +7 -1
  6. package/dist/commands/docs.js +1 -0
  7. package/dist/commands/dump.d.ts +15 -2
  8. package/dist/commands/dump.js +18 -4
  9. package/dist/commands/providers.d.ts +4 -0
  10. package/dist/commands/providers.js +52 -0
  11. package/dist/commands/render.d.ts +4 -0
  12. package/dist/commands/render.js +64 -9
  13. package/dist/commands/schemas.d.ts +2 -2
  14. package/dist/commands/schemas.js +133 -22
  15. package/dist/commands/show.d.ts +1 -0
  16. package/dist/commands/show.js +7 -1
  17. package/dist/commands/spaces.d.ts +3 -1
  18. package/dist/commands/spaces.js +11 -2
  19. package/dist/commands/validate-file.d.ts +3 -0
  20. package/dist/commands/validate-file.js +24 -6
  21. package/dist/commands/validate.d.ts +4 -0
  22. package/dist/commands/validate.js +79 -23
  23. package/dist/config.d.ts +25 -1
  24. package/dist/config.js +106 -12
  25. package/dist/filter/augment-nodes.d.ts +3 -1
  26. package/dist/filter/augment-nodes.js +12 -7
  27. package/dist/filter/filter-nodes.js +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +96 -31
  30. package/dist/judge/context.d.ts +20 -0
  31. package/dist/judge/context.js +105 -0
  32. package/dist/judge/factory.d.ts +44 -0
  33. package/dist/judge/factory.js +148 -0
  34. package/dist/judge/index.d.ts +13 -0
  35. package/dist/judge/index.js +8 -0
  36. package/dist/judge/judge-node.d.ts +27 -0
  37. package/dist/judge/judge-node.js +122 -0
  38. package/dist/judge/prompt.d.ts +10 -0
  39. package/dist/judge/prompt.js +92 -0
  40. package/dist/judge/provider/anthropic.d.ts +17 -0
  41. package/dist/judge/provider/anthropic.js +43 -0
  42. package/dist/judge/provider/fake.d.ts +26 -0
  43. package/dist/judge/provider/fake.js +56 -0
  44. package/dist/judge/provider/jev.d.ts +37 -0
  45. package/dist/judge/provider/jev.js +169 -0
  46. package/dist/judge/provider/openai-decisions.d.ts +21 -0
  47. package/dist/judge/provider/openai-decisions.js +84 -0
  48. package/dist/judge/provider/openai.d.ts +20 -0
  49. package/dist/judge/provider/openai.js +53 -0
  50. package/dist/judge/provider/per-rule.d.ts +10 -0
  51. package/dist/judge/provider/per-rule.js +18 -0
  52. package/dist/judge/trim.d.ts +27 -0
  53. package/dist/judge/trim.js +62 -0
  54. package/dist/judge/types.d.ts +92 -0
  55. package/dist/judge/types.js +9 -0
  56. package/dist/judge/verdicts.d.ts +24 -0
  57. package/dist/judge/verdicts.js +82 -0
  58. package/dist/load-space-graph.d.ts +7 -5
  59. package/dist/load-space-graph.js +19 -8
  60. package/dist/plugins/loader.js +3 -3
  61. package/dist/plugins/markdown/extract-content-links.js +8 -29
  62. package/dist/plugins/markdown/index.js +8 -2
  63. package/dist/plugins/markdown/parse-embedded.d.ts +8 -0
  64. package/dist/plugins/markdown/parse-embedded.js +195 -24
  65. package/dist/plugins/markdown/read-space.js +42 -14
  66. package/dist/plugins/markdown/render-directory.d.ts +11 -0
  67. package/dist/plugins/markdown/render-directory.js +130 -0
  68. package/dist/plugins/markdown/template-sync.js +3 -4
  69. package/dist/plugins/markdown/util.d.ts +26 -5
  70. package/dist/plugins/markdown/util.js +56 -15
  71. package/dist/plugins/mermaid/render-mermaid.js +2 -1
  72. package/dist/plugins/miro/sync.js +7 -6
  73. package/dist/plugins/util.d.ts +39 -1
  74. package/dist/plugins/util.js +139 -0
  75. package/dist/read/resolve-graph-edges.d.ts +1 -1
  76. package/dist/read/resolve-graph-edges.js +46 -27
  77. package/dist/read/wikilink-utils.d.ts +17 -0
  78. package/dist/read/wikilink-utils.js +40 -0
  79. package/dist/render/render.d.ts +3 -1
  80. package/dist/render/render.js +2 -2
  81. package/dist/schema/evaluate-rule.d.ts +33 -2
  82. package/dist/schema/evaluate-rule.js +58 -4
  83. package/dist/schema/gating.d.ts +69 -0
  84. package/dist/schema/gating.js +253 -0
  85. package/dist/schema/meta-schemas.d.ts +13 -0
  86. package/dist/schema/meta-schemas.js +50 -0
  87. package/dist/schema/metadata-contract.d.ts +952 -183
  88. package/dist/schema/metadata-contract.js +177 -33
  89. package/dist/schema/rule-filter.d.ts +12 -0
  90. package/dist/schema/rule-filter.js +46 -0
  91. package/dist/schema/rule-severity.d.ts +13 -0
  92. package/dist/schema/rule-severity.js +33 -0
  93. package/dist/schema/schema.d.ts +14 -8
  94. package/dist/schema/schema.js +308 -199
  95. package/dist/schema/validate-graph.d.ts +2 -1
  96. package/dist/schema/validate-graph.js +40 -23
  97. package/dist/schema/validate-rules.d.ts +16 -3
  98. package/dist/schema/validate-rules.js +91 -29
  99. package/dist/schemas/_knowledge_wiki.json +24 -24
  100. package/dist/schemas/_okf.json +172 -0
  101. package/dist/schemas/_ost_strict.json +17 -8
  102. package/dist/schemas/_sctx_base.json +3 -3
  103. package/dist/schemas/_strategy_general.json +38 -38
  104. package/dist/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  105. package/dist/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
  106. package/dist/schemas/generated/latest/_structured_context_schema_meta.json +400 -0
  107. package/dist/schemas/knowledge_wiki.json +10 -9
  108. package/dist/schemas/okf.json +8 -0
  109. package/dist/schemas/strategy_general.json +15 -14
  110. package/dist/schemas/strict_ost.json +17 -16
  111. package/dist/space-context.d.ts +5 -2
  112. package/dist/space-context.js +9 -4
  113. package/dist/space-graph.d.ts +9 -7
  114. package/dist/space-graph.js +7 -5
  115. package/dist/testing.d.ts +33 -0
  116. package/dist/testing.js +72 -0
  117. package/dist/types.d.ts +64 -16
  118. package/dist/validate.d.ts +62 -9
  119. package/dist/validate.js +229 -114
  120. package/docs/concepts.md +65 -9
  121. package/docs/config.md +78 -1
  122. package/docs/rules.md +168 -50
  123. package/docs/schemas.md +152 -52
  124. package/package.json +17 -11
  125. package/schemas/_knowledge_wiki.json +24 -24
  126. package/schemas/_okf.json +172 -0
  127. package/schemas/_ost_strict.json +17 -8
  128. package/schemas/_sctx_base.json +3 -3
  129. package/schemas/_strategy_general.json +38 -38
  130. package/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  131. package/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
  132. package/schemas/generated/latest/_structured_context_schema_meta.json +400 -0
  133. package/schemas/knowledge_wiki.json +10 -9
  134. package/schemas/okf.json +8 -0
  135. package/schemas/strategy_general.json +15 -14
  136. package/schemas/strict_ost.json +17 -16
  137. package/dist/schemas/generated/_structured_context_schema_meta.json +0 -191
  138. package/schemas/generated/_structured_context_schema_meta.json +0 -191
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,71 +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.label,
161
+ const sortedTypes = [...validTypes].sort();
162
+ result.nodeFailures.push({
163
+ node,
164
+ invalidType: { validTypes },
125
165
  errors: [
126
166
  {
127
167
  instancePath: '/type',
128
168
  keyword: 'enum',
129
- message: `Invalid type "${nodeType}". Valid types are: ${validTypes.sort().join(', ')}`,
130
- params: { allowedValues: validTypes },
169
+ message: `Invalid type "${nodeType}". Valid types are: ${sortedTypes.join(', ')}`,
170
+ params: { allowedValues: sortedTypes },
131
171
  schemaPath: '#/oneOf',
132
172
  data: nodeType,
133
173
  },
134
174
  ],
135
- nodeData: node.schemaData,
136
175
  });
137
176
  continue;
138
177
  }
139
- const valid = schemaValidator(node.schemaData);
140
- if (valid) {
178
+ if (schemaValidator(node.schemaData)) {
141
179
  result.validCount++;
142
180
  }
143
181
  else {
144
- result.nodeErrorCount++;
145
- result.nodeErrors.push({
146
- file: node.label,
147
- errors: schemaValidator.errors || [],
148
- nodeData: node.schemaData,
149
- });
182
+ result.nodeFailures.push({ node, errors: schemaValidator.errors || [] });
150
183
  }
151
184
  }
152
185
  // Detect duplicate node keys (titles)
153
186
  const titleToFiles = new Map();
154
187
  for (const node of nodes) {
155
- const title = node.title;
156
- if (!titleToFiles.has(title)) {
157
- titleToFiles.set(title, []);
188
+ if (!titleToFiles.has(node.title)) {
189
+ titleToFiles.set(node.title, []);
158
190
  }
159
- titleToFiles.get(title).push(node.label);
191
+ titleToFiles.get(node.title).push(node.label);
160
192
  }
161
193
  for (const [title, files] of titleToFiles) {
162
194
  if (files.length > 1) {
@@ -164,16 +196,27 @@ export async function validateSpace(context, options = {}) {
164
196
  }
165
197
  }
166
198
  // Validate all hierarchy constraints (field references and structure)
167
- const hierarchyValidation = validateGraph(nodes, metadata, readResult.unresolvedRefs);
199
+ const hierarchyValidation = validateGraph(nodes, metadata, unresolvedRefs);
168
200
  result.refErrors.push(...hierarchyValidation.refErrors);
169
201
  result.hierarchyViolations = [...hierarchyValidation.violations];
170
- // Calculate orphan count (informational, not a validation error)
171
- if (metadata.hierarchy) {
172
- result.orphans = [...buildSpaceGraph(nodes, metadata.hierarchy.levels).orphans];
202
+ // Calculate orphans across every hierarchy (informational, not a validation error)
203
+ for (const hierarchy of metadata.hierarchies ?? []) {
204
+ result.orphans.push(...buildSpaceGraph(nodes, hierarchy).orphans);
173
205
  }
174
- // 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).
175
207
  if (metadata.rules) {
176
- 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);
177
220
  }
178
221
  // Collect unresolved content links (warnings — not errors)
179
222
  const seen = new Set();
@@ -190,8 +233,61 @@ export async function validateSpace(context, options = {}) {
190
233
  }
191
234
  return result;
192
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
+ }
193
289
  /** Find which space a file belongs to by checking directory containment. */
194
- function resolveFileSpace(filePath, config) {
290
+ export function resolveFileSpace(filePath, config) {
195
291
  const absFile = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
196
292
  for (const space of config.spaces) {
197
293
  const absSpace = isAbsolute(space.path) ? space.path : resolve(process.cwd(), space.path);
@@ -204,109 +300,112 @@ function resolveFileSpace(filePath, config) {
204
300
  return null;
205
301
  }
206
302
  /**
207
- * Validate a single file within its space.
208
- *
209
- * Reads the full space (required for graph correctness) but filters all reported
210
- * errors to only those attributable to the target file.
211
- *
212
- * Returns a result object — does not output to console or call process.exit.
213
- * 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.
214
305
  */
215
- export async function validateFile(filePath, config, options = {}) {
216
- const resolved = resolveFileSpace(filePath, config);
217
- if (!resolved) {
218
- return {
219
- file: filePath,
220
- inSpace: false,
221
- message: 'File does not belong to any configured space.',
222
- };
223
- }
224
- const { space, label } = resolved;
225
- const context = createSpaceContext(space.name, config, { configDir: options.configDir });
226
- const { schema, schemaRefRegistry, schemaValidator } = context;
227
- const metadata = schema.metadata;
228
- const readResult = await readSpace(context);
229
- const { nodes } = readResult;
230
- // Pre-extract valid types for early type validation
231
- 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;
232
308
  const errors = {};
233
- // Schema validation errors for this node
234
- for (const node of nodes) {
309
+ const warnings = {};
310
+ for (const { node, errors: ajvErrors, invalidType } of result.nodeFailures) {
235
311
  if (node.label !== label)
236
312
  continue;
237
- // Early type validation - check before full AJV validation to prevent cascading errors
238
- const nodeType = node.schemaData.type;
239
- if (nodeType !== undefined && validTypes.length > 0 && !validTypes.includes(nodeType)) {
240
- 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(',')}`] = {
241
319
  kind: 'schema',
242
- message: `Invalid type "${nodeType}". Valid types are: ${validTypes.sort().join(', ')}`,
320
+ message: ajvErrors[0].message,
321
+ ...position,
243
322
  };
244
323
  continue;
245
324
  }
246
- const valid = schemaValidator(node.schemaData);
247
- if (!valid) {
248
- const formatted = formatErrors(schemaValidator.errors ?? [], schema, schemaRefRegistry, node.schemaData);
249
- for (const { message, dedupeKey } of formatted) {
250
- errors[`schema:${dedupeKey}`] = { kind: 'schema', message };
251
- }
325
+ const formatted = formatErrors(ajvErrors, schema, schemaRefRegistry, node.schemaData);
326
+ for (const { message, dedupeKey } of formatted) {
327
+ errors[`schema:${dedupeKey}`] = { kind: 'schema', message, ...position };
252
328
  }
253
329
  }
254
330
  // Duplicate key errors — include if this file is one of the duplicates
255
- const titleToFiles = new Map();
256
- for (const node of nodes) {
257
- if (!titleToFiles.has(node.title))
258
- titleToFiles.set(node.title, []);
259
- titleToFiles.get(node.title).push(node.label);
260
- }
261
- for (const [title, files] of titleToFiles) {
262
- if (files.length > 1 && files.includes(label)) {
263
- const others = files.filter((f) => f !== label);
264
- errors[`duplicate:${title}`] = {
265
- kind: 'duplicate',
266
- message: `Duplicate title "${title}" also exists in: ${others.join(', ')}`,
267
- };
268
- }
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
+ };
269
339
  }
270
340
  // Broken links and hierarchy violations from this file
271
- const hierarchyValidation = validateGraph(nodes, metadata, readResult.unresolvedRefs);
272
- for (const { file, parent, error } of hierarchyValidation.refErrors) {
341
+ for (const { file, parent, error } of result.refErrors) {
273
342
  if (file === label) {
274
343
  errors[`broken-link:${parent}`] = { kind: 'broken-link', message: `${parent} → ${error}` };
275
344
  }
276
345
  }
277
- for (const v of hierarchyValidation.violations) {
346
+ for (const v of result.hierarchyViolations) {
278
347
  if (v.file === label) {
279
348
  errors[`hierarchy:${v.description}`] = { kind: 'hierarchy', message: v.description };
280
349
  }
281
350
  }
282
- // Rule violations for this node
283
- if (metadata.rules) {
284
- const ruleViolations = await validateRules(nodes, metadata.rules);
285
- for (const v of ruleViolations) {
286
- if (v.file === label) {
287
- errors[`rule:${v.ruleId}`] = { kind: 'rule', message: `[${v.ruleId}] ${v.description}` };
288
- }
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
+ };
289
360
  }
290
361
  }
291
- // Unresolved content links for this node (warnings — do not affect errorCount)
292
- const warnings = {};
293
- const targetNode = nodes.find((n) => n.label === label);
294
- if (targetNode) {
295
- const seen = new Set();
296
- for (const link of targetNode.resolvedLinks) {
297
- if (link.location === 'internal' && !seen.has(link.target)) {
298
- seen.add(link.target);
299
- warnings[`content-link:${link.target}`] = {
300
- kind: 'content-link',
301
- message: `Unresolved content link: ${link.target}`,
302
- };
303
- }
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
+ };
304
380
  }
305
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);
306
405
  return {
307
406
  file: isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath),
308
407
  label,
309
- space: space.name,
408
+ space: context.space.name,
310
409
  errors,
311
410
  errorCount: Object.keys(errors).length,
312
411
  warnings,
@@ -314,3 +413,19 @@ export async function validateFile(filePath, config, options = {}) {
314
413
  inSpace: true,
315
414
  };
316
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
 
@@ -93,6 +97,10 @@ A **space node** (or **node** for short) is a single entity in a `space` — a n
93
97
 
94
98
  Node types are defined by the schema in use and may vary across schemas. Examples from the default schema: `vision`, `mission`, `goal`, `opportunity`, `solution`. The tooling is not prescriptive about which types exist — schemas are designed to be extended and replaced.
95
99
 
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.
101
+
102
+ A node's `content` is the markdown source of its body; see [Node content](#node-content).
103
+
96
104
  > `space_on_a_page` and `dashboard` are not `space node` types — they are `tooling types`.
97
105
 
98
106
  ### Embedded node
@@ -101,6 +109,20 @@ An **embedded node** is a `space node` defined *within* a containing document ra
101
109
 
102
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.
103
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
+
104
126
  ### Type alias
105
127
 
106
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`.
@@ -111,7 +133,7 @@ A `space node`'s resolved type (`resolvedType`) is its canonical type after alia
111
133
 
112
134
  ## Typed page
113
135
 
114
- 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).
115
137
 
116
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.
117
139
 
@@ -123,7 +145,7 @@ A **schema** defines the valid structure for nodes in a `space`: the fields, typ
123
145
 
124
146
  The schema handles structural validation. Cross-node and workflow checks are handled by executable `rules` defined in `$metadata.rules`.
125
147
 
126
- Schemas are composable: structural definitions and metadata can be sourced across `$ref` graphs, then merged deterministically (root metadata applied last, single hierarchy provider, aliases merged, rules merged by `id` with explicit override semantics).
148
+ Schemas are composable. Structural definitions are reused with `$ref` (validation only). Metadata is reused only by listing schema `$id`s in `$metadata.imports`: imports merge transitively, in order, with the schema's own metadata last. Hierarchies, relationships, aliases and rules all follow one rule — a duplicate is an error unless the later entry sets `override: true` — and each declared hierarchy is kept as a separate named hierarchy.
127
149
 
128
150
  ### Rules
129
151
 
@@ -141,8 +163,34 @@ Rules may be:
141
163
 
142
164
  Rules are distinct from schema validation: the schema checks structure; rules check meaning and quality.
143
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
+
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.
169
+
144
170
  See [docs/rules.md](rules.md) for the rules reference, including JSONata expression syntax and the full `$metadata` field reference.
145
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
+
146
194
  ---
147
195
 
148
196
  ## Tooling types
@@ -160,6 +208,10 @@ The **hierarchy** is the ordered list of node types in a space, from root to lea
160
208
 
161
209
  The hierarchy is modelled as a layered DAG: a non-root node may have zero parents (orphaned), one parent, or multiple parents. The `show` command renders this as an indented tree, marking repeated nodes with `(*)` where the subtree is already shown elsewhere.
162
210
 
211
+ A space may have **several hierarchies**, one for each imported schema that declares one. Each hierarchy has a **name** (defaulting to the declaring schema's file stem), and each type belongs to at most one hierarchy. Structural validation runs per hierarchy; relationships may connect types across hierarchies.
212
+
213
+ The **main hierarchy** is the one declared by the root schema; failing that, the only imported hierarchy; failing that, the one the root selects with `"hierarchy": { "$ref": ... }`. The main hierarchy drives `space on a page` parsing, hierarchy embedding, template sync, and default rendering. `show`, `diagram` and `render` take `--hierarchy <name>` to render another. See [docs/schemas.md](schemas.md#multiple-hierarchies).
214
+
163
215
  Each non-root level uses the shared `field`, `fieldOn`, and `multiple` edge options (see [Graph edges](#graph-edges)). Hierarchy-specific options:
164
216
 
165
217
  | Option | Default | Meaning |
@@ -250,9 +302,10 @@ Each entry is a `ResolvedParentRef` object:
250
302
  | `title` | `string` | The parent node's title |
251
303
  | `field` | `string` | The frontmatter field that held the wikilink |
252
304
  | `source` | `'hierarchy' \| 'relationship'` | Whether the edge came from a hierarchy level or a relationship |
305
+ | `hierarchy` | `string` | Name of the hierarchy the edge belongs to; present only when `source` is `'hierarchy'` |
253
306
  | `selfRef` | `boolean` | Whether the edge is a same-type (self-referential) parent link |
254
307
 
255
- The `source` label lets downstream consumers distinguish edge types without re-inspecting the schema. Validation routes `hierarchy` edges to structural checks (parent-type rules, skip-level detection) and `relationship` edges to field reference checks (type-match, missing-target). Tree rendering and rule evaluation use the full set.
308
+ The `source` and `hierarchy` labels let downstream consumers distinguish edge types and hierarchies without re-inspecting the schema. Validation routes `hierarchy` edges to structural checks within their own hierarchy (parent-type rules, skip-level detection) and `relationship` edges to field reference checks (type-match, missing-target). Tree rendering and rule evaluation use the full set.
256
309
 
257
310
  ### Content links
258
311
 
@@ -288,7 +341,7 @@ An **anchor** is a block anchor (e.g. `^goal1`) appended to a heading in a `type
288
341
 
289
342
  ## Filter expressions
290
343
 
291
- 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.
292
345
 
293
346
  ### Syntax
294
347
 
@@ -303,7 +356,7 @@ Keywords (`WHERE`, `SELECT`) are case-insensitive.
303
356
 
304
357
  ### Predicate context
305
358
 
306
- 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:
307
360
 
308
361
  | Field | Description |
309
362
  |-------|-------------|
@@ -315,9 +368,12 @@ Each entry in `ancestors[]` or `descendants[]` includes all schema fields of the
315
368
  | Metadata field | Type | Description |
316
369
  |----------------|------|-------------|
317
370
  | `_field` | `string` | The edge field name that connects to this ancestor/descendant |
318
- | `_source` | `'hierarchy' \| 'relationship'` | Whether the edge came from the hierarchy or a relationship |
371
+ | `_source` | `'hierarchy' \| 'relationship'` | Whether the edge came from a hierarchy or a relationship |
372
+ | `_hierarchy` | `string` | Hierarchy name; present only for hierarchy edges |
319
373
  | `_selfRef` | `boolean` | Whether the edge is a same-type (self-referential) link |
320
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
+
321
377
  ### SELECT spec
322
378
 
323
379
  The SELECT clause expands the result set by walking the graph from matched nodes. The spec is a comma-separated list of directives: