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.
Files changed (70) hide show
  1. package/README.md +2 -2
  2. package/dist/api.d.ts +17 -17
  3. package/dist/api.js +12 -12
  4. package/dist/commands/dump.js +1 -1
  5. package/dist/commands/validate.js +8 -8
  6. package/dist/config.d.ts +1 -1
  7. package/dist/config.js +4 -4
  8. package/dist/filter/augment-nodes.d.ts +5 -4
  9. package/dist/filter/augment-nodes.js +20 -19
  10. package/dist/filter/expand-include.d.ts +2 -2
  11. package/dist/filter/expand-include.js +15 -17
  12. package/dist/filter/filter-nodes.js +2 -2
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.js +17 -17
  15. package/dist/judge/context.d.ts +1 -1
  16. package/dist/judge/context.js +12 -12
  17. package/dist/judge/factory.d.ts +1 -1
  18. package/dist/judge/factory.js +1 -1
  19. package/dist/judge/index.d.ts +1 -1
  20. package/dist/judge/judge-node.d.ts +4 -4
  21. package/dist/judge/judge-node.js +7 -7
  22. package/dist/judge/prompt.d.ts +1 -1
  23. package/dist/judge/prompt.js +3 -3
  24. package/dist/judge/provider/fake.d.ts +1 -1
  25. package/dist/judge/provider/fake.js +3 -6
  26. package/dist/judge/provider/jev.js +1 -1
  27. package/dist/judge/provider/openai-decisions.js +1 -1
  28. package/dist/judge/trim.d.ts +1 -1
  29. package/dist/judge/types.d.ts +4 -1
  30. package/dist/judge/verdicts.d.ts +1 -1
  31. package/dist/load-space-graph.d.ts +3 -3
  32. package/dist/load-space-graph.js +4 -4
  33. package/dist/plugins/loader.js +1 -1
  34. package/dist/plugins/markdown/index.d.ts +6 -0
  35. package/dist/plugins/markdown/index.js +1 -0
  36. package/dist/plugins/markdown/parse-embedded.d.ts +7 -2
  37. package/dist/plugins/markdown/parse-embedded.js +23 -21
  38. package/dist/plugins/markdown/read-space.js +9 -8
  39. package/dist/plugins/markdown/render-bullets.js +3 -3
  40. package/dist/plugins/markdown/render-directory.js +1 -1
  41. package/dist/plugins/markdown/util.d.ts +12 -1
  42. package/dist/plugins/markdown/util.js +21 -0
  43. package/dist/plugins/mermaid/render-mermaid.js +5 -6
  44. package/dist/plugins/miro/cache.js +1 -1
  45. package/dist/plugins/miro/sync.js +3 -2
  46. package/dist/read/resolve-graph-edges.js +12 -18
  47. package/dist/render/render.d.ts +1 -1
  48. package/dist/render/render.js +1 -1
  49. package/dist/schema/evaluate-rule.d.ts +4 -4
  50. package/dist/schema/evaluate-rule.js +14 -10
  51. package/dist/schema/gating.d.ts +10 -10
  52. package/dist/schema/gating.js +45 -44
  53. package/dist/schema/validate-graph.js +8 -11
  54. package/dist/schema/validate-rules.d.ts +4 -4
  55. package/dist/schema/validate-rules.js +15 -17
  56. package/dist/schemas/_ost_strict.json +1 -1
  57. package/dist/space-context.d.ts +2 -2
  58. package/dist/space-context.js +3 -3
  59. package/dist/space-graph.d.ts +9 -9
  60. package/dist/space-graph.js +16 -16
  61. package/dist/testing.d.ts +2 -2
  62. package/dist/testing.js +3 -3
  63. package/dist/types.d.ts +18 -14
  64. package/dist/validate.d.ts +6 -6
  65. package/dist/validate.js +26 -27
  66. package/docs/concepts.md +25 -7
  67. package/docs/config.md +8 -0
  68. package/docs/rules.md +17 -13
  69. package/package.json +5 -5
  70. package/schemas/_ost_strict.json +1 -1
@@ -1,4 +1,4 @@
1
- import type { Hierarchy, SpaceNode } from './types.js';
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 title. Preserves insertion order. */
13
- readonly nodes: ReadonlyMap<string, SpaceNode>;
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 title → direct children connected via this hierarchy's edges only. */
21
- readonly hierarchyChildren: ReadonlyMap<string, readonly SpaceNode[]>;
22
- /** All-edges children map: parent title → direct children connected via any edge (hierarchy + relationship). */
23
- readonly children: ReadonlyMap<string, readonly SpaceNode[]>;
24
- /** Set of all node titles that are part of the hierarchy (roots + their descendants + orphans). */
25
- readonly hierarchyTitles: ReadonlySet<string>;
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
  };
@@ -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.title, node);
16
- hierarchyChildrenMap.set(node.title, []);
17
- childrenMap.set(node.title, []);
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.title))
24
- childrenMap.set(parentRef.title, []);
25
- childrenMap.get(parentRef.title).push(node);
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.title))
29
- hierarchyChildrenMap.set(parentRef.title, []);
30
- hierarchyChildrenMap.get(parentRef.title).push(node);
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.title));
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 hierarchyTitles: BFS from roots + orphans through hierarchyChildren
61
- const hierarchyTitles = new Set();
60
+ // Build hierarchyIds: BFS from roots + orphans through hierarchyChildren
61
+ const hierarchyIds = new Set();
62
62
  function addHierarchySubtree(node) {
63
- if (hierarchyTitles.has(node.title))
63
+ if (hierarchyIds.has(node.id))
64
64
  return;
65
- hierarchyTitles.add(node.title);
66
- for (const child of hierarchyChildrenMap.get(node.title) ?? []) {
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
- hierarchyTitles,
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.js';
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: (title: string, overrides?: Partial<ResolvedParentRef>) => ResolvedParentRef;
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 = (title, overrides = {}) => {
5
+ export const makeParentRef = (id, overrides = {}) => {
6
6
  const source = overrides.source ?? 'hierarchy';
7
7
  return {
8
- title,
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
- label: `${title}.md`,
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.js';
3
- import type { MetadataContractHierarchyLevel, MetadataContractRelationship, Rule, SharedEdgeFields } from './schema/metadata-contract.js';
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 title but the edge context
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
- /** Canonical title of the parent node. */
38
- title: string;
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
- /** Source identifier of the node containing the broken link. */
60
- label: string;
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
- /** Source identifier for error messages (filename or heading title) */
113
- label: string;
114
- /** Canonical title of the node (from schemaData.title). First-class accessor. */
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 title, the field it came from, and its edge context.
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.js';
175
- export type { Rule } from './schema/metadata-contract.js';
178
+ export type { CoverageGate, GatingSeed } from './schema/gating';
179
+ export type { Rule } from './schema/metadata-contract';
176
180
  export type RuleViolation = {
177
- /** Node label, or empty string for global-scope rules. */
181
+ /** Node id, or empty string for global-scope rules. */
178
182
  file: string;
179
183
  ruleId: string;
180
184
  category: RuleCategory;
@@ -1,9 +1,9 @@
1
1
  import type { ErrorObject } from 'ajv';
2
- import type { Config, SpaceConfig } from './config.js';
3
- import type { SemanticJudgeProvider } from './judge/types.js';
4
- import { type RuleFilter } from './schema/rule-filter.js';
5
- import { extractEntityInfo } from './schema/schema.js';
6
- import type { CoverageGate, FileNotInSpaceResult, FileValidationResult, GraphViolation, ParseIssue, ReadSpaceResult, RuleViolation, SchemaWithMetadata, SpaceContext, SpaceNode, ValidateFileOutput } from './types.js';
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
- title: string;
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.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';
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 keys (titles)
186
- const titleToFiles = new Map();
185
+ // Detect duplicate node ids. Display titles may repeat.
186
+ const idToFiles = new Map();
187
187
  for (const node of nodes) {
188
- if (!titleToFiles.has(node.title)) {
189
- titleToFiles.set(node.title, []);
188
+ if (!idToFiles.has(node.id)) {
189
+ idToFiles.set(node.id, []);
190
190
  }
191
- titleToFiles.get(node.title).push(node.label);
191
+ idToFiles.get(node.id).push(node.id);
192
192
  }
193
- for (const [title, files] of titleToFiles) {
193
+ for (const [id, files] of idToFiles) {
194
194
  if (files.length > 1) {
195
- result.duplicateErrors.push({ title, files });
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
- const file = node.source?.file ?? node.label;
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, title: v.nodeTitle, reason: 'hierarchy' })),
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.label}::${link.target}`;
225
+ const key = `${node.id}::${link.target}`;
227
226
  if (!seen.has(key)) {
228
227
  seen.add(key);
229
- result.unresolvedContentLinks.push({ file: node.label, target: link.target });
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.title);
251
+ const nodeDepth = depths.get(node.id);
253
252
  if (nodeDepth !== undefined && nodeDepth > options.depth) {
254
- skippedByDepth.push({ file: node.label, title: node.title });
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.label,
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.label !== label)
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 { title, files } of result.duplicateErrors) {
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:${title}`] = {
334
+ errors[`duplicate:${id}`] = {
336
335
  kind: 'duplicate',
337
- message: `Duplicate title "${title}" also exists in: ${others.join(', ')}`,
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 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.
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 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.
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
- | Plain title | `[[My Goal]]` | The `space node` whose title equals `My Goal` |
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
- | `title` | `string` | The parent node's title |
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`. `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:
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 title |
364
- | `descendants[]` | Flat array of descendant nodes, nearest first, deduplicated by title |
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` fails, the `semantic` part does not run on that node.
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
- - `resolvedParentTitle`
109
- - `resolvedParentTitles`
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[resolvedParentTitle=$$.current.title and resolvedType='solution'])
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 rule fail.
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 fails
172
- in a way that makes its descendants' rules untrustworthy or wasteful to run:
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 failure** on a node — always gates. The node's own rules are
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 failure with `gatesDescendants: true`** — gates the node's subtree, though the
179
- failing node's own other rules still run.
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 failure. It is per-subtree: once a node is gated, its descendants inherit the same
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 failed, the `expression` or the `semantic` part. Its default comes from the severity of that failure: `true` for `error`, and `false` for `warning` and `info`. Set it explicitly when that default doesn't fit.
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 failure (schema,
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.13.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 && tsc-alias -p tsconfig.build.json && cp -r schemas dist/ && chmod +x dist/index.js",
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"
@@ -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[resolvedParentTitle=$$.current.title and resolvedType='solution']) >= 3"
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",