Skip to content

Commit 1103e45

Browse files
committed
feat: unify hierarchy and relationship edges into a single labelled graph
Both hierarchy levels and relationships now resolve into a single `resolvedParents: ResolvedParentRef[]` array on each node. Each ref carries `source: 'hierarchy' | 'relationship'` so downstream consumers can route edges without re-inspecting the schema. Key changes: - `resolveHierarchyEdges` → `resolveGraphEdges`, generalised to handle both hierarchy levels and relationship definitions in one post-parse pass - `validate-hierarchy` → `validate-graph`, with explicit `source` filtering: hierarchy edges go to structural checks, relationship edges to ref checks - `classifyNodes`, `show`, `diagram` filter to `source: 'hierarchy'` - Miro sync connector building and cache hashing filter to hierarchy edges - `multi` → `multiple` rename fixed in metadata contract and all schemas - `ResolvedParentRef` shape documented; concepts, schemas, architecture, and schema-authoring reference updated to reflect the unified graph model - Repro test (issue #56) replaced with permanent `source`-aware regression tests in resolve-hierarchy-edges.test.ts
1 parent 75b8140 commit 1103e45

33 files changed

Lines changed: 870 additions & 702 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ The `selfRefField` property enables different fields for regular vs same-type re
115115
| `fieldOn` | `"child"` | `"child"`: child holds a link pointing up. `"parent"`: parent holds an array of child links. |
116116
| `format` | `"page"` | Hint for `template-sync`: `"table"`, `"list"`, or `"heading"` |
117117
| `matchers` | `[]` | Heading text to match for embedded parsing (strings or `/regex/`). Case-insensitive. |
118-
| `multi` | `true` | Whether multiple children are expected |
118+
| `multiple` | `true` | Whether multiple children are expected |
119119
| `embeddedTemplateFields` | `[]` | Field names to include as table columns in templates |
120120

121121
With `fieldOn: "parent"`, embedded child nodes (parsed from a matching heading's list or table) are appended as wikilinks to the parent's `field` array, rather than receiving a `parent` field. This matches schemas where the content model naturally lists children on the parent (e.g. `activity.tasks: ["[[Task A]]"]`).

docs/architecture.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ flowchart LR
5555
| Boundary | Data |
5656
|---|---|
5757
| Space → Read | Raw markdown files / `space_on_a_page` file |
58-
| Read → Nodes | `SpaceNode[]` — schemaData (canonical fields), resolvedType, resolvedParents, linkTargets |
59-
| Schema → Read | Hierarchy levels (type names, edge fields, direction, cardinality), type aliases |
58+
| Read → Nodes | `SpaceNode[]` — schemaData (canonical fields), resolvedType, resolvedParents (`ResolvedParentRef[]`), linkTargets |
59+
| Schema → Read | Hierarchy levels + relationships (type names, edge fields, direction, cardinality), type aliases |
6060
| Schema → Validate | AJV validator, hierarchy rules, JSONata rule expressions |
6161
| Nodes → Output | Validated node set; output commands interpret as needed |
6262
| Config → Output | `fieldMap` (reverse) applied by template-sync for file field names |

docs/concepts.md

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -146,25 +146,20 @@ See [docs/rules.md](rules.md) for the rules reference, including JSONata express
146146

147147
## Hierarchy
148148

149-
The **hierarchy** is the ordered list of node types in a space, from root to leaf. It is defined in the schema's `$metadata.hierarchy.levels` array and drives depth-based type inference (for `space on a page`), tree rendering, and hierarchy validation. The root type has no parent; every other type has parents in the level immediately above (unless `$metadata.hierarchy.allowSkipLevels` is set).
149+
The **hierarchy** is the ordered list of node types in a space, from root to leaf. It is defined in the schema's `$metadata.hierarchy.levels` array and drives depth-based type inference (for `space on a page`), tree rendering, and structural validation. The root type has no parent; every other type has parents in the level immediately above (unless `$metadata.hierarchy.allowSkipLevels` is set).
150150

151-
Relationships between levels are 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.
151+
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.
152152

153-
### Edge configuration
154-
155-
A **hierarchy edge** is a directional link connecting a child node to one or more parent nodes. Each non-root level in the hierarchy defines how its edges are expressed in frontmatter. The default is a single `parent` wikilink on the child node, but any field name, direction, and cardinality can be configured.
153+
Each non-root level uses the shared `field`, `fieldOn`, and `multiple` edge options (see [Graph edges](#graph-edges)). Two additional options are hierarchy-specific:
156154

157155
| Option | Default | Meaning |
158156
|---|---|---|
159-
| `field` | `"parent"` | The frontmatter field that holds the wikilink(s) for the regular parent-child relationship |
160-
| `fieldOn` | `"child"` | `"parent"` means the field is on the **parent** node and points to children (reversed direction) |
161-
| `multiple` | `false` | When `true`, the field holds an **array** of wikilinks rather than a single one |
162-
| `selfRef` | `false` | When `true`, a node may have a parent of the same resolved type (uses `field` for same-type relationships) |
163-
| `selfRefField` | _undefined_ | When set, specifies a different field for same-type parent relationships (always on child-side) |
157+
| `selfRef` | `false` | When `true`, a node may have a parent of the same resolved type, using `field` for both regular and same-type parents |
158+
| `selfRefField` | _undefined_ | When set, specifies a separate field for same-type parent relationships (always on the child). Requires `selfRef: true`. |
164159

165160
**Example: Activities listing Capabilities with sub-capabilities**
166161

167-
```
162+
```json
168163
"levels": [
169164
"Activities",
170165
{
@@ -177,41 +172,63 @@ A **hierarchy edge** is a directional link connecting a child node to one or mor
177172
]
178173
```
179174

180-
This configuration supports two relationship types:
181-
- **Activities → Capabilities**: Via `capabilities` field on Activity nodes (array of capability wikilinks)
182-
- **Capability → Capability**: Via `parent` field on Capability nodes (single parent capability wikilink)
175+
This defines two edge types for Capabilities:
176+
- **Activities → Capabilities**: Via `capabilities` array field on Activity nodes
177+
- **Capability → Capability**: Via `parent` field on Capability nodes (same-type, child-side)
183178

184-
Without `selfRefField`, a type can only define one relationship field. The `selfRef` flag enables same-type relationships but uses the same `field` for both regular and same-type parents.
179+
---
185180

186-
Dangling wikilinks — edge field values that do not resolve to any known node — are reported as reference errors during validation.
181+
## Relationships
187182

188-
### Resolved parents
183+
A **relationship** is a link between a parent type and a child type that is not part of the primary structural hierarchy. For example, an `opportunity` might have a relationship with `assumption` (multiple) or `problem_statement` (single).
189184

190-
**Resolved parents** (`resolvedParents`) is the set of parent node titles derived from a node's edge field(s) at *parse* time. It is always an array (empty if unresolved or root-level). Tooling uses `resolvedParents` for tree rendering, hierarchy validation, rule evaluation, and diagram/Miro sync.
185+
Relationships are defined in `$metadata.relationships`. Like hierarchy levels, they use the shared `field`, `fieldOn`, and `multiple` edge options (see [Graph edges](#graph-edges)), but carry additional metadata used for parsing and template generation:
191186

192-
### Wikilink
187+
- **`parent`** / **`type`** — the parent and child canonical types (required)
188+
- **`format`** — parsing/generation hint: `"heading"`, `"list"`, `"table"`, or `"page"`
189+
- **`matchers`** — heading text patterns (strings or `/regex/`) used to detect relationship sections during embedded parsing
190+
191+
A heading in a typed page that matches a relationship's `matchers` signals to the parser that following content (single nodes, list items, or table rows) should be typed as that relationship's child type — without requiring explicit inline `[type:: x]` annotations.
192+
193+
---
193194

194-
A **wikilink** is the `[[Title]]` linking syntax (compatible with Obsidian) used to express hierarchy edges between `space nodes`. Any edge field — whether named `parent` or a custom name — holds wikilinks to linked nodes.
195+
## Graph edges
195196

196-
Two forms are supported:
197+
Both `hierarchy.levels` and `relationships` define **edges** in a directed graph over the node set. All edges use the same three configuration options:
198+
199+
| Option | Default | Meaning |
200+
|---|---|---|
201+
| `field` | `"parent"` | The frontmatter field holding the wikilink(s) |
202+
| `fieldOn` | `"child"` | `"parent"` means the field is on the **parent** node and points to children (reversed direction) |
203+
| `multiple` | `false` | When `true`, the field holds an **array** of wikilinks rather than a single one |
204+
205+
The `fieldOn: "parent"` pattern is used when the content model lists children on the parent node (e.g. `tasks: ["[[Task A]]", "[[Task B]]"]`). Embedded parsing then appends child wikilinks to the parent's field array rather than setting a `parent` field on each child.
206+
207+
Dangling wikilinks — edge field values that do not resolve to any known node — are reported as reference errors during validation.
208+
209+
### Wikilink
210+
211+
A **wikilink** is the `[[Title]]` linking syntax (compatible with Obsidian) used in edge fields to reference other `space nodes`. Two forms are supported:
197212

198213
| Form | Example | Resolves to |
199214
|---|---|---|
200215
| Plain title | `[[My Goal]]` | The `space node` whose title equals `My Goal` |
201216
| Anchor ref | `[[vision_page#^goal1]]` | The `embedded node` with `anchor` `goal1` inside `vision_page.md` |
202217

203-
### Relationships (Adjacent)
204-
205-
An **adjacent relationship** (or simply **relationship**) is a link between a parent type and a child type that is not part of the primary structural hierarchy. For example, an `opportunity` might have a relationship with `assumption` (multiple) or `problem_statement` (single).
218+
### Resolved parents
206219

207-
Relationships are defined in the schema's `$metadata.relationships` and provide tips for both generation (`template-sync`) and parsing (`parse-embedded`). In particular, a heading matching a relationship name in a typed page acts as a signal to the parser: content (single-node), and list items or table rows (multi-node) below it are typed as that relationship's child type without requiring explicit inline annotations.
220+
**Resolved parents** (`resolvedParents`) is the set of parent references derived from a node's edge fields at *parse* time. It is always an array (empty if unresolved or root-level). Both `hierarchy.levels` and `relationships` edges resolve into this single array — forming a unified labelled directed graph over the node set.
208221

209-
Relationships support two link directions via `field` and `fieldOn`:
222+
Each entry is a `ResolvedParentRef` object:
210223

211-
- **`fieldOn: "child"` (default)** — the child node carries the relationship field (e.g. `parent: "[[Opportunity A]]"`). This is the conventional form inherited from hierarchy edges.
212-
- **`fieldOn: "parent"`** — the parent node carries an array field (e.g. `tasks: ["[[Task A]]", "[[Task B]]"]`). Use this when the content model places the list on the parent rather than on each child. Embedded parsing populates the parent's field array rather than setting `parent` on each child node.
224+
| Field | Type | Description |
225+
|---|---|---|
226+
| `title` | `string` | The parent node's title |
227+
| `field` | `string` | The frontmatter field that held the wikilink |
228+
| `source` | `'hierarchy' \| 'relationship'` | Whether the edge came from a hierarchy level or a relationship |
229+
| `selfRef` | `boolean` | Whether the edge is a same-type (self-referential) parent link |
213230

214-
The `field` property names the frontmatter field (defaults to `"parent"` for child-side; must be explicit for parent-side). Validation checks all wikilinks in the field resolve to nodes of the declared type.
231+
The `source` label lets downstream consumers distinguish edge types without re-inspecting the schema. Validation routes `hierarchy` edges to structural checks (parent-type rules, skip-level detection) and `relationship` edges to field reference checks (type-match, missing-target). Tree rendering and rule evaluation use the full set.
215232

216233
### Anchor
217234

docs/schemas.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,13 @@ Top-level metadata shape:
9797
| `hierarchy` | object | Optional per provider; at most one provider may define it after composition |
9898
| `hierarchy.levels` | `(string \| HierarchyLevel)[]` | Ordered root→leaf types |
9999
| `hierarchy.allowSkipLevels` | `boolean` | Optional; allows parent to be any ancestor level |
100-
| `relationships` | `Relationship[]` | Optional; defines adjacent related node links |
100+
| `relationships` | `Relationship[]` | Optional; defines related node links outside the primary hierarchy |
101101
| `aliases` | `Record<string, string>` | Optional type alias map |
102102
| `rules` | `Rule[]` | Optional flat rule array |
103103

104104
### Relationships
105105

106-
Adjacent relationships define how related nodes (not part of the hierarchy) are handled during parsing and template generation.
106+
Relationships define links between node types that are not part of the primary structural hierarchy. They are handled during parsing and template generation.
107107

108108
| Field | Type | Default | Description |
109109
|---|---|---|---|
@@ -114,7 +114,7 @@ Adjacent relationships define how related nodes (not part of the hierarchy) are
114114
| `format` | `string` | `"page"` | Hint for `template-sync`: `"table"`, `"list"`, or `"heading"` |
115115
| `matchers` | `string[]` | `[]` | Heading text to match (strings or `/regex/`). Case-insensitive. |
116116
| `embeddedTemplateFields` | `string[]` | `[]` | Field names to include in templates when `format` is `"table"` |
117-
| `multi` | `boolean` | `true` | Whether multiple children are expected |
117+
| `multiple` | `boolean` | `true` | Whether multiple children are expected |
118118

119119
**`fieldOn: "child"` (default)** — child node has a field pointing to its parent. Embedded parsing sets this field on each child node; validation checks that it resolves to a node of the declared parent type.
120120

@@ -145,7 +145,7 @@ Adjacent relationships define how related nodes (not part of the hierarchy) are
145145
"fieldOn": "parent",
146146
"format": "list",
147147
"matchers": ["Tasks"],
148-
"multi": true
148+
"multiple": true
149149
}
150150
]
151151
```

schemas/general.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@
2525
"type": "problem_statement",
2626
"format": "heading",
2727
"matchers": ["Problem statement", "What problem are we solving?"],
28-
"multi": false
28+
"multiple": false
2929
},
3030
{
3131
"parent": "opportunity",
3232
"type": "assumption",
3333
"format": "table",
3434
"matchers": ["Assumptions", "^Assumptions?$"],
3535
"embeddedTemplateFields": ["assumption", "status", "confidence"],
36-
"multi": true
36+
"multiple": true
3737
}
3838
]
3939
},

schemas/generated/_ost_tools_schema_meta.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
"format": {
8787
"enum": ["heading", "list", "table", "page"]
8888
},
89-
"multi": {
89+
"multiple": {
9090
"type": "boolean"
9191
},
9292
"matchers": {

skills/ost-tools/references/schema-authoring.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ Use object entries to override defaults:
4646
- `multiple: true` for array wikilinks
4747
- `selfRef: true` for same-type parent links
4848

49-
### Adjacent Relationships (`$metadata.relationships`)
49+
### Relationships (`$metadata.relationships`)
5050

51-
Adjacent relationships define how sub-entities (nodes inside other files) are parsed and generated.
51+
Relationships define how sub-entities (nodes inside other files) are parsed and generated.
5252

5353
| Field | Default | Description |
5454
|---|---|---|
@@ -58,7 +58,7 @@ Adjacent relationships define how sub-entities (nodes inside other files) are pa
5858
| `fieldOn` | `"child"` | `"child"`: child has the field pointing up. `"parent"`: parent has an array field pointing down to children. |
5959
| `format` | | Hint for `template-sync`: `"table"`, `"list"`, or `"heading"` |
6060
| `matchers` | | Heading text to match (strings or `/regex/`). Case-insensitive. |
61-
| `multi` | `true` | Whether multiple children are expected |
61+
| `multiple` | `true` | Whether multiple children are expected |
6262
| `embeddedTemplateFields` | | Field names for table columns |
6363

6464
**`fieldOn: "parent"` pattern** — use when the content model lists children on the parent (e.g. `activity.tasks: ["[[Task A]]"]`). Embedded parsing appends child wikilinks to the parent's `field` array rather than setting a `parent` field on each child. Validation checks each array entry resolves to a node of `type`.
@@ -146,6 +146,12 @@ Each rule evaluation receives: `nodes`, `current`, `parent`, `parents`.
146146

147147
Use `resolvedType` in comparisons (not raw `type`) so aliases are respected.
148148

149+
Each node also carries convenience fields for common queries:
150+
- `resolvedParentTitle` — title of the first resolved parent (or `undefined`)
151+
- `resolvedParentTitles` — array of all resolved parent titles
152+
153+
`resolvedParents` on the raw node is an array of `ResolvedParentRef` objects (`{ title, field, source, selfRef }`); use the convenience fields for simple title-matching.
154+
149155
```jsonata
150156
$count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution'])
151157
```

src/commands/validate.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import type { ErrorObject } from 'ajv';
22
import { readSpace } from '../read/read-space';
3-
import { buildTargetIndex } from '../read/wikilink-utils';
43
import { buildFullRegistry, createValidator, loadMetadata, readRawSchema } from '../schema/schema';
5-
import { validateHierarchyWithFields, validateRelationships } from '../schema/validate-hierarchy';
4+
import { validateGraph } from '../schema/validate-graph';
65
import { validateRules } from '../schema/validate-rules';
7-
import type { HierarchyViolation, RuleViolation } from '../types';
6+
import type { GraphViolation, RuleViolation } from '../types';
87
import { classifyNodes } from '../util/graph-helpers';
98
import { extractEntityInfo } from './schemas';
109

@@ -20,7 +19,7 @@ interface ValidationResult {
2019
refErrors: Array<{ file: string; parent: string; error: string }>;
2120
duplicateErrors: Array<{ title: string; files: string[] }>;
2221
ruleViolations: RuleViolation[];
23-
hierarchyViolations: HierarchyViolation[];
22+
hierarchyViolations: GraphViolation[];
2423
orphanCount: number;
2524
skipped: string[];
2625
nonSpace: string[];
@@ -187,12 +186,10 @@ export async function validate(path: string, options: { schema: string; template
187186
}
188187

189188
// Validate all hierarchy constraints (field references and structure)
190-
const linkTargetIndex = buildTargetIndex(nodes);
191-
const hierarchyValidation = validateHierarchyWithFields(nodes, metadata);
192-
const relValidation = validateRelationships(nodes, metadata, linkTargetIndex);
189+
const hierarchyValidation = validateGraph(nodes, metadata);
193190

194-
result.refErrors.push(...hierarchyValidation.refErrors, ...relValidation.refErrors);
195-
result.hierarchyViolations = [...hierarchyValidation.violations, ...relValidation.violations];
191+
result.refErrors.push(...hierarchyValidation.refErrors);
192+
result.hierarchyViolations = [...hierarchyValidation.violations];
196193

197194
// Calculate orphan count (informational, not a validation error)
198195
if (metadata.hierarchy) {

src/integrations/miro/cache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function computeNodeHash(node: SpaceNode): string {
5454
status: node.schemaData.status,
5555
summary: node.schemaData.summary,
5656
priority: node.schemaData.priority,
57-
parents: node.resolvedParents,
57+
parents: node.resolvedParents.filter((r) => r.source === 'hierarchy').map((r) => r.title),
5858
};
5959
return createHash('sha256').update(JSON.stringify(relevant)).digest('hex').slice(0, 16);
6060
}

src/integrations/miro/sync.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,8 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi
325325
const desiredEdges = new Map<string, { parentTitle: string; childTitle: string }>();
326326
for (const node of nodes) {
327327
const childTitle = node.schemaData.title as string;
328-
for (const parentTitle of node.resolvedParents) {
328+
for (const { title: parentTitle, source } of node.resolvedParents) {
329+
if (source !== 'hierarchy') continue;
329330
// Both endpoints must have verified cards on the board
330331
if (verifiedCardIds.has(parentTitle) && verifiedCardIds.has(childTitle)) {
331332
const key = `${parentTitle}\u2192${childTitle}`;

0 commit comments

Comments
 (0)