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.
- package/README.md +52 -198
- package/dist/api.d.ts +17 -15
- package/dist/api.js +12 -9
- package/dist/commands/diagram.d.ts +1 -0
- package/dist/commands/diagram.js +7 -1
- package/dist/commands/docs.js +1 -0
- package/dist/commands/dump.d.ts +15 -2
- package/dist/commands/dump.js +18 -4
- package/dist/commands/providers.d.ts +4 -0
- package/dist/commands/providers.js +52 -0
- package/dist/commands/render.d.ts +4 -0
- package/dist/commands/render.js +64 -9
- package/dist/commands/schemas.d.ts +2 -2
- package/dist/commands/schemas.js +133 -22
- package/dist/commands/show.d.ts +1 -0
- package/dist/commands/show.js +7 -1
- package/dist/commands/spaces.d.ts +3 -1
- package/dist/commands/spaces.js +11 -2
- package/dist/commands/validate-file.d.ts +3 -0
- package/dist/commands/validate-file.js +24 -6
- package/dist/commands/validate.d.ts +4 -0
- package/dist/commands/validate.js +79 -23
- package/dist/config.d.ts +25 -1
- package/dist/config.js +106 -12
- package/dist/filter/augment-nodes.d.ts +3 -1
- package/dist/filter/augment-nodes.js +12 -7
- package/dist/filter/filter-nodes.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +96 -31
- 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 +7 -5
- package/dist/load-space-graph.js +19 -8
- package/dist/plugins/loader.js +3 -3
- package/dist/plugins/markdown/extract-content-links.js +8 -29
- package/dist/plugins/markdown/index.js +8 -2
- package/dist/plugins/markdown/parse-embedded.d.ts +8 -0
- package/dist/plugins/markdown/parse-embedded.js +195 -24
- package/dist/plugins/markdown/read-space.js +42 -14
- package/dist/plugins/markdown/render-directory.d.ts +11 -0
- package/dist/plugins/markdown/render-directory.js +130 -0
- 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/plugins/mermaid/render-mermaid.js +2 -1
- package/dist/plugins/miro/sync.js +7 -6
- package/dist/plugins/util.d.ts +39 -1
- package/dist/plugins/util.js +139 -0
- package/dist/read/resolve-graph-edges.d.ts +1 -1
- package/dist/read/resolve-graph-edges.js +46 -27
- package/dist/read/wikilink-utils.d.ts +17 -0
- package/dist/read/wikilink-utils.js +40 -0
- package/dist/render/render.d.ts +3 -1
- package/dist/render/render.js +2 -2
- 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/meta-schemas.d.ts +13 -0
- package/dist/schema/meta-schemas.js +50 -0
- package/dist/schema/metadata-contract.d.ts +952 -183
- package/dist/schema/metadata-contract.js +177 -33
- 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.d.ts +14 -8
- package/dist/schema/schema.js +308 -199
- package/dist/schema/validate-graph.d.ts +2 -1
- package/dist/schema/validate-graph.js +40 -23
- package/dist/schema/validate-rules.d.ts +16 -3
- package/dist/schema/validate-rules.js +91 -29
- package/dist/schemas/_knowledge_wiki.json +24 -24
- package/dist/schemas/_okf.json +172 -0
- package/dist/schemas/_ost_strict.json +17 -8
- package/dist/schemas/_sctx_base.json +3 -3
- package/dist/schemas/_strategy_general.json +38 -38
- package/dist/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
- package/dist/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
- package/dist/schemas/generated/latest/_structured_context_schema_meta.json +400 -0
- package/dist/schemas/knowledge_wiki.json +10 -9
- package/dist/schemas/okf.json +8 -0
- package/dist/schemas/strategy_general.json +15 -14
- package/dist/schemas/strict_ost.json +17 -16
- package/dist/space-context.d.ts +5 -2
- package/dist/space-context.js +9 -4
- package/dist/space-graph.d.ts +9 -7
- package/dist/space-graph.js +7 -5
- package/dist/testing.d.ts +33 -0
- package/dist/testing.js +72 -0
- package/dist/types.d.ts +64 -16
- package/dist/validate.d.ts +62 -9
- package/dist/validate.js +229 -114
- package/docs/concepts.md +65 -9
- package/docs/config.md +78 -1
- package/docs/rules.md +168 -50
- package/docs/schemas.md +152 -52
- package/package.json +17 -11
- package/schemas/_knowledge_wiki.json +24 -24
- package/schemas/_okf.json +172 -0
- package/schemas/_ost_strict.json +17 -8
- package/schemas/_sctx_base.json +3 -3
- package/schemas/_strategy_general.json +38 -38
- package/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
- package/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
- package/schemas/generated/latest/_structured_context_schema_meta.json +400 -0
- package/schemas/knowledge_wiki.json +10 -9
- package/schemas/okf.json +8 -0
- package/schemas/strategy_general.json +15 -14
- package/schemas/strict_ost.json +17 -16
- package/dist/schemas/generated/_structured_context_schema_meta.json +0 -191
- 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 {
|
|
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,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
|
-
*
|
|
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
|
-
|
|
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: ${
|
|
130
|
-
params: { allowedValues:
|
|
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
|
-
|
|
140
|
-
if (valid) {
|
|
178
|
+
if (schemaValidator(node.schemaData)) {
|
|
141
179
|
result.validCount++;
|
|
142
180
|
}
|
|
143
181
|
else {
|
|
144
|
-
result.
|
|
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
|
-
|
|
156
|
-
|
|
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,
|
|
199
|
+
const hierarchyValidation = validateGraph(nodes, metadata, unresolvedRefs);
|
|
168
200
|
result.refErrors.push(...hierarchyValidation.refErrors);
|
|
169
201
|
result.hierarchyViolations = [...hierarchyValidation.violations];
|
|
170
|
-
// Calculate
|
|
171
|
-
|
|
172
|
-
result.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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
216
|
-
const
|
|
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
|
-
|
|
234
|
-
for (const node of
|
|
309
|
+
const warnings = {};
|
|
310
|
+
for (const { node, errors: ajvErrors, invalidType } of result.nodeFailures) {
|
|
235
311
|
if (node.label !== label)
|
|
236
312
|
continue;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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:
|
|
320
|
+
message: ajvErrors[0].message,
|
|
321
|
+
...position,
|
|
243
322
|
};
|
|
244
323
|
continue;
|
|
245
324
|
}
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
//
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
|
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`
|
|
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
|
|
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
|
|
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:
|