structured-context 0.13.0 → 0.14.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 +2 -2
- package/dist/api.d.ts +17 -17
- package/dist/api.js +12 -12
- package/dist/commands/dump.js +1 -1
- package/dist/commands/validate.js +8 -8
- package/dist/config.d.ts +1 -1
- package/dist/config.js +4 -4
- package/dist/filter/augment-nodes.d.ts +5 -4
- package/dist/filter/augment-nodes.js +20 -19
- package/dist/filter/expand-include.d.ts +2 -2
- package/dist/filter/expand-include.js +15 -17
- package/dist/filter/filter-nodes.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +17 -17
- package/dist/judge/context.d.ts +1 -1
- package/dist/judge/context.js +12 -12
- package/dist/judge/factory.d.ts +1 -1
- package/dist/judge/factory.js +1 -1
- package/dist/judge/index.d.ts +1 -1
- package/dist/judge/judge-node.d.ts +4 -4
- package/dist/judge/judge-node.js +7 -7
- package/dist/judge/prompt.d.ts +1 -1
- package/dist/judge/prompt.js +3 -3
- package/dist/judge/provider/fake.d.ts +1 -1
- package/dist/judge/provider/fake.js +3 -6
- package/dist/judge/provider/jev.js +1 -1
- package/dist/judge/provider/openai-decisions.js +1 -1
- package/dist/judge/trim.d.ts +1 -1
- package/dist/judge/types.d.ts +4 -1
- package/dist/judge/verdicts.d.ts +1 -1
- package/dist/load-space-graph.d.ts +3 -3
- package/dist/load-space-graph.js +4 -4
- package/dist/plugins/loader.js +1 -1
- package/dist/plugins/markdown/index.d.ts +6 -0
- package/dist/plugins/markdown/index.js +1 -0
- package/dist/plugins/markdown/parse-embedded.d.ts +7 -2
- package/dist/plugins/markdown/parse-embedded.js +23 -21
- package/dist/plugins/markdown/read-space.js +9 -8
- package/dist/plugins/markdown/render-bullets.js +3 -3
- package/dist/plugins/markdown/render-directory.js +1 -1
- package/dist/plugins/markdown/util.d.ts +12 -1
- package/dist/plugins/markdown/util.js +21 -0
- package/dist/plugins/mermaid/render-mermaid.js +5 -6
- package/dist/plugins/miro/cache.js +1 -1
- package/dist/plugins/miro/sync.js +3 -2
- package/dist/read/resolve-graph-edges.js +12 -18
- package/dist/render/render.d.ts +1 -1
- package/dist/render/render.js +1 -1
- package/dist/schema/evaluate-rule.d.ts +4 -4
- package/dist/schema/evaluate-rule.js +14 -10
- package/dist/schema/gating.d.ts +10 -10
- package/dist/schema/gating.js +45 -44
- package/dist/schema/validate-graph.js +8 -11
- package/dist/schema/validate-rules.d.ts +4 -4
- package/dist/schema/validate-rules.js +15 -17
- package/dist/schemas/_ost_strict.json +1 -1
- package/dist/space-context.d.ts +2 -2
- package/dist/space-context.js +3 -3
- package/dist/space-graph.d.ts +9 -9
- package/dist/space-graph.js +16 -16
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +3 -3
- package/dist/types.d.ts +18 -14
- package/dist/validate.d.ts +6 -6
- package/dist/validate.js +26 -27
- package/docs/concepts.md +25 -7
- package/docs/config.md +8 -0
- package/docs/rules.md +17 -13
- package/package.json +5 -5
- package/schemas/_ost_strict.json +1 -1
package/dist/space-graph.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Hierarchy, SpaceNode } from './types
|
|
1
|
+
import type { Hierarchy, NodeId, SpaceNode } from './types';
|
|
2
2
|
/**
|
|
3
3
|
* A navigable graph over a set of SpaceNodes.
|
|
4
4
|
*
|
|
@@ -9,20 +9,20 @@ import type { Hierarchy, SpaceNode } from './types.js';
|
|
|
9
9
|
* so consumers don't need to build their own indexes.
|
|
10
10
|
*/
|
|
11
11
|
export type SpaceGraph = {
|
|
12
|
-
/** All nodes, keyed by
|
|
13
|
-
readonly nodes: ReadonlyMap<
|
|
12
|
+
/** All nodes, keyed by id. Preserves insertion order. */
|
|
13
|
+
readonly nodes: ReadonlyMap<NodeId, SpaceNode>;
|
|
14
14
|
/** Hierarchy roots: nodes of the root type with no valid hierarchy parents. */
|
|
15
15
|
readonly hierarchyRoots: readonly SpaceNode[];
|
|
16
16
|
/** Orphans: hierarchy-typed nodes with no valid hierarchy parents in the node set. */
|
|
17
17
|
readonly orphans: readonly SpaceNode[];
|
|
18
18
|
/** Non-hierarchy nodes: type not in the hierarchy levels definition. */
|
|
19
19
|
readonly nonHierarchy: readonly SpaceNode[];
|
|
20
|
-
/** Hierarchy children map: parent
|
|
21
|
-
readonly hierarchyChildren: ReadonlyMap<
|
|
22
|
-
/** All-edges children map: parent
|
|
23
|
-
readonly children: ReadonlyMap<
|
|
24
|
-
/** Set of all node
|
|
25
|
-
readonly
|
|
20
|
+
/** Hierarchy children map: parent id → direct children connected via this hierarchy's edges only. */
|
|
21
|
+
readonly hierarchyChildren: ReadonlyMap<NodeId, readonly SpaceNode[]>;
|
|
22
|
+
/** All-edges children map: parent id → direct children connected via any edge (hierarchy + relationship). */
|
|
23
|
+
readonly children: ReadonlyMap<NodeId, readonly SpaceNode[]>;
|
|
24
|
+
/** Set of all node ids that are part of the hierarchy (roots + their descendants + orphans). */
|
|
25
|
+
readonly hierarchyIds: ReadonlySet<NodeId>;
|
|
26
26
|
/** The hierarchy used to build this graph, if the schema has one. */
|
|
27
27
|
readonly hierarchy: Hierarchy | undefined;
|
|
28
28
|
};
|
package/dist/space-graph.js
CHANGED
|
@@ -12,22 +12,22 @@ export function buildSpaceGraph(nodes, hierarchy) {
|
|
|
12
12
|
const nonHierarchy = [];
|
|
13
13
|
// First pass: register all nodes and init adjacency lists
|
|
14
14
|
for (const node of nodes) {
|
|
15
|
-
nodesMap.set(node.
|
|
16
|
-
hierarchyChildrenMap.set(node.
|
|
17
|
-
childrenMap.set(node.
|
|
15
|
+
nodesMap.set(node.id, node);
|
|
16
|
+
hierarchyChildrenMap.set(node.id, []);
|
|
17
|
+
childrenMap.set(node.id, []);
|
|
18
18
|
}
|
|
19
19
|
// Second pass: build children maps (inverted from resolvedParents)
|
|
20
20
|
for (const node of nodes) {
|
|
21
21
|
for (const parentRef of node.resolvedParents) {
|
|
22
22
|
// All-edges map
|
|
23
|
-
if (!childrenMap.has(parentRef.
|
|
24
|
-
childrenMap.set(parentRef.
|
|
25
|
-
childrenMap.get(parentRef.
|
|
23
|
+
if (!childrenMap.has(parentRef.id))
|
|
24
|
+
childrenMap.set(parentRef.id, []);
|
|
25
|
+
childrenMap.get(parentRef.id).push(node);
|
|
26
26
|
// Hierarchy-only map
|
|
27
27
|
if (isHierarchyEdge(parentRef)) {
|
|
28
|
-
if (!hierarchyChildrenMap.has(parentRef.
|
|
29
|
-
hierarchyChildrenMap.set(parentRef.
|
|
30
|
-
hierarchyChildrenMap.get(parentRef.
|
|
28
|
+
if (!hierarchyChildrenMap.has(parentRef.id))
|
|
29
|
+
hierarchyChildrenMap.set(parentRef.id, []);
|
|
30
|
+
hierarchyChildrenMap.get(parentRef.id).push(node);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
}
|
|
@@ -50,20 +50,20 @@ export function buildSpaceGraph(nodes, hierarchy) {
|
|
|
50
50
|
}
|
|
51
51
|
else {
|
|
52
52
|
// Check if at least one hierarchy parent is actually in the node set
|
|
53
|
-
const hasValidParent = hierarchyParents.some((r) => nodesMap.has(r.
|
|
53
|
+
const hasValidParent = hierarchyParents.some((r) => nodesMap.has(r.id));
|
|
54
54
|
if (!hasValidParent) {
|
|
55
55
|
// All hierarchy parents are dangling — treat as orphan
|
|
56
56
|
orphans.push(node);
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
// Build
|
|
61
|
-
const
|
|
60
|
+
// Build hierarchyIds: BFS from roots + orphans through hierarchyChildren
|
|
61
|
+
const hierarchyIds = new Set();
|
|
62
62
|
function addHierarchySubtree(node) {
|
|
63
|
-
if (
|
|
63
|
+
if (hierarchyIds.has(node.id))
|
|
64
64
|
return;
|
|
65
|
-
|
|
66
|
-
for (const child of hierarchyChildrenMap.get(node.
|
|
65
|
+
hierarchyIds.add(node.id);
|
|
66
|
+
for (const child of hierarchyChildrenMap.get(node.id) ?? []) {
|
|
67
67
|
addHierarchySubtree(child);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
@@ -78,7 +78,7 @@ export function buildSpaceGraph(nodes, hierarchy) {
|
|
|
78
78
|
nonHierarchy,
|
|
79
79
|
hierarchyChildren: hierarchyChildrenMap,
|
|
80
80
|
children: childrenMap,
|
|
81
|
-
|
|
81
|
+
hierarchyIds,
|
|
82
82
|
hierarchy,
|
|
83
83
|
};
|
|
84
84
|
}
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { BaseNode, Hierarchy, HierarchyLevel, Relationship, ResolvedParentRef, SpaceNode } from './types
|
|
1
|
+
import type { BaseNode, Hierarchy, HierarchyLevel, Relationship, ResolvedParentRef, SpaceNode } from './types';
|
|
2
2
|
/**
|
|
3
3
|
* Creates a ResolvedParentRef with sensible defaults for use in tests.
|
|
4
4
|
* Override any field to represent specific edge contexts.
|
|
5
5
|
*/
|
|
6
|
-
export declare const makeParentRef: (
|
|
6
|
+
export declare const makeParentRef: (id: string, overrides?: Partial<ResolvedParentRef>) => ResolvedParentRef;
|
|
7
7
|
/**
|
|
8
8
|
* Creates a HierarchyLevel with defaults matching schema metadata normalization.
|
|
9
9
|
*/
|
package/dist/testing.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Creates a ResolvedParentRef with sensible defaults for use in tests.
|
|
3
3
|
* Override any field to represent specific edge contexts.
|
|
4
4
|
*/
|
|
5
|
-
export const makeParentRef = (
|
|
5
|
+
export const makeParentRef = (id, overrides = {}) => {
|
|
6
6
|
const source = overrides.source ?? 'hierarchy';
|
|
7
7
|
return {
|
|
8
|
-
|
|
8
|
+
id: id,
|
|
9
9
|
field: 'parent',
|
|
10
10
|
source,
|
|
11
11
|
...(source === 'hierarchy' ? { hierarchy: 'main' } : {}),
|
|
@@ -55,7 +55,7 @@ export const makeRelationship = (parent, type, overrides = {}) => ({
|
|
|
55
55
|
* Creates a BaseNode with sensible defaults for use in tests.
|
|
56
56
|
*/
|
|
57
57
|
export const makeNode = (title, type, extra = {}, linkTargets) => ({
|
|
58
|
-
|
|
58
|
+
id: title,
|
|
59
59
|
title,
|
|
60
60
|
schemaData: { title, type, ...extra },
|
|
61
61
|
linkTargets: linkTargets ?? [title],
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
|
|
2
|
-
import type { Config, SpaceConfig } from './config
|
|
3
|
-
import type { MetadataContractHierarchyLevel, MetadataContractRelationship, Rule, SharedEdgeFields } from './schema/metadata-contract
|
|
2
|
+
import type { Config, SpaceConfig } from './config';
|
|
3
|
+
import type { MetadataContractHierarchyLevel, MetadataContractRelationship, Rule, SharedEdgeFields } from './schema/metadata-contract';
|
|
4
4
|
/**
|
|
5
5
|
* Minimal normalized edge for graph resolution.
|
|
6
6
|
* Derives routing fields from SharedEdgeFields to stay in sync with the schema.
|
|
@@ -28,14 +28,18 @@ export type Relationship = Omit<MetadataContractRelationship, 'field' | 'fieldOn
|
|
|
28
28
|
fieldOn: 'child' | 'parent';
|
|
29
29
|
multiple: boolean;
|
|
30
30
|
};
|
|
31
|
+
/** Unique key of a node within a space. Supplied by the plugin that produced the node. */
|
|
32
|
+
export type NodeId = string & {
|
|
33
|
+
readonly __brand: 'NodeId';
|
|
34
|
+
};
|
|
31
35
|
/**
|
|
32
|
-
* A resolved parent reference, capturing not just the parent
|
|
36
|
+
* A resolved parent reference, capturing not just the parent id but the edge context
|
|
33
37
|
* from which it was resolved. All hierarchy levels and relationships produce entries of
|
|
34
38
|
* this type, forming a single labelled graph rather than separate structures.
|
|
35
39
|
*/
|
|
36
40
|
export type ResolvedParentRef = {
|
|
37
|
-
/**
|
|
38
|
-
|
|
41
|
+
/** Id of the parent node. */
|
|
42
|
+
id: NodeId;
|
|
39
43
|
/** The field name that contained the wikilink (e.g. 'parent', 'key_activities', 'produces_data'). */
|
|
40
44
|
field: string;
|
|
41
45
|
/** Whether this edge originates from a hierarchy level or a relationship definition. */
|
|
@@ -56,8 +60,8 @@ export type SourceLocation = {
|
|
|
56
60
|
column?: number;
|
|
57
61
|
};
|
|
58
62
|
export type UnresolvedRef = {
|
|
59
|
-
/**
|
|
60
|
-
|
|
63
|
+
/** Id of the node containing the broken link. */
|
|
64
|
+
id: NodeId;
|
|
61
65
|
/** Raw wikilink value (or String(rawField) for invalid_shape). */
|
|
62
66
|
ref: string;
|
|
63
67
|
/** Field name that contained the link. */
|
|
@@ -109,9 +113,9 @@ export type ResolvedContentLink = ContentLinkBase & {
|
|
|
109
113
|
* Core enriches this into a SpaceNode after parsing.
|
|
110
114
|
*/
|
|
111
115
|
export type BaseNode = {
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
/**
|
|
116
|
+
/** Unique key of the node: graph key, parent lookup, gating, duplicate detection and the file in messages. Supplied by the plugin. */
|
|
117
|
+
id: NodeId;
|
|
118
|
+
/** Display title (from schemaData.title). May repeat across nodes. */
|
|
115
119
|
title: string;
|
|
116
120
|
/** Fields validated against the active schema. */
|
|
117
121
|
schemaData: Record<string, unknown>;
|
|
@@ -138,7 +142,7 @@ export type SpaceNode = BaseNode & {
|
|
|
138
142
|
resolvedType: string;
|
|
139
143
|
/**
|
|
140
144
|
* Resolved parent references derived from all edge fields (hierarchy levels + relationships).
|
|
141
|
-
* Each entry carries the parent
|
|
145
|
+
* Each entry carries the parent id, the field it came from, and its edge context.
|
|
142
146
|
* Always present, empty if no parents resolved.
|
|
143
147
|
*/
|
|
144
148
|
resolvedParents: ResolvedParentRef[];
|
|
@@ -171,10 +175,10 @@ export type RuleCategory = 'validation' | 'coherence' | 'workflow' | 'best-pract
|
|
|
171
175
|
* A rule's severity defaults from its category and may be overridden per rule.
|
|
172
176
|
*/
|
|
173
177
|
export type RuleSeverity = 'error' | 'warning' | 'info';
|
|
174
|
-
export type { CoverageGate, GatingSeed } from './schema/gating
|
|
175
|
-
export type { Rule } from './schema/metadata-contract
|
|
178
|
+
export type { CoverageGate, GatingSeed } from './schema/gating';
|
|
179
|
+
export type { Rule } from './schema/metadata-contract';
|
|
176
180
|
export type RuleViolation = {
|
|
177
|
-
/** Node
|
|
181
|
+
/** Node id, or empty string for global-scope rules. */
|
|
178
182
|
file: string;
|
|
179
183
|
ruleId: string;
|
|
180
184
|
category: RuleCategory;
|
package/dist/validate.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ErrorObject } from 'ajv';
|
|
2
|
-
import type { Config, SpaceConfig } from './config
|
|
3
|
-
import type { SemanticJudgeProvider } from './judge/types
|
|
4
|
-
import { type RuleFilter } from './schema/rule-filter
|
|
5
|
-
import { extractEntityInfo } from './schema/schema
|
|
6
|
-
import type { CoverageGate, FileNotInSpaceResult, FileValidationResult, GraphViolation, ParseIssue, ReadSpaceResult, RuleViolation, SchemaWithMetadata, SpaceContext, SpaceNode, ValidateFileOutput } from './types
|
|
2
|
+
import type { Config, SpaceConfig } from './config';
|
|
3
|
+
import type { SemanticJudgeProvider } from './judge/types';
|
|
4
|
+
import { type RuleFilter } from './schema/rule-filter';
|
|
5
|
+
import { extractEntityInfo } from './schema/schema';
|
|
6
|
+
import type { CoverageGate, FileNotInSpaceResult, FileValidationResult, GraphViolation, NodeId, ParseIssue, ReadSpaceResult, RuleViolation, SchemaWithMetadata, SpaceContext, SpaceNode, ValidateFileOutput } from './types';
|
|
7
7
|
export interface FormattedError {
|
|
8
8
|
message: string;
|
|
9
9
|
dedupeKey: string;
|
|
@@ -24,7 +24,7 @@ export interface ValidationResult {
|
|
|
24
24
|
error: string;
|
|
25
25
|
}>;
|
|
26
26
|
duplicateErrors: Array<{
|
|
27
|
-
|
|
27
|
+
id: NodeId;
|
|
28
28
|
files: string[];
|
|
29
29
|
}>;
|
|
30
30
|
/** All rule violations with their effective severity. Only `error` severity fails validation. */
|
package/dist/validate.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve } from 'node:path';
|
|
2
|
-
import { readSpace } from './read/read-space
|
|
3
|
-
import { computeHierarchyDepths, evaluateRulesWithGating } from './schema/gating
|
|
4
|
-
import { filterRules } from './schema/rule-filter
|
|
5
|
-
import { extractEntityInfo } from './schema/schema
|
|
6
|
-
import { validateGraph } from './schema/validate-graph
|
|
7
|
-
import { validateRules } from './schema/validate-rules
|
|
8
|
-
import { createSpaceContext } from './space-context
|
|
9
|
-
import { buildSpaceGraph } from './space-graph
|
|
2
|
+
import { readSpace } from './read/read-space';
|
|
3
|
+
import { computeHierarchyDepths, evaluateRulesWithGating } from './schema/gating';
|
|
4
|
+
import { filterRules } from './schema/rule-filter';
|
|
5
|
+
import { extractEntityInfo } from './schema/schema';
|
|
6
|
+
import { validateGraph } from './schema/validate-graph';
|
|
7
|
+
import { validateRules } from './schema/validate-rules';
|
|
8
|
+
import { createSpaceContext } from './space-context';
|
|
9
|
+
import { buildSpaceGraph } from './space-graph';
|
|
10
10
|
/**
|
|
11
11
|
* Format AJV errors for better readability.
|
|
12
12
|
* Groups related errors and extracts helpful context like allowed values.
|
|
@@ -182,17 +182,17 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
|
|
|
182
182
|
result.nodeFailures.push({ node, errors: schemaValidator.errors || [] });
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
|
-
// Detect duplicate node
|
|
186
|
-
const
|
|
185
|
+
// Detect duplicate node ids. Display titles may repeat.
|
|
186
|
+
const idToFiles = new Map();
|
|
187
187
|
for (const node of nodes) {
|
|
188
|
-
if (!
|
|
189
|
-
|
|
188
|
+
if (!idToFiles.has(node.id)) {
|
|
189
|
+
idToFiles.set(node.id, []);
|
|
190
190
|
}
|
|
191
|
-
|
|
191
|
+
idToFiles.get(node.id).push(node.id);
|
|
192
192
|
}
|
|
193
|
-
for (const [
|
|
193
|
+
for (const [id, files] of idToFiles) {
|
|
194
194
|
if (files.length > 1) {
|
|
195
|
-
result.duplicateErrors.push({
|
|
195
|
+
result.duplicateErrors.push({ id, files });
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
// Validate all hierarchy constraints (field references and structure)
|
|
@@ -207,10 +207,9 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
|
|
|
207
207
|
if (metadata.rules) {
|
|
208
208
|
const seeds = [
|
|
209
209
|
...result.nodeFailures.map(({ node }) => {
|
|
210
|
-
|
|
211
|
-
return { file, title: String(node.schemaData.title ?? file), reason: 'schema' };
|
|
210
|
+
return { file: node.source?.file ?? node.id, id: node.id, reason: 'schema' };
|
|
212
211
|
}),
|
|
213
|
-
...result.hierarchyViolations.map((v) => ({ file: v.file,
|
|
212
|
+
...result.hierarchyViolations.map((v) => ({ file: v.file, id: v.file, reason: 'hierarchy' })),
|
|
214
213
|
];
|
|
215
214
|
const trimReport = { nodes: new Set(), ruleIds: new Set() };
|
|
216
215
|
const { violations, gates } = await evaluateSpaceRules(nodes, metadata, seeds, { ...options, trimReport });
|
|
@@ -223,10 +222,10 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
|
|
|
223
222
|
for (const node of nodes) {
|
|
224
223
|
for (const link of node.resolvedLinks) {
|
|
225
224
|
if (link.location === 'internal') {
|
|
226
|
-
const key = `${node.
|
|
225
|
+
const key = `${node.id}::${link.target}`;
|
|
227
226
|
if (!seen.has(key)) {
|
|
228
227
|
seen.add(key);
|
|
229
|
-
result.unresolvedContentLinks.push({ file: node.
|
|
228
|
+
result.unresolvedContentLinks.push({ file: node.id, target: link.target });
|
|
230
229
|
}
|
|
231
230
|
}
|
|
232
231
|
}
|
|
@@ -249,9 +248,9 @@ export async function validateSpace(context, options = {}) {
|
|
|
249
248
|
const depths = computeHierarchyDepths(fullGraph);
|
|
250
249
|
const included = [];
|
|
251
250
|
for (const node of nodes) {
|
|
252
|
-
const nodeDepth = depths.get(node.
|
|
251
|
+
const nodeDepth = depths.get(node.id);
|
|
253
252
|
if (nodeDepth !== undefined && nodeDepth > options.depth) {
|
|
254
|
-
skippedByDepth.push({ file: node.
|
|
253
|
+
skippedByDepth.push({ file: node.id, title: node.title });
|
|
255
254
|
}
|
|
256
255
|
else {
|
|
257
256
|
included.push(node);
|
|
@@ -268,7 +267,7 @@ export async function validateSpace(context, options = {}) {
|
|
|
268
267
|
validCount: result.validCount,
|
|
269
268
|
nodeErrorCount: result.nodeFailures.length,
|
|
270
269
|
nodeErrors: result.nodeFailures.map(({ node, errors }) => ({
|
|
271
|
-
file: node.source?.file ?? node.
|
|
270
|
+
file: node.source?.file ?? node.id,
|
|
272
271
|
...(node.source?.line !== undefined ? { line: node.source.line } : {}),
|
|
273
272
|
errors,
|
|
274
273
|
nodeData: node.schemaData,
|
|
@@ -308,7 +307,7 @@ function issuesForFile(result, label, context, includeGated) {
|
|
|
308
307
|
const errors = {};
|
|
309
308
|
const warnings = {};
|
|
310
309
|
for (const { node, errors: ajvErrors, invalidType } of result.nodeFailures) {
|
|
311
|
-
if (node.
|
|
310
|
+
if (node.id !== label)
|
|
312
311
|
continue;
|
|
313
312
|
const position = {
|
|
314
313
|
...(node.source?.line !== undefined ? { line: node.source.line } : {}),
|
|
@@ -328,13 +327,13 @@ function issuesForFile(result, label, context, includeGated) {
|
|
|
328
327
|
}
|
|
329
328
|
}
|
|
330
329
|
// Duplicate key errors — include if this file is one of the duplicates
|
|
331
|
-
for (const {
|
|
330
|
+
for (const { id, files } of result.duplicateErrors) {
|
|
332
331
|
if (!files.includes(label))
|
|
333
332
|
continue;
|
|
334
333
|
const others = files.filter((f) => f !== label);
|
|
335
|
-
errors[`duplicate:${
|
|
334
|
+
errors[`duplicate:${id}`] = {
|
|
336
335
|
kind: 'duplicate',
|
|
337
|
-
message: `Duplicate
|
|
336
|
+
message: `Duplicate id "${id}" also exists in: ${others.join(', ')}`,
|
|
338
337
|
};
|
|
339
338
|
}
|
|
340
339
|
// Broken links and hierarchy violations from this file
|
package/docs/concepts.md
CHANGED
|
@@ -97,12 +97,30 @@ A **space node** (or **node** for short) is a single entity in a `space` — a n
|
|
|
97
97
|
|
|
98
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.
|
|
99
99
|
|
|
100
|
+
Each node has an `id` (its unique key), a `title` (its display name) and `linkTargets` (the names wikilinks may use to reach it). See [Node key](#node-key).
|
|
101
|
+
|
|
100
102
|
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
103
|
|
|
102
104
|
A node's `content` is the markdown source of its body; see [Node content](#node-content).
|
|
103
105
|
|
|
104
106
|
> `space_on_a_page` and `dashboard` are not `space node` types — they are `tooling types`.
|
|
105
107
|
|
|
108
|
+
### Node key
|
|
109
|
+
|
|
110
|
+
Three values on every node do separate jobs:
|
|
111
|
+
|
|
112
|
+
| Value | Job | Unique? | Markdown source |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| `id` | Identifies the node: graph key, parent lookup, gating, duplicate detection, and the file shown in messages | Yes. Two nodes with the same `id` are a duplicate error | Path relative to the space root, with extension (`projects/alpha/Overview.md`). Embedded nodes use `<page id>#<heading>` |
|
|
115
|
+
| `title` | Display name in `show`, rendered output and judge prompts | No | `title:` frontmatter, else the containing folder's name for a [folder index](#folder-index), else the file's basename |
|
|
116
|
+
| `linkTargets` | The names a wikilink may use to reach the node | Per name. A name shared by several nodes is reported as ambiguous where a link uses it | The explicit `title:`, the basename, and every path suffix without `.md` |
|
|
117
|
+
|
|
118
|
+
A wikilink is looked up in `linkTargets`, and the result is stored as the target node's `id`. Other values hold the rest of the picture: `source` is where the node is defined, `resolvedParents` lists parents by `id`, and `resolvedLinks` classifies content links. A parent field resolves exactly as a link does.
|
|
119
|
+
|
|
120
|
+
### Folder index
|
|
121
|
+
|
|
122
|
+
A **folder index** is a page that stands for its folder: `Overview.md` or `index.md` inside `projects/alpha/`. Obsidian has no container pages, so a node with child pages is a folder plus an index page. Listing the index basenames in the markdown plugin's [`folderIndex`](config.md#folderindex) config gives each folder index the folder's name as its `title`. The derived title is for display only and never a link target.
|
|
123
|
+
|
|
106
124
|
### Embedded node
|
|
107
125
|
|
|
108
126
|
An **embedded node** is a `space node` defined *within* a containing document rather than as its own file. Embedded nodes are declared using markdown heading syntax with inline field annotations (e.g. `[type:: goal]`) or `anchor-implied types`, and are extracted at parse time.
|
|
@@ -187,9 +205,9 @@ The **per-node input cap** limits the input for one evaluated node and its relat
|
|
|
187
205
|
|
|
188
206
|
### Cascade gating
|
|
189
207
|
|
|
190
|
-
**Cascade gating** is the mechanism by which a schema
|
|
208
|
+
**Cascade gating** is the mechanism by which a schema validation error, hierarchy structure violation, or gating 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
209
|
|
|
192
|
-
A **coverage gate** records one instance of this: the gating node, what kind of
|
|
210
|
+
A **coverage gate** records one instance of this: the gating node, what kind of violation 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
211
|
|
|
194
212
|
---
|
|
195
213
|
|
|
@@ -288,7 +306,7 @@ A **wikilink** is the `[[Title]]` linking syntax (compatible with Obsidian) used
|
|
|
288
306
|
|
|
289
307
|
| Form | Example | Resolves to |
|
|
290
308
|
|---|---|---|
|
|
291
|
-
|
|
|
309
|
+
| Name or path | `[[My Goal]]`, `[[alpha/Overview]]` | The `space node` with that name in its `linkTargets`. A bare name shared by several nodes is ambiguous; add enough of the path to make it unique, as Obsidian does |
|
|
292
310
|
| Anchor ref | `[[vision_page#^goal1]]` | The `embedded node` with `anchor` `goal1` inside `vision_page.md` |
|
|
293
311
|
|
|
294
312
|
### Resolved parents
|
|
@@ -299,7 +317,7 @@ Each entry is a `ResolvedParentRef` object:
|
|
|
299
317
|
|
|
300
318
|
| Field | Type | Description |
|
|
301
319
|
|---|---|---|
|
|
302
|
-
| `
|
|
320
|
+
| `id` | `NodeId` | The parent node's `id` |
|
|
303
321
|
| `field` | `string` | The frontmatter field that held the wikilink |
|
|
304
322
|
| `source` | `'hierarchy' \| 'relationship'` | Whether the edge came from a hierarchy level or a relationship |
|
|
305
323
|
| `hierarchy` | `string` | Name of the hierarchy the edge belongs to; present only when `source` is `'hierarchy'` |
|
|
@@ -356,12 +374,12 @@ Keywords (`WHERE`, `SELECT`) are case-insensitive.
|
|
|
356
374
|
|
|
357
375
|
### Predicate context
|
|
358
376
|
|
|
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`. `
|
|
377
|
+
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`. `id` (e.g. `"solutions/My Solution.md"`) and `title` are always available regardless of schema. `resolvedParentIds` lists the `id` of each parent and `resolvedParentTitles` their titles. Additionally, two pre-computed traversal arrays are available:
|
|
360
378
|
|
|
361
379
|
| Field | Description |
|
|
362
380
|
|-------|-------------|
|
|
363
|
-
| `ancestors[]` | Flat array of ancestor nodes, nearest first, deduplicated by
|
|
364
|
-
| `descendants[]` | Flat array of descendant nodes, nearest first, deduplicated by
|
|
381
|
+
| `ancestors[]` | Flat array of ancestor nodes, nearest first, deduplicated by `id` |
|
|
382
|
+
| `descendants[]` | Flat array of descendant nodes, nearest first, deduplicated by `id` |
|
|
365
383
|
|
|
366
384
|
Each entry in `ancestors[]` or `descendants[]` includes all schema fields of the target node, plus edge metadata:
|
|
367
385
|
|
package/docs/config.md
CHANGED
|
@@ -147,6 +147,14 @@ Maps file/frontmatter field names to canonical schema field names:
|
|
|
147
147
|
- `templateDir` — directory containing template files (used by `template-sync` and excluded when parsing)
|
|
148
148
|
- `templatePrefix` — filename prefix for templates (default blank)
|
|
149
149
|
|
|
150
|
+
### `folderIndex`
|
|
151
|
+
|
|
152
|
+
Names the pages that stand for their folder (see [Folder index](concepts.md#folder-index)). A page whose basename matches, case-insensitively, takes the containing folder's name as its `title` (eg `Project A/index.md` is parsed to `Project A`). An explicit `title:` still wins. A match at the space root keeps its basename. When empty or absent, titles come from the basename.
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{ "plugins": { "markdown": { "folderIndex": ["Overview", "index"] } } }
|
|
156
|
+
```
|
|
157
|
+
|
|
150
158
|
### `typeInference`
|
|
151
159
|
|
|
152
160
|
Automatically assigns a node type based on folder structure when no `type` field is present in frontmatter. Explicit `type:` always takes precedence.
|
package/docs/rules.md
CHANGED
|
@@ -84,7 +84,9 @@ Prefer choosing the right category and relying on its default. Set `severity` on
|
|
|
84
84
|
|
|
85
85
|
### Order within a rule
|
|
86
86
|
|
|
87
|
-
A rule runs its parts in this order: `when`, then `expression`, then the `semantic` part. If the `expression`
|
|
87
|
+
A rule runs its parts in this order: `when`, then `expression`, then the `semantic` part. If the `expression` does not return `true`, the `semantic` part does not run on that node.
|
|
88
|
+
|
|
89
|
+
A node with a [gating](#cascade-gating) violation from an `expression` has none of its `semantic` parts evaluated. A non-gating violation, such as `info`, does not stop them.
|
|
88
90
|
|
|
89
91
|
An error in a `when` or `expression` is reported as a non-gating `error` violation.
|
|
90
92
|
|
|
@@ -105,8 +107,9 @@ Each evaluation receives:
|
|
|
105
107
|
|
|
106
108
|
Useful resolved fields on nodes:
|
|
107
109
|
- `resolvedType`
|
|
108
|
-
- `
|
|
109
|
-
- `
|
|
110
|
+
- `id`: the node's unique key. Compare nodes by `id`, since titles may repeat.
|
|
111
|
+
- `resolvedParentId`, `resolvedParentIds`: the `id` of the first parent, and of all parents
|
|
112
|
+
- `resolvedParentTitle`, `resolvedParentTitles`: the display titles of the same parents
|
|
110
113
|
|
|
111
114
|
Prefer `resolvedType` over raw `type` so aliases are handled correctly.
|
|
112
115
|
|
|
@@ -115,7 +118,7 @@ Prefer `resolvedType` over raw `type` so aliases are handled correctly.
|
|
|
115
118
|
Inside `nodes[...]`, bare names refer to each candidate node. Use `$$` to reference outer variables:
|
|
116
119
|
|
|
117
120
|
```jsonata
|
|
118
|
-
$count(nodes[
|
|
121
|
+
$count(nodes[resolvedParentId=$$.current.id and resolvedType='solution'])
|
|
119
122
|
```
|
|
120
123
|
|
|
121
124
|
### Common patterns
|
|
@@ -136,7 +139,7 @@ $not($exists(current.due)) or $toMillis(current.due) > $millis()
|
|
|
136
139
|
$toMillis(current.stale_after) > $toMillis(current.generated.at)
|
|
137
140
|
```
|
|
138
141
|
|
|
139
|
-
A datetime without an offset (`2026-07-01T00:00:00`) is read in the machine's local time zone, so require an offset with `format: "date-time"` when results must not depend on where validation runs. `$toMillis()` also needs seconds: it cannot parse a minute-precision datetime such as `2026-07-01T09:15`, which `format: "date-or-date-time"` accepts. A value `$toMillis()` cannot parse makes the
|
|
142
|
+
A datetime without an offset (`2026-07-01T00:00:00`) is read in the machine's local time zone, so require an offset with `format: "date-time"` when results must not depend on where validation runs. `$toMillis()` also needs seconds: it cannot parse a minute-precision datetime such as `2026-07-01T09:15`, which `format: "date-or-date-time"` accepts. A value `$toMillis()` cannot parse makes the expression fail.
|
|
140
143
|
|
|
141
144
|
The same applies to [filter expressions](concepts.md#filter-expressions): compare dates with `$toMillis()`, not as strings.
|
|
142
145
|
|
|
@@ -168,27 +171,28 @@ Example override:
|
|
|
168
171
|
## Cascade gating
|
|
169
172
|
|
|
170
173
|
When a schema uses `$metadata.hierarchy`, rule evaluation cascades top-down through the
|
|
171
|
-
hierarchy tree (breadth-first from roots and orphans). A **gate** occurs when a node
|
|
172
|
-
|
|
174
|
+
hierarchy tree (breadth-first from roots and orphans). A **gate** occurs when a node has
|
|
175
|
+
a violation that makes its descendants' rules untrustworthy or wasteful to run:
|
|
173
176
|
|
|
174
|
-
- **Schema validation
|
|
177
|
+
- **Schema validation error** on a node — always gates. The node's own rules are
|
|
175
178
|
suppressed too, since its data is structurally invalid.
|
|
176
179
|
- **Hierarchy structure violation** on a node (e.g. an invalid parent) — always gates the
|
|
177
180
|
node's subtree, though the node's own rules still run.
|
|
178
|
-
- **A rule
|
|
179
|
-
|
|
181
|
+
- **A gating rule violation** — a violation with `error` severity, or any violation of a rule with
|
|
182
|
+
`gatesDescendants: true`. It gates the node's subtree and skips the node's own semantic rules; its other
|
|
183
|
+
expressions still run.
|
|
180
184
|
|
|
181
185
|
Gating exists so that expensive or semantic downstream rules (see [Semantic rules](semantic-rules.md)) aren't wasted
|
|
182
186
|
evaluating nodes whose ancestors are already known-bad, and so noise doesn't drown out the
|
|
183
|
-
actionable
|
|
187
|
+
actionable violation. It is per-subtree: once a node is gated, its descendants inherit the same
|
|
184
188
|
gate (the original blocker), rather than each generating a new one. Cascade gating only applies to nodes within the
|
|
185
189
|
hierarchy DAG.
|
|
186
190
|
|
|
187
|
-
A rule has one `gatesDescendants` setting. It applies to whichever part
|
|
191
|
+
A rule has one `gatesDescendants` setting. It applies to whichever part reported the violation, the `expression` or the `semantic` part. Its default comes from the severity of that violation: `true` for `error`, and `false` for `warning` and `info`. Set it explicitly when that default doesn't fit.
|
|
188
192
|
|
|
189
193
|
### Coverage output
|
|
190
194
|
|
|
191
|
-
`sctx validate` reports gating as **coverage gates**: which node's
|
|
195
|
+
`sctx validate` reports gating as **coverage gates**: which node's violation (schema,
|
|
192
196
|
hierarchy, or a specific `ruleId`) suppressed rule evaluation, and the list of files gated
|
|
193
197
|
as a result. This makes the validation "edge" visible — e.g. "this blocked goal means all
|
|
194
198
|
opportunities beneath it were not evaluated" — for both human reading and agent consumption
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "structured-context",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Keep your content clean and your thinking clear with assistive tools for structured knowledge",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -35,12 +35,13 @@
|
|
|
35
35
|
"clean": "rm -rf dist",
|
|
36
36
|
"generate:schema-meta": "bun run scripts/generate-schema-meta.ts",
|
|
37
37
|
"typecheck": "tsc --noEmit",
|
|
38
|
-
"build": "bun run clean && bun run generate:schema-meta && tsc -p tsconfig.build.json &&
|
|
38
|
+
"build": "bun run clean && bun run generate:schema-meta && tsc -p tsconfig.build.json && cp -r schemas dist/ && chmod +x dist/index.js",
|
|
39
39
|
"prepublishOnly": "bun run build",
|
|
40
40
|
"validate": "bun run src/index.ts validate",
|
|
41
41
|
"diagram": "bun run src/index.ts diagram",
|
|
42
42
|
"test": "bun test tests/",
|
|
43
43
|
"test:smoke": "bun test smoke/",
|
|
44
|
+
"test:package": "bash scripts/test-package.sh",
|
|
44
45
|
"test:judge": "SCTX_LIVE_JUDGE_TESTS=1 bun test tests/judge/live.test.ts",
|
|
45
46
|
"test:models": "SCTX_MODEL_VALIDATION=1 bun test tests/judge/model-validation.test.ts",
|
|
46
47
|
"test:hook": "bun test hook-test/unit/",
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"test:all": "bun test",
|
|
49
50
|
"lint": "biome check",
|
|
50
51
|
"lint:fix": "biome check --write",
|
|
51
|
-
"preversion": "bun run generate:schema-meta && bun run lint:fix && bun run test",
|
|
52
|
+
"preversion": "bun run generate:schema-meta && bun run lint:fix && bun run test && bun run test:package",
|
|
52
53
|
"version": "bun run generate:schema-meta --release && git add -A",
|
|
53
54
|
"postversion": "git push --follow-tags"
|
|
54
55
|
},
|
|
@@ -57,8 +58,7 @@
|
|
|
57
58
|
"@biomejs/biome": "^2.5.15",
|
|
58
59
|
"@types/bun": "latest",
|
|
59
60
|
"json-schema-to-ts": "^3.1.1",
|
|
60
|
-
"lefthook": "^2.1.9"
|
|
61
|
-
"tsc-alias": "^1.9.1"
|
|
61
|
+
"lefthook": "^2.1.9"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"typescript": "^5"
|
package/schemas/_ost_strict.json
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"category": "best-practice",
|
|
35
35
|
"description": "Explore multiple candidate solutions (aim for at least three) for the target opportunity",
|
|
36
36
|
"type": "opportunity",
|
|
37
|
-
"expression": "(current.status != 'exploring' and current.status != 'active') or $count(nodes[
|
|
37
|
+
"expression": "(current.status != 'exploring' and current.status != 'active') or $count(nodes[resolvedParentId=$$.current.id and resolvedType='solution']) >= 3"
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
"id": "opportunity-customer-voice",
|