You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Copy file name to clipboardExpand all lines: README.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -115,7 +115,7 @@ The `selfRefField` property enables different fields for regular vs same-type re
115
115
|`fieldOn`|`"child"`|`"child"`: child holds a link pointing up. `"parent"`: parent holds an array of child links. |
116
116
|`format`|`"page"`| Hint for `template-sync`: `"table"`, `"list"`, or `"heading"`|
117
117
|`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 |
119
119
|`embeddedTemplateFields`|`[]`| Field names to include as table columns in templates |
120
120
121
121
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]]"]`).
Copy file name to clipboardExpand all lines: docs/concepts.md
+46-29Lines changed: 46 additions & 29 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -146,25 +146,20 @@ See [docs/rules.md](rules.md) for the rules reference, including JSONata express
146
146
147
147
## Hierarchy
148
148
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).
150
150
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.
152
152
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:
156
154
157
155
| Option | Default | Meaning |
158
156
|---|---|---|
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`. |
164
159
165
160
**Example: Activities listing Capabilities with sub-capabilities**
166
161
167
-
```
162
+
```json
168
163
"levels": [
169
164
"Activities",
170
165
{
@@ -177,41 +172,63 @@ A **hierarchy edge** is a directional link connecting a child node to one or mor
177
172
]
178
173
```
179
174
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)
183
178
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
+
---
185
180
186
-
Dangling wikilinks — edge field values that do not resolve to any known node — are reported as reference errors during validation.
181
+
## Relationships
187
182
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).
189
184
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:
191
186
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
+
---
193
194
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
195
196
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:
197
212
198
213
| Form | Example | Resolves to |
199
214
|---|---|---|
200
215
| Plain title |`[[My Goal]]`| The `space node` whose title equals `My Goal`|
201
216
| Anchor ref |`[[vision_page#^goal1]]`| The `embedded node` with `anchor``goal1` inside `vision_page.md`|
202
217
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
206
219
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.
208
221
209
-
Relationships support two link directions via `field` and `fieldOn`:
222
+
Each entry is a `ResolvedParentRef` object:
210
223
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 |
213
230
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.
|`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|
101
101
|`aliases`|`Record<string, string>`| Optional type alias map |
102
102
|`rules`|`Rule[]`| Optional flat rule array |
103
103
104
104
### Relationships
105
105
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.
107
107
108
108
| Field | Type | Default | Description |
109
109
|---|---|---|---|
@@ -114,7 +114,7 @@ Adjacent relationships define how related nodes (not part of the hierarchy) are
114
114
|`format`|`string`|`"page"`| Hint for `template-sync`: `"table"`, `"list"`, or `"heading"`|
115
115
|`matchers`|`string[]`|`[]`| Heading text to match (strings or `/regex/`). Case-insensitive. |
116
116
|`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 |
118
118
119
119
**`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.
120
120
@@ -145,7 +145,7 @@ Adjacent relationships define how related nodes (not part of the hierarchy) are
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.
52
52
53
53
| Field | Default | Description |
54
54
|---|---|---|
@@ -58,7 +58,7 @@ Adjacent relationships define how sub-entities (nodes inside other files) are pa
58
58
|`fieldOn`|`"child"`|`"child"`: child has the field pointing up. `"parent"`: parent has an array field pointing down to children. |
59
59
|`format`|| Hint for `template-sync`: `"table"`, `"list"`, or `"heading"`|
60
60
|`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 |
62
62
|`embeddedTemplateFields`|| Field names for table columns |
63
63
64
64
**`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`.
Use `resolvedType` in comparisons (not raw `type`) so aliases are respected.
148
148
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
+
149
155
```jsonata
150
156
$count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution'])
0 commit comments