diff --git a/.agents/hooks/lint-and-test.sh b/.agents/hooks/lint-and-test.sh
index b4b91ea..b7778e3 100755
--- a/.agents/hooks/lint-and-test.sh
+++ b/.agents/hooks/lint-and-test.sh
@@ -3,6 +3,10 @@
# Exit code 0: all checks passed (allow)
# Exit code 2: checks failed (deny/block for both Claude Code and Gemini CLI)
+if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
+ exit 0 # Let Claude stop
+fi
+
STATUS=0
echo "π Running pre-stop checks..." >&2
diff --git a/README.md b/README.md
index 4693407..d6a663a 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,14 @@ ost-tools schemas use a Draft-07-based metaschema that adds a top-level `$metada
"levels": ["outcome", { "type": "opportunity", "selfRef": true }, "solution", "assumption_test"],
"allowSkipLevels": false
},
+ "relationships": [
+ {
+ "parent": "opportunity",
+ "type": "assumption",
+ "format": "table",
+ "matchers": ["Assumptions"]
+ }
+ ],
"aliases": { "experiment": "assumption_test" },
"rules": [
{
@@ -83,6 +91,7 @@ Schema hierarchy levels support DAG (multi-parent) relationships via configurabl
{ "type": "opportunity", "selfRef": true }
{ "type": "solution", "field": "fulfills", "multiple": true }
{ "type": "requirement", "field": "generates", "fieldOn": "parent", "multiple": true }
+{ "type": "solution", "field": "solutions", "fieldOn": "parent", "multiple": true, "selfRefField": "parent" }
```
| Property | Default | Description |
@@ -92,6 +101,24 @@ Schema hierarchy levels support DAG (multi-parent) relationships via configurabl
| `fieldOn` | `"child"` | Which side holds the field: `"child"` (child points up) or `"parent"` (parent points down) |
| `multiple` | `false` | Whether the field is an array of wikilinks (enables multi-parent DAG) |
| `selfRef` | `false` | Whether a node of this type may reference a same-type parent |
+| `selfRefField` | _undefined_ | Optional field for same-type parent relationships (always on child-side and singular) |
+
+The `selfRefField` property enables different fields for regular vs same-type relationships. For example, requirements can list solutions via `solutions` on the requirement node, while solutions can reference parent solutions via `parent` on the solution node.
+
+**Adjacent Relationships** (`$metadata.relationships`) define connections between types outside the primary hierarchy β such as an `activity` having many `task` nodes. They drive embedded parsing (typed headings, lists, tables) and template generation.
+
+| Property | Default | Description |
+|---|---|---|
+| `parent` | required | Parent canonical type |
+| `type` | required | Child canonical type |
+| `field` | `"parent"` | Frontmatter field holding the wikilink(s). Required when `fieldOn: "parent"`. |
+| `fieldOn` | `"child"` | `"child"`: child holds a link pointing up. `"parent"`: parent holds an array of child links. |
+| `format` | `"page"` | Hint for `template-sync`: `"table"`, `"list"`, or `"heading"` |
+| `matchers` | `[]` | Heading text to match for embedded parsing (strings or `/regex/`). Case-insensitive. |
+| `multi` | `true` | Whether multiple children are expected |
+| `embeddedTemplateFields` | `[]` | Field names to include as table columns in templates |
+
+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]]"]`).
Metadata is composable across `$ref` graphs:
- zero or one metadata provider may define `hierarchy`
diff --git a/docs/concepts.md b/docs/concepts.md
index 495e9d7..133fbc9 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -13,7 +13,7 @@ flowchart TD
subgraph dir [Space Directory]
direction TB
tp[Typed Page
type: goal in frontmatter]
- en[Embedded Nodes
headings with type annotations
or anchor-implied types]
+ en[Embedded Nodes
headings, bullets, or table rows
explicitly typed, relationship-implied,
or anchor-implied]
other[Other files
no frontmatter β skipped
no type field β nonSpace]
tp -->|body parsed for| en
end
@@ -21,10 +21,12 @@ flowchart TD
subgraph soap [Space on a Page]
direction TB
sf[Single .md file
type: space_on_a_page]
- hn[Heading nodes
depth β type via hierarchy]
- bn[Bullet nodes
explicit inline type annotation]
+ hn[Heading nodes
depth β type via hierarchy
or relationship-implied]
+ bn[Bullet nodes
explicitly typed or relationship-implied]
+ tn[Table rows
typed via relationship heading
or first-column name]
sf -->|headings| hn
sf -->|typed bullets| bn
+ sf -->|typed tables| tn
end
pe[parse-embedded
extractEmbeddedNodes]
@@ -44,8 +46,10 @@ A **space directory** is a directory of markdown files that backs a `space`. Eac
Each `.md` file with a `type` frontmatter field is a **typed page** β it represents one node. Its body is also scanned for **embedded nodes**:
- **Heading with `[type:: x]`** or **anchor-implied type** (e.g. `## My Goal ^goal1`) β becomes a child node.
+- **Relationship headings** (e.g. `### Assumptions`) β when matched to a relationship definition in the schema, signals that following content (list items or table rows) should be typed as that relationship's child type, without requiring explicit inline annotations.
- **Untyped headings** β update the depth stack for parent resolution but do not become nodes.
- **Typed bullet items** (`- [type:: solution] Title`) β become child nodes at any nesting depth.
+- **Table rows** under a relationship heading β become child nodes of the relationship type.
- **YAML blocks** and **unbracketed `key:: value` paragraph fields** β merged into the current node's `schemaData`.
Parsing behaviour for a space directory:
@@ -152,10 +156,32 @@ A **hierarchy edge** is a directional link connecting a child node to one or mor
| Option | Default | Meaning |
|---|---|---|
-| `field` | `"parent"` | The frontmatter field that holds the wikilink(s) |
+| `field` | `"parent"` | The frontmatter field that holds the wikilink(s) for the regular parent-child relationship |
| `fieldOn` | `"child"` | `"parent"` means the field is on the **parent** node and points to children (reversed direction) |
| `multiple` | `false` | When `true`, the field holds an **array** of wikilinks rather than a single one |
-| `selfRef` | `false` | When `true`, a node may have a parent of the same resolved type |
+| `selfRef` | `false` | When `true`, a node may have a parent of the same resolved type (uses `field` for same-type relationships) |
+| `selfRefField` | _undefined_ | When set, specifies a different field for same-type parent relationships (always on child-side) |
+
+**Example: Activities listing Capabilities with sub-capabilities**
+
+```
+"levels": [
+ "Activities",
+ {
+ "type": "Capabilities",
+ "field": "capabilities",
+ "fieldOn": "parent",
+ "multiple": true,
+ "selfRefField": "parent"
+ }
+]
+```
+
+This configuration supports two relationship types:
+- **Activities β Capabilities**: Via `capabilities` field on Activity nodes (array of capability wikilinks)
+- **Capability β Capability**: Via `parent` field on Capability nodes (single parent capability wikilink)
+
+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.
Dangling wikilinks β edge field values that do not resolve to any known node β are reported as reference errors during validation.
@@ -174,6 +200,19 @@ Two forms are supported:
| Plain title | `[[My Goal]]` | The `space node` whose title equals `My Goal` |
| Anchor ref | `[[vision_page#^goal1]]` | The `embedded node` with `anchor` `goal1` inside `vision_page.md` |
+### Relationships (Adjacent)
+
+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).
+
+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.
+
+Relationships support two link directions via `field` and `fieldOn`:
+
+- **`fieldOn: "child"` (default)** β the child node carries the relationship field (e.g. `parent: "[[Opportunity A]]"`). This is the conventional form inherited from hierarchy edges.
+- **`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.
+
+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.
+
### Anchor
An **anchor** is a block anchor (e.g. `^goal1`) appended to a heading in a `typed page`, using Obsidian block anchor syntax. Anchors serve two purposes:
diff --git a/docs/schemas.md b/docs/schemas.md
index 1450d81..2511a2c 100644
--- a/docs/schemas.md
+++ b/docs/schemas.md
@@ -97,9 +97,61 @@ Top-level metadata shape:
| `hierarchy` | object | Optional per provider; at most one provider may define it after composition |
| `hierarchy.levels` | `(string \| HierarchyLevel)[]` | Ordered rootβleaf types |
| `hierarchy.allowSkipLevels` | `boolean` | Optional; allows parent to be any ancestor level |
+| `relationships` | `Relationship[]` | Optional; defines adjacent related node links |
| `aliases` | `Record` | Optional type alias map |
| `rules` | `Rule[]` | Optional flat rule array |
+### Relationships
+
+Adjacent relationships define how related nodes (not part of the hierarchy) are handled during parsing and template generation.
+
+| Field | Type | Default | Description |
+|---|---|---|---|
+| `parent` | `string` | **Required** | The parent's canonical type name |
+| `type` | `string` | **Required** | The child's canonical type name |
+| `field` | `string` | `"parent"` | The frontmatter field that holds the wikilink(s) for this relationship |
+| `fieldOn` | `string` | `"child"` | `"child"` (child holds a link to parent) or `"parent"` (parent holds an array of child links) |
+| `format` | `string` | `"page"` | Hint for `template-sync`: `"table"`, `"list"`, or `"heading"` |
+| `matchers` | `string[]` | `[]` | Heading text to match (strings or `/regex/`). Case-insensitive. |
+| `embeddedTemplateFields` | `string[]` | `[]` | Field names to include in templates when `format` is `"table"` |
+| `multi` | `boolean` | `true` | Whether multiple children are expected |
+
+**`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.
+
+**`fieldOn: "parent"` β parent-side array** β the parent node has an array field (`field`) holding wikilinks to child nodes. When `field` is required, it must be specified. Embedded parsing appends `[[Child]]` entries to the parent node's field array; validation checks that each entry resolves to a node of the declared child type.
+
+**Example β child-side (default):**
+
+```json5
+"relationships": [
+ {
+ "parent": "opportunity",
+ "type": "assumption",
+ "format": "table",
+ "matchers": ["Assumptions", "/assum.*/"],
+ "embeddedTemplateFields": ["assumption", "status", "confidence"]
+ }
+]
+```
+
+**Example β parent-side array:**
+
+```json5
+"relationships": [
+ {
+ "parent": "activity",
+ "type": "task",
+ "field": "tasks",
+ "fieldOn": "parent",
+ "format": "list",
+ "matchers": ["Tasks"],
+ "multi": true
+ }
+]
+```
+
+With this configuration, embedded task items under an Activity's "Tasks" heading populate `activity.tasks` as `[[Task Title]]` wikilinks, and validation confirms each entry resolves to a `task` node.
+
`HierarchyLevel` options:
| Option | Default | Meaning |
diff --git a/schemas/_ost_tools_base.json b/schemas/_ost_tools_base.json
index 3f14ad6..4600f8a 100644
--- a/schemas/_ost_tools_base.json
+++ b/schemas/_ost_tools_base.json
@@ -58,6 +58,15 @@
}
},
"required": ["status"]
+ },
+ "parentNodeProps": {
+ "type": "object",
+ "properties": {
+ "parent": {
+ "$ref": "#/$defs/wikilink",
+ "description": "Parent node"
+ }
+ }
}
}
}
diff --git a/schemas/general.json b/schemas/general.json
index 7bd63c1..4cc41b7 100644
--- a/schemas/general.json
+++ b/schemas/general.json
@@ -18,7 +18,24 @@
"outcome": "goal",
"assumption_test": "experiment",
"test": "experiment"
- }
+ },
+ "relationships": [
+ {
+ "parent": "opportunity",
+ "type": "problem_statement",
+ "format": "heading",
+ "matchers": ["Problem statement", "What problem are we solving?"],
+ "multi": false
+ },
+ {
+ "parent": "opportunity",
+ "type": "assumption",
+ "format": "table",
+ "matchers": ["Assumptions", "^Assumptions?$"],
+ "embeddedTemplateFields": ["assumption", "status", "confidence"],
+ "multi": true
+ }
+ ]
},
"oneOf": [
{
@@ -62,11 +79,11 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
- "type": { "const": "mission" },
- "parent": { "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink" }
+ "type": { "const": "mission" }
},
"required": ["type"],
"additionalProperties": true,
@@ -82,11 +99,11 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
"type": { "enum": ["goal", "outcome"] },
- "parent": { "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink" },
"priority": { "$ref": "ost-tools://_ost_tools_base#/$defs/priority" }
},
"required": ["type"],
@@ -104,11 +121,11 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
"type": { "const": "opportunity" },
- "parent": { "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink" },
"priority": { "$ref": "ost-tools://_ost_tools_base#/$defs/priority" },
"impact": { "$ref": "ost-tools://_ost_tools_base#/$defs/assessment" },
"feasibility": { "$ref": "ost-tools://_ost_tools_base#/$defs/assessment" },
@@ -137,14 +154,11 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
"type": { "const": "solution" },
- "parent": {
- "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink",
- "description": "Parent opportunity or solution (wikilink)"
- },
"priority": { "$ref": "ost-tools://_ost_tools_base#/$defs/priority" },
"impact": { "$ref": "ost-tools://_ost_tools_base#/$defs/assessment" },
"feasibility": { "$ref": "ost-tools://_ost_tools_base#/$defs/assessment" },
@@ -173,14 +187,11 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
- "type": { "enum": ["experiment", "assumption_test", "test"] },
- "parent": {
- "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink",
- "description": "Parent solution (wikilink)"
- }
+ "type": { "enum": ["experiment", "assumption_test", "test"] }
},
"required": ["type"],
"additionalProperties": true,
@@ -196,6 +207,51 @@
"parent": "[[Mobile App Solution]]"
}
]
+ },
+ {
+ "type": "object",
+ "allOf": [
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
+ ],
+ "properties": {
+ "type": { "const": "assumption" },
+ "confidence": {
+ "type": "string",
+ "enum": ["low", "medium", "high"],
+ "description": "Confidence level in this assumption"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": true,
+ "examples": [
+ {
+ "type": "assumption",
+ "status": "identified",
+ "parent": "[[Some Opportunity]]",
+ "confidence": "medium"
+ }
+ ]
+ },
+ {
+ "type": "object",
+ "allOf": [
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
+ ],
+ "properties": {
+ "type": { "const": "problem_statement" }
+ },
+ "required": ["type"],
+ "additionalProperties": true,
+ "examples": [
+ {
+ "type": "problem_statement",
+ "status": "identified"
+ }
+ ]
}
]
}
diff --git a/schemas/generated/_ost_tools_schema_meta.json b/schemas/generated/_ost_tools_schema_meta.json
index 91f01a3..cf494eb 100644
--- a/schemas/generated/_ost_tools_schema_meta.json
+++ b/schemas/generated/_ost_tools_schema_meta.json
@@ -44,6 +44,10 @@
},
"selfRef": {
"type": "boolean"
+ },
+ "selfRefField": {
+ "type": "string",
+ "minLength": 1
}
},
"required": ["type"],
@@ -59,6 +63,52 @@
"required": ["levels"],
"additionalProperties": false
},
+ "relationships": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "parent": {
+ "type": "string",
+ "minLength": 1
+ },
+ "type": {
+ "type": "string",
+ "minLength": 1
+ },
+ "field": {
+ "type": "string",
+ "minLength": 1
+ },
+ "fieldOn": {
+ "enum": ["child", "parent"]
+ },
+ "format": {
+ "enum": ["heading", "list", "table", "page"]
+ },
+ "multi": {
+ "type": "boolean"
+ },
+ "matchers": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ },
+ "minItems": 1
+ },
+ "embeddedTemplateFields": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ },
+ "required": ["parent", "type"],
+ "additionalProperties": false
+ }
+ },
"aliases": {
"type": "object",
"additionalProperties": {
diff --git a/schemas/strict_ost.json b/schemas/strict_ost.json
index 019af9e..4614477 100644
--- a/schemas/strict_ost.json
+++ b/schemas/strict_ost.json
@@ -32,15 +32,12 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" },
{ "$ref": "ost-tools://_ost_strict#/$defs/opportunityProps" }
],
"properties": {
- "type": { "const": "opportunity" },
- "parent": {
- "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink",
- "description": "Parent outcome or opportunity (wikilink). The OST methodology allows nested opportunity hierarchies."
- }
+ "type": { "const": "opportunity" }
},
"required": ["type"],
"additionalProperties": true,
@@ -57,14 +54,11 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
- "type": { "const": "solution" },
- "parent": {
- "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink",
- "description": "Parent opportunity (wikilink). The OST methodology requires solutions connect directly to opportunities."
- }
+ "type": { "const": "solution" }
},
"required": ["type"],
"additionalProperties": true,
@@ -80,15 +74,12 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
+ { "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" },
{ "$ref": "ost-tools://_ost_strict#/$defs/assumptionTestProps" }
],
"properties": {
- "type": { "const": "assumption_test" },
- "parent": {
- "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink",
- "description": "Parent solution (wikilink)"
- }
+ "type": { "const": "assumption_test" }
},
"required": ["type"],
"additionalProperties": true,
diff --git a/skills/ost-tools/SKILL.md b/skills/ost-tools/SKILL.md
index 47d0a52..9560df2 100644
--- a/skills/ost-tools/SKILL.md
+++ b/skills/ost-tools/SKILL.md
@@ -39,9 +39,7 @@ bunx ost-tools readme # full documentation
`spaces` is the starting point β it shows each space as a block with its schema name, `fieldMap`
mappings (if any), template config, and whether Miro is configured.
-`schemas show --space` is the primary schema tool β it lists entity types and their properties (required
-marked with `*`), the hierarchy, all rules with descriptions, definitions with enum values, and the
-loaded schema registry. **Run this before authoring content or writing rules** to ensure you use the correct field names and types. The registry section
+`schemas show --space` is the primary schema tool β it lists entity types and their properties (required marked with `*`), the hierarchy, **adjacent relationships**, all rules with descriptions, definitions with enum values, and the loaded schema registry. **Run this before authoring content or writing rules** to ensure you use the correct field names and types. The registry section
at the bottom shows which bundled and local partials are in scope for `$ref` targets.
`schemas show ` (e.g. `_ost_tools_base.json`) reveals available definitions in bundled
@@ -63,6 +61,20 @@ Run `bunx ost-tools --help` or `bunx ost-tools --help` for flags.
**`dump` is the key debugging tool.** Use it to verify `fieldMap` remapping is working or to
inspect exactly what JSONata rules see when a rule fires unexpectedly.
+## Embedded nodes and Relationships
+
+Embedded nodes are nodes that live physically inside another node's file (via tables or lists) rather than as separate files.
+
+**Adjacent Relationships** determine how these are parsed:
+- **Heading matching:** When a heading matches a relationship `matcher` (case-insensitive or `/regex/`), following tables/lists are parsed as that child type.
+- **Agnostic parsing:** The parser uses the semantic grandparent as the parent for items matched via a relationship heading.
+- **`fieldOn` direction:** Relationships support two link directions. `fieldOn: "child"` (default) sets the relationship field on each child node. `fieldOn: "parent"` instead populates the parent node's array field with `[[Child]]` wikilinks β use this when the content model lists children on the parent (e.g. `activity.tasks`). When `fieldOn: "parent"`, child nodes do not get a `parent` field from the relationship.
+
+**When to use sub-entities:**
+- For fine-grained items like `Assumption`, `Risk`, or `Requirement` that would clutter the filesystem if separate.
+- When you want to group related child nodes under a stable heading in a parent's body.
+- For developing a template page with structured headings and lists or tables to fill in.
+
## Non-obvious issues
**All files appear as "Non-space (no type field)"** β the space uses a different field name for the entity discriminator
diff --git a/skills/ost-tools/references/schema-authoring.md b/skills/ost-tools/references/schema-authoring.md
index 637c2e0..274d563 100644
--- a/skills/ost-tools/references/schema-authoring.md
+++ b/skills/ost-tools/references/schema-authoring.md
@@ -16,6 +16,14 @@ See `~/src/ost-tools/schemas/` for examples (`general.json`, `strict_ost.json`,
],
"allowSkipLevels": false
},
+ "relationships": [
+ {
+ "parent": "opportunity",
+ "type": "assumption",
+ "format": "table",
+ "matchers": ["Assumptions"]
+ }
+ ],
"aliases": { "experiment": "assumption_test" },
"rules": [
{
@@ -38,6 +46,23 @@ Use object entries to override defaults:
- `multiple: true` for array wikilinks
- `selfRef: true` for same-type parent links
+### Adjacent Relationships (`$metadata.relationships`)
+
+Adjacent relationships define how sub-entities (nodes inside other files) are parsed and generated.
+
+| Field | Default | Description |
+|---|---|---|
+| `parent` | required | Parent canonical type |
+| `type` | required | Child canonical type |
+| `field` | `"parent"` | Frontmatter field holding the wikilink(s). Must be explicit when `fieldOn: "parent"`. |
+| `fieldOn` | `"child"` | `"child"`: child has the field pointing up. `"parent"`: parent has an array field pointing down to children. |
+| `format` | | Hint for `template-sync`: `"table"`, `"list"`, or `"heading"` |
+| `matchers` | | Heading text to match (strings or `/regex/`). Case-insensitive. |
+| `multi` | `true` | Whether multiple children are expected |
+| `embeddedTemplateFields` | | Field names for table columns |
+
+**`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`.
+
Rules are a flat array. Categories are labels only (`validation`, `coherence`, `workflow`, `best-practice`).
## Metadata composition semantics
diff --git a/skills/ost-tools/references/schema-design.md b/skills/ost-tools/references/schema-design.md
index a57affb..eebec32 100644
--- a/skills/ost-tools/references/schema-design.md
+++ b/skills/ost-tools/references/schema-design.md
@@ -40,6 +40,21 @@ level object in `$metadata.hierarchy.levels`.
For a mixed graph (some hierarchical, some lateral entities), put the main chain in `hierarchy.levels`
and include lateral types in `oneOf` without hierarchy constraints.
+### 2a. Adjacent Relationships vs Primary Hierarchy
+
+Use **Primary Hierarchy** (`$metadata.hierarchy`) for the main structural backbone of your tree. These links:
+- Drive the tree rendering in `show`.
+- Are typically expressed as separate files or major sections.
+- Are strictly parented.
+
+Use **Adjacent Relationships** (`$metadata.relationships`) for fine-grained sub-entities (e.g., Assumptions, Requirements). These links:
+- Enable **Sub-entities**: multiple nodes that live physically inside their parent's file.
+- Support **Agnostic Parsing**: headings automatically type the following tables or lists.
+- Reduce file sprawl.
+- Are "flattened" in most views, acting more like properties with identity.
+
+**Design Rule:** If a child type has its own detailed properties and lives naturally inside a table or nested list of the parent, model it as an **Adjacent Relationship**.
+
## 3. Handle naming conflicts
**Common problem:** content uses `type` as a sub-classification field AND `record_type` (or similar)
diff --git a/src/commands/schemas.ts b/src/commands/schemas.ts
index 0cfb1f1..49aad09 100644
--- a/src/commands/schemas.ts
+++ b/src/commands/schemas.ts
@@ -1,11 +1,11 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
-import type { AnySchemaObject, SchemaObject } from 'ajv';
+import type { AnySchemaObject } from 'ajv';
import JSON5 from 'json5';
import { loadConfig, resolveSchema } from '../config';
import { buildFullRegistry, bundledSchemasDir, loadMetadata, readRawSchema } from '../schema';
import { mergeVariantProperties } from '../schema-refs';
-import type { SchemaMetadata } from '../types';
+import type { SchemaMetadata, SchemaWithMetadata } from '../types';
function isBundledPath(schemaPath: string): boolean {
return dirname(schemaPath) === bundledSchemasDir;
@@ -37,7 +37,7 @@ interface EntityInfo {
function extractEntities(
oneOf: unknown[],
registry: Map,
- schema: SchemaObject,
+ schema: SchemaWithMetadata,
): EntityVariant[] {
return oneOf.map((entry) => {
const { properties, required } = mergeVariantProperties(entry as AnySchemaObject, schema, registry);
@@ -58,10 +58,10 @@ function extractEntities(
* Extract entity information for ERD generation.
* Returns a flat list of all entity types with their properties.
*/
-function extractEntityInfo(
+export function extractEntityInfo(
oneOf: unknown[],
registry: Map,
- schema: SchemaObject,
+ schema: SchemaWithMetadata,
): EntityInfo[] {
const result: EntityInfo[] = [];
for (const entry of oneOf) {
@@ -86,7 +86,7 @@ function extractEntityInfo(
return result;
}
-function showEntities(oneOf: unknown[], registry: Map, schema: SchemaObject): void {
+function showEntities(oneOf: unknown[], registry: Map, schema: SchemaWithMetadata): void {
const entities = extractEntities(oneOf, registry, schema);
console.log('\nEntities:');
for (const { types, properties, required } of entities) {
@@ -151,6 +151,15 @@ function showMetadata(metadata: SchemaMetadata): void {
}
}
}
+
+ if (metadata.relationships && metadata.relationships.length > 0) {
+ console.log('\nRelationships:');
+ for (const rel of metadata.relationships) {
+ const fields = rel.embeddedTemplateFields?.length ? ` [fields: ${rel.embeddedTemplateFields.join(', ')}]` : '';
+ const matchers = rel.matchers?.length ? ` (matches: ${rel.matchers.join(', ')})` : '';
+ console.log(` ${rel.parent} β ${rel.type} [${rel.format ?? 'page'}]${fields}${matchers}`);
+ }
+ }
}
function showRegistry(schemaPath: string, registry: Map): void {
@@ -296,7 +305,7 @@ export function showSchema(
return;
}
- const schema = JSON5.parse(content) as SchemaObject;
+ const schema = JSON5.parse(content) as SchemaWithMetadata;
const registry = buildFullRegistry(schemaPath) as Map;
// Handle --mermaid-erd: generate ERD and exit
diff --git a/src/commands/template-sync.ts b/src/commands/template-sync.ts
index a566cf9..37ae47b 100644
--- a/src/commands/template-sync.ts
+++ b/src/commands/template-sync.ts
@@ -5,15 +5,18 @@ import { glob } from 'glob';
import matter from 'gray-matter';
import yaml from 'js-yaml';
import { invertFieldMap } from '../config';
+import type { MetadataContractRelationship } from '../metadata-contract';
import { buildFullRegistry, readRawSchema } from '../schema';
import { mergeVariantProperties, resolveRef } from '../schema-refs';
+import type { SchemaWithMetadata } from '../types';
-interface TypeVariant {
+export interface TypeVariant {
required: string[];
optional: string[];
properties: Record;
example: Record;
description: string | undefined;
+ relationships: MetadataContractRelationship[];
}
// Fields derived from the filesystem β present at validation time but not written to frontmatter
@@ -26,7 +29,7 @@ function enumPlaceholder(def: AnySchemaObject): string {
function withEnumPlaceholders(
example: Record,
properties: Record,
- schema: SchemaObject,
+ schema: SchemaWithMetadata,
registry: Map,
): Record {
return Object.fromEntries(
@@ -40,7 +43,7 @@ function withEnumPlaceholders(
function commentedHint(
fieldName: string,
propDef: AnySchemaObject | undefined,
- schema: SchemaObject,
+ schema: SchemaWithMetadata,
registry: Map,
): string {
const def = resolveRef(propDef, schema, registry);
@@ -67,7 +70,10 @@ function commentedHint(
return `# ${fieldName}: ${value}${description ? ` # ${description}` : ''}`;
}
-function getTypeVariants(schema: SchemaObject, registry: Map): Map {
+export function getTypeVariants(
+ schema: SchemaWithMetadata,
+ registry: Map,
+): Map {
const map = new Map();
for (const variant of schema.oneOf) {
const typeName = variant.properties?.type?.const as string;
@@ -84,22 +90,27 @@ function getTypeVariants(schema: SchemaObject, registry: Map;
const description = (variant as { description?: string }).description;
+ const allRelationships = schema.$metadata?.relationships || [];
+ const typeRelationships = allRelationships.filter((rel) => rel.parent === typeName);
+
map.set(typeName, {
required,
optional,
properties: allProperties,
example,
description,
+ relationships: typeRelationships,
});
}
return map;
}
-function generateNewContent(
+export function generateNewContent(
nodeType: string,
variant: TypeVariant,
- schema: SchemaObject,
+ schema: SchemaWithMetadata,
registry: Map,
+ allVariants: Map,
body = '\nTODO\n',
fieldMap: Record = {},
): string {
@@ -143,7 +154,56 @@ function generateNewContent(
const newFrontmatter = hints.length > 0 ? `${frontmatterYaml}\n${hints.join('\n')}` : frontmatterYaml;
- return `---\n${newFrontmatter}\n---${body}`;
+ let relationshipStubs = '';
+ for (const rel of variant.relationships) {
+ const matcher = rel.matchers?.[0] || rel.type;
+ if (body.includes(`### ${matcher}`)) {
+ continue;
+ }
+
+ const childVariant = allVariants.get(rel.type);
+ const childExample = childVariant?.example || {};
+
+ if (rel.format === 'table' && rel.embeddedTemplateFields) {
+ const header = `| ${rel.embeddedTemplateFields.join(' | ')} |`;
+ const sep = `| ${rel.embeddedTemplateFields.map(() => '---|').join('')}`;
+ const exampleValues = rel.embeddedTemplateFields.map((field) => {
+ const val = childExample[field];
+ return val !== undefined ? String(val) : ' ';
+ });
+ const exampleRow = `| ${exampleValues.join(' | ')} |`;
+ relationshipStubs += `\n### ${matcher}\n\n${header}\n${sep}\n${exampleRow}\n`;
+ } else if (rel.format === 'heading') {
+ let stub = `\n### ${matcher}\n\n`;
+ if (childVariant) {
+ // Include inline fields from example if it's a heading
+ const fields = Object.entries(childExample)
+ .filter(([k]) => k !== 'type' && k !== 'title' && k !== 'parent')
+ .map(([k, v]) => `[${k}:: ${v}]`)
+ .join(' ');
+ stub += `${fields}${fields ? ' ' : ''}TODO: Describe ${rel.type}\n`;
+ } else {
+ stub += `TODO: Describe ${rel.type}\n`;
+ }
+ relationshipStubs += stub;
+ } else if (rel.format === 'list') {
+ let stub = `\n### ${matcher}\n\n- [type:: ${rel.type}] `;
+ if (childVariant) {
+ const fields = Object.entries(childExample)
+ .filter(([k]) => k !== 'type' && k !== 'title' && k !== 'parent')
+ .map(([k, v]) => `[${k}:: ${v}]`)
+ .join(' ');
+ stub += `${fields}${fields ? ' ' : ''}TODO`;
+ } else {
+ stub += 'TODO';
+ }
+ relationshipStubs += `${stub}\n`;
+ }
+ }
+
+ const finalBody = relationshipStubs ? `\n${relationshipStubs}${body.trimStart()}` : body;
+
+ return `---\n${newFrontmatter}\n---${finalBody}`;
}
export async function templateSync(
@@ -204,7 +264,7 @@ export async function templateSync(
console.log(`β ${filename}: type "${nodeType}" should be named "${expectedFilename}"`);
}
- const newContent = generateNewContent(nodeType, variant, schema, registry, body, fieldMap);
+ const newContent = generateNewContent(nodeType, variant, schema, registry, typeVariants, body, fieldMap);
if (newContent === content) {
console.log(`β ${filename}`);
@@ -251,7 +311,7 @@ export async function templateSync(
if (options.createMissing) {
for (const type of missingTypes) {
const variant = typeVariants.get(type)!;
- const newContent = generateNewContent(type, variant, schema, registry, undefined, fieldMap);
+ const newContent = generateNewContent(type, variant, schema, registry, typeVariants, undefined, fieldMap);
const newFilename = `${templatePrefix}${type}.md`;
const newFilePath = join(templateDir, newFilename);
diff --git a/src/commands/validate.ts b/src/commands/validate.ts
index 537bd22..a326310 100644
--- a/src/commands/validate.ts
+++ b/src/commands/validate.ts
@@ -1,25 +1,135 @@
import { statSync } from 'node:fs';
import type { ErrorObject } from 'ajv';
+import { classifyNodes } from '../graph-helpers';
import { readSpaceDirectory } from '../read-space-directory';
import { readSpaceOnAPage } from '../read-space-on-a-page';
-import { buildTargetIndex, wikilinkToTarget } from '../resolve-links';
-import { createValidator, loadMetadata } from '../schema';
+import { buildFullRegistry, createValidator, loadMetadata, readRawSchema } from '../schema';
import type { HierarchyViolation, RuleViolation, SpaceNode } from '../types';
-import { validateHierarchy } from '../validate-hierarchy';
+import { validateHierarchyWithFields, validateRelationships } from '../validate-hierarchy';
import { validateRules } from '../validate-rules';
+import { buildTargetIndex } from '../wikilink-utils';
+import { extractEntityInfo } from './schemas';
+
+interface FormattedError {
+ message: string;
+ dedupeKey: string;
+}
interface ValidationResult {
validCount: number;
nodeErrorCount: number;
- nodeErrors: Array<{ file: string; errors: ErrorObject[] }>;
+ nodeErrors: Array<{ file: string; errors: ErrorObject[]; nodeData: Record }>;
refErrors: Array<{ file: string; parent: string; error: string }>;
duplicateErrors: Array<{ title: string; files: string[] }>;
ruleViolations: RuleViolation[];
hierarchyViolations: HierarchyViolation[];
+ orphanCount: number;
skipped: string[];
nonSpace: string[];
}
+/**
+ * Format AJV errors for better readability.
+ * Groups related errors and extracts helpful context like allowed values.
+ */
+function formatErrors(errors: ErrorObject[], schemaPath: string, nodeData: Record): FormattedError[] {
+ const formatted: FormattedError[] = [];
+ const seen = new Set();
+
+ // Group errors by instancePath
+ const byPath = new Map();
+ for (const err of errors) {
+ const path = err.instancePath || 'root';
+ if (!byPath.has(path)) {
+ byPath.set(path, []);
+ }
+ byPath.get(path)!.push(err);
+ }
+
+ for (const [path, pathErrors] of byPath) {
+ // Check if this is a oneOf failure at root - extract valid types from schema
+ const isRootOneOf = path === 'root' || path === '/type';
+ let hasOneOfContext = false;
+ if (isRootOneOf && pathErrors.length > 1) {
+ const schema = readRawSchema(schemaPath);
+ hasOneOfContext = Array.isArray(schema.oneOf);
+
+ if (hasOneOfContext) {
+ const registry = buildFullRegistry(schemaPath);
+ const entities = extractEntityInfo(schema.oneOf as unknown[], registry, schema);
+ const validTypes = entities.map((e) => e.type).sort();
+
+ if (validTypes.length > 0) {
+ const actualValue = nodeData.type;
+ // Only show type error if the actual type is NOT in the valid types list
+ if (actualValue !== undefined && !validTypes.includes(String(actualValue))) {
+ const message = `Invalid type "${actualValue}". Valid types are: ${validTypes.join(', ')}`;
+ const key = `type:${validTypes.join(',')}`;
+ if (!seen.has(key)) {
+ seen.add(key);
+ formatted.push({ message, dedupeKey: key });
+ }
+ }
+ }
+ }
+ }
+
+ // Handle individual errors
+ for (const err of pathErrors) {
+ // Skip individual type const/enum errors when in oneOf context (we handle it above)
+ if (
+ hasOneOfContext &&
+ (err.keyword === 'const' || err.keyword === 'enum') &&
+ (err.instancePath === '' || err.instancePath === '/type')
+ ) {
+ continue;
+ }
+
+ const parts = err.instancePath.split('/').filter(Boolean);
+ const fieldName = parts.length > 0 ? parts[parts.length - 1]! : 'root';
+
+ let message = err.message;
+ let key = `${err.instancePath}:${err.keyword}`;
+
+ // Enhance const errors
+ if (err.keyword === 'const' && err.params?.allowedValue !== undefined) {
+ const actual = err.data !== undefined ? `"${err.data}"` : 'missing value';
+ const expected = `"${err.params.allowedValue}"`;
+ message = `${fieldName}: expected ${expected}, got ${actual}`;
+ key = `${err.instancePath}:const:${err.params.allowedValue}`;
+ }
+ // Enhance enum errors
+ else if (err.keyword === 'enum' && err.params?.allowedValues && Array.isArray(err.params.allowedValues)) {
+ let actual = err.data !== undefined ? `"${err.data}"` : null;
+ // If err.data is undefined, try to get the value from nodeData using the field name
+ if (actual === null && fieldName !== 'root') {
+ const actualValue = nodeData[fieldName];
+ if (actualValue !== undefined) {
+ actual = `"${actualValue}"`;
+ }
+ }
+ if (actual === null) {
+ actual = 'missing value';
+ }
+ const allowed = err.params.allowedValues.map((v: unknown) => `"${v}"`).join(', ');
+ message = `${fieldName}: ${actual} is not valid. Allowed: ${allowed}`;
+ key = `${err.instancePath}:enum:${err.params.allowedValues.join(',')}`;
+ }
+ // Generic message with path
+ else {
+ message = `${fieldName}: ${err.message}`;
+ }
+
+ if (!seen.has(key)) {
+ seen.add(key);
+ formatted.push({ message, dedupeKey: key });
+ }
+ }
+ }
+
+ return formatted;
+}
+
export async function validate(path: string, options: { schema: string; templateDir?: string }): Promise {
const validateFunc = createValidator(options.schema);
@@ -48,6 +158,7 @@ export async function validate(path: string, options: { schema: string; template
duplicateErrors: [],
ruleViolations: [],
hierarchyViolations: [],
+ orphanCount: 0,
skipped,
nonSpace: nonSpace,
};
@@ -65,6 +176,7 @@ export async function validate(path: string, options: { schema: string; template
result.nodeErrors.push({
file: node.label,
errors: validateFunc.errors || [],
+ nodeData: node.schemaData as Record,
});
}
}
@@ -85,81 +197,19 @@ export async function validate(path: string, options: { schema: string; template
}
}
- // Build targetIndex for link validation
+ // Validate all hierarchy constraints (field references and structure)
const linkTargetIndex = buildTargetIndex(nodes);
- const levels = metadata.hierarchy?.levels ?? [];
-
- // Validate edge field references for each hierarchy level
- for (let i = 1; i < levels.length; i++) {
- const level = levels[i]!;
- const parentLevel = levels[i - 1]!;
-
- // Determine which nodes to check based on who has the field
- const nodesToCheck =
- level.fieldOn === 'parent'
- ? nodes.filter((n) => n.resolvedType === parentLevel.type)
- : nodes.filter((n) => n.resolvedType === level.type);
-
- for (const node of nodesToCheck) {
- const rawField = node.schemaData[level.field];
- if (rawField === undefined || rawField === null) continue;
-
- if (level.multiple) {
- if (!Array.isArray(rawField)) {
- result.refErrors.push({
- file: node.label,
- parent: String(rawField),
- error: `Field "${level.field}" must be an array of wikilinks, got ${typeof rawField}`,
- });
- continue;
- }
- for (const ref of rawField) {
- if (typeof ref !== 'string') continue;
- const target = wikilinkToTarget(ref);
- const resolved = linkTargetIndex.get(target);
- if (resolved === undefined) {
- result.refErrors.push({
- file: node.label,
- parent: ref,
- error: `Link target "${target}" in field "${level.field}" not found`,
- });
- } else if (resolved === null) {
- result.refErrors.push({
- file: node.label,
- parent: ref,
- error: `Link target "${target}" in field "${level.field}" is ambiguous (matches multiple nodes)`,
- });
- }
- }
- } else {
- if (typeof rawField !== 'string') {
- result.refErrors.push({
- file: node.label,
- parent: String(rawField),
- error: `Field "${level.field}" must be a wikilink string, got ${typeof rawField}`,
- });
- continue;
- }
- const target = wikilinkToTarget(rawField);
- const resolved = linkTargetIndex.get(target);
- if (resolved === undefined) {
- result.refErrors.push({
- file: node.label,
- parent: rawField,
- error: `Link target "${target}" in field "${level.field}" not found`,
- });
- } else if (resolved === null) {
- result.refErrors.push({
- file: node.label,
- parent: rawField,
- error: `Link target "${target}" in field "${level.field}" is ambiguous (matches multiple nodes)`,
- });
- }
- }
- }
- }
+ const hierarchyValidation = validateHierarchyWithFields(nodes, metadata);
+ const relValidation = validateRelationships(nodes, metadata, linkTargetIndex);
+
+ result.refErrors.push(...hierarchyValidation.refErrors, ...relValidation.refErrors);
+ result.hierarchyViolations = [...hierarchyValidation.violations, ...relValidation.violations];
- result.hierarchyViolations = validateHierarchy(nodes, metadata);
+ // Calculate orphan count (informational, not a validation error)
+ if (metadata.hierarchy) {
+ const classification = classifyNodes(nodes, metadata.hierarchy.levels);
+ result.orphanCount = classification.orphans.length;
+ }
// Load and execute rules validation if schema defines rules
if (metadata.rules) {
@@ -167,46 +217,72 @@ export async function validate(path: string, options: { schema: string; template
}
// Report
- console.log(`\nπ Space Validation Results`);
- console.log(`β`.repeat(50));
- console.log(`β
Valid: ${result.validCount}`);
- console.log(`β Node Errors: ${result.nodeErrorCount}`);
- console.log(`π Reference Errors: ${result.refErrors.length}`);
- console.log(`π Duplicate Keys: ${result.duplicateErrors.length}`);
- console.log(`π Rule Violations: ${result.ruleViolations.length}`);
- console.log(`ποΈ Hierarchy Violations: ${result.hierarchyViolations.length}`);
- console.log(`β Skipped (no frontmatter): ${result.skipped.length}`);
- console.log(`π Non-space (no type field): ${result.nonSpace.length}`);
+ const reset = '\x1b[0m';
+ const green = '\x1b[32m';
+ const red = '\x1b[31m';
+ const yellow = '\x1b[33m';
+
+ const colorFor = (count: number, isWarning: boolean): string => {
+ if (count === 0) return green;
+ return isWarning ? yellow : red;
+ };
+
+ const fmt = (label: string, count: number, isError = false, isWarning = false): string => {
+ let color: string;
+ if (isError) {
+ color = colorFor(count, isWarning);
+ } else {
+ // For non-error items (like "Valid"), green if count > 0, red if 0
+ color = count > 0 ? green : red;
+ }
+ const countStr = String(count).padStart(3);
+ return `${label.padEnd(40)} ${color}${countStr}${reset}`;
+ };
+
+ console.log(`\nSpace Validation Results`);
+ console.log(`β`.repeat(45));
+ console.log('Content and structure');
+ console.log(fmt(' Valid', result.validCount));
+ console.log(fmt(' Schema validation errors', result.nodeErrorCount, true));
+ console.log(fmt(' Broken links', result.refErrors.length, true));
+ console.log(fmt(' Duplicate keys', result.duplicateErrors.length, true));
+ console.log(fmt(' Rule violations', result.ruleViolations.length, true));
+ console.log(fmt(' Hierarchy violations', result.hierarchyViolations.length, true));
+ console.log(fmt(' Orphans (hierarchy nodes - no parent)', result.orphanCount, true, true));
+ console.log('Skipped');
+ console.log(fmt(' No frontmatter', result.skipped.length, true, true));
+ console.log(fmt(' No type field', result.nonSpace.length, true, true));
if (result.skipped.length > 0) {
- console.log(`\nβ Skipped files (no frontmatter):`);
+ console.log(`\nSkipped files (no frontmatter):`);
for (const f of result.skipped) console.log(` ${f}`);
}
if (result.nonSpace.length > 0) {
- console.log(`\nπ Non-space files (no type field):`);
+ console.log(`\nNon-space files (no type field):`);
for (const f of result.nonSpace) console.log(` ${f}`);
}
if (result.nodeErrors.length > 0) {
- console.log(`\nβ Node errors:`);
- result.nodeErrors.forEach(({ file, errors }) => {
+ console.log(`\nSchema validation errors:`);
+ result.nodeErrors.forEach(({ file, errors, nodeData }) => {
console.log(`\n ${file}:`);
- errors.forEach((err: ErrorObject) => {
- console.log(` ${err.instancePath || 'root'}: ${err.message}`);
+ const formatted = formatErrors(errors, options.schema, nodeData);
+ formatted.forEach(({ message }) => {
+ console.log(` ${message}`);
});
});
}
if (result.refErrors.length > 0) {
- console.log(`\nπ Reference errors (dangling links):`);
+ console.log(`\nBroken links:`);
result.refErrors.forEach(({ file, parent, error }) => {
console.log(` ${file}: ${parent} β ${error}`);
});
}
if (result.duplicateErrors.length > 0) {
- console.log(`\nπ Duplicate node keys (same title in multiple files):`);
+ console.log(`\nDuplicate keys (same title in multiple files):`);
result.duplicateErrors.forEach(({ title, files }) => {
console.log(` "${title}":`);
for (const f of files) {
@@ -216,7 +292,7 @@ export async function validate(path: string, options: { schema: string; template
}
if (result.ruleViolations.length > 0) {
- console.log(`\nπ Rule violations:`);
+ console.log(`\nRule violations:`);
// Group by category
const byCategory = new Map();
@@ -237,7 +313,7 @@ export async function validate(path: string, options: { schema: string; template
}
if (result.hierarchyViolations.length > 0) {
- console.log(`\nποΈ Hierarchy violations:`);
+ console.log(`\nHierarchy violations:`);
for (const v of result.hierarchyViolations) {
console.log(` ${v.file}: ${v.description}`);
}
diff --git a/src/index.ts b/src/index.ts
index f74d570..89bdb18 100755
--- a/src/index.ts
+++ b/src/index.ts
@@ -68,7 +68,16 @@ program
// Save cursor position after header (for clearing later)
process.stdout.write('\x1b[s');
- let exitCode = await validate(spacePath, { schema: schemaPath, templateDir });
+ let exitCode = 0;
+ const innerValidate = async () => {
+ try {
+ exitCode = await validate(spacePath, { schema: schemaPath, templateDir });
+ } catch (error) {
+ console.error(`β Error during validation: ${error instanceof Error ? error.message : String(error)}`);
+ exitCode = 1;
+ }
+ };
+ await innerValidate();
// Collect paths to watch (all config files, schema dirs, and space path)
const watchPaths = [...configFiles, ...schemaDirs, spacePath];
@@ -82,12 +91,16 @@ program
},
});
- watcher.on('change', async (changedPath) => {
+ const handleFileChange = async (filePath: string, action: string) => {
// Restore cursor to header position and clear everything below
process.stdout.write('\x1b[u\x1b[0J');
- console.log(`π ${changedPath} changed, re-validating...\n`);
- exitCode = await validate(spacePath, { schema: schemaPath, templateDir });
- });
+ console.log(`π ${filePath} ${action}, re-validating...\n`);
+ await innerValidate();
+ };
+
+ watcher.on('add', (path) => handleFileChange(path, 'added'));
+ watcher.on('change', (path) => handleFileChange(path, 'changed'));
+ watcher.on('unlink', (path) => handleFileChange(path, 'removed'));
watcher.on('error', (error) => {
console.error(`Watcher error: ${error}`);
@@ -172,7 +185,7 @@ spacesCmd
.action(listSpaces);
program.addCommand(spacesCmd);
-const schemasCmd = new Command('schemas').description('List and inspect schemas');
+const schemasCmd = new Command('schemas').alias('schema').description('List and inspect schemas');
schemasCmd
.command('list', { isDefault: true })
.description('List available schemas')
diff --git a/src/metadata-contract.ts b/src/metadata-contract.ts
index 91f96f4..3b095b3 100644
--- a/src/metadata-contract.ts
+++ b/src/metadata-contract.ts
@@ -11,6 +11,7 @@ const HIERARCHY_LEVEL_SCHEMA = {
fieldOn: { enum: ['child', 'parent'] },
multiple: { type: 'boolean' },
selfRef: { type: 'boolean' },
+ selfRefField: { type: 'string', minLength: 1 },
},
required: ['type'],
additionalProperties: false,
@@ -40,6 +41,29 @@ const RULE_REF_SCHEMA = {
additionalProperties: false,
} as const;
+const RELATIONSHIP_SCHEMA = {
+ type: 'object',
+ properties: {
+ parent: { type: 'string', minLength: 1 },
+ type: { type: 'string', minLength: 1 },
+ field: { type: 'string', minLength: 1 },
+ fieldOn: { enum: ['child', 'parent'] },
+ format: { enum: ['heading', 'list', 'table', 'page'] },
+ multi: { type: 'boolean' },
+ matchers: {
+ type: 'array',
+ items: { type: 'string', minLength: 1 },
+ minItems: 1,
+ },
+ embeddedTemplateFields: {
+ type: 'array',
+ items: { type: 'string', minLength: 1 },
+ },
+ },
+ required: ['parent', 'type'],
+ additionalProperties: false,
+} as const;
+
export const OST_TOOLS_METADATA_SCHEMA = {
type: 'object',
properties: {
@@ -58,6 +82,10 @@ export const OST_TOOLS_METADATA_SCHEMA = {
required: ['levels'],
additionalProperties: false,
},
+ relationships: {
+ type: 'array',
+ items: RELATIONSHIP_SCHEMA,
+ },
aliases: {
type: 'object',
additionalProperties: { type: 'string', minLength: 1 },
@@ -91,3 +119,4 @@ export type MetadataContractRuleRef = FromSchema;
export type MetadataContractRules = Exclude;
export type MetadataContractRuleEntry = MetadataContractRules[number];
export type MetadataContractResolvedRules = MetadataContractRule[];
+export type MetadataContractRelationship = FromSchema;
diff --git a/src/parse-embedded.ts b/src/parse-embedded.ts
index 606523c..b6b5e41 100644
--- a/src/parse-embedded.ts
+++ b/src/parse-embedded.ts
@@ -1,10 +1,11 @@
import { load as yamlLoad } from 'js-yaml';
-import type { Code, Heading, List, ListItem, Paragraph, Root } from 'mdast';
+import type { Code, Heading, List, ListItem, Paragraph, Root, Table, TableRow } from 'mdast';
import { toString as mdastToString } from 'mdast-util-to-string';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { applyFieldMap } from './config';
+import type { MetadataContractRelationship } from './metadata-contract';
import { resolveNodeType } from './schema';
import type { SpaceNode, SpaceOnAPageDiagnostics } from './types';
@@ -80,13 +81,29 @@ export function extractAnchor(text: string): { cleanText: string; anchor?: strin
* If the anchor name exactly matches a space node type (optionally followed by digits),
* return that type. Otherwise return undefined.
* Examples: "mission" -> "mission", "goal1" -> "goal", "myanchor" -> undefined
+ *
+ * Also checks relationship types (for parent-side relationships where child type may not be in hierarchy).
*/
-export function anchorToNodeType(anchor: string, hierarchy: readonly string[]): string | undefined {
+export function anchorToNodeType(
+ anchor: string,
+ hierarchy: readonly string[],
+ relationships?: MetadataContractRelationship[],
+): string | undefined {
for (const type of hierarchy) {
if (anchor === type || new RegExp(`^${type}\\d+$`).test(anchor)) {
return type;
}
}
+
+ // Check relationship types (for parent-side relationships)
+ if (relationships) {
+ for (const rel of relationships) {
+ if (anchor === rel.type || new RegExp(`^${rel.type}\\d+$`).test(anchor)) {
+ return rel.type;
+ }
+ }
+ }
+
return undefined;
}
@@ -131,6 +148,8 @@ function processListItem(
buildLinkTargets: (title: string) => string[],
typeAliases: Record,
fieldMap?: Record,
+ pendingType?: string,
+ parentFieldAppend?: { node: SpaceNode; field: string },
): void {
const firstPara = item.children.find((c) => c.type === 'paragraph') as Paragraph | undefined;
@@ -143,18 +162,20 @@ function processListItem(
const { cleanText, fields: rawFields } = extractBracketedFields(rawText);
const fields = applyFieldMap(rawFields, fieldMap) as Record;
- if (fields.type) {
+ const type = fields.type ?? pendingType;
+
+ if (type) {
const dashIdx = cleanText.indexOf(' - ');
const title = (dashIdx >= 0 ? cleanText.slice(0, dashIdx) : cleanText).trim();
const summary = dashIdx >= 0 ? cleanText.slice(dashIdx + 3).trim() : undefined;
const schemaData: Record = {
title,
- type: fields.type,
+ type,
status: DEFAULT_STATUS,
...fields,
};
- if (parentRef) schemaData.parent = parentRef;
+ if (parentRef && !parentFieldAppend) schemaData.parent = parentRef;
if (summary) schemaData.summary = summary;
const linkTargets = buildLinkTargets(title);
@@ -163,15 +184,46 @@ function processListItem(
schemaData,
linkTargets,
resolvedParents: [],
- resolvedType: resolveNodeType(fields.type, typeAliases),
+ resolvedType: resolveNodeType(type, typeAliases),
};
nodes.push(newNode);
+ if (parentFieldAppend) {
+ const linkRef = `[[${linkTargets[0] ?? title}]]`;
+ const fieldName = parentFieldAppend.field;
+ const fieldValue = parentFieldAppend.node.schemaData[fieldName];
+
+ if (fieldValue === undefined) {
+ // Field doesn't exist yet - create new array
+ parentFieldAppend.node.schemaData[fieldName] = [linkRef];
+ } else if (Array.isArray(fieldValue)) {
+ // Field is already an array - append to it
+ fieldValue.push(linkRef);
+ } else {
+ // Field exists but is not an array - this is an error
+ throw new Error(
+ `Cannot append child link to field '${fieldName}' on node '${parentFieldAppend.node.label}': ` +
+ `field exists but is not an array (found ${typeof fieldValue}). ` +
+ `Child link: ${linkRef}`,
+ );
+ }
+ }
+
const nestedParentRef = `[[${linkTargets[0] ?? title}]]`;
for (const child of item.children) {
if (child.type === 'list') {
for (const subItem of (child as List).children) {
- processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap);
+ processListItem(
+ subItem,
+ nestedParentRef,
+ newNode,
+ nodes,
+ makeLabel,
+ buildLinkTargets,
+ typeAliases,
+ fieldMap,
+ pendingType,
+ );
}
}
}
@@ -208,6 +260,10 @@ export interface ExtractEmbeddedOptions {
* Example: { "record_type": "type" } renames `record_type` to `type` in extracted data.
*/
fieldMap?: Record;
+ /**
+ * Relationship definitions from schema metadata to determine sub-entity types and matcher rules.
+ */
+ relationships?: MetadataContractRelationship[];
}
export interface ExtractEmbeddedResult {
@@ -222,7 +278,7 @@ export interface ExtractEmbeddedResult {
* (directory) to find embedded sub-nodes within a page's content.
*/
export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptions): ExtractEmbeddedResult {
- const { pageTitle, pageType, hierarchy, typeAliases = {}, fieldMap } = options;
+ const { pageTitle, pageType, hierarchy, typeAliases = {}, fieldMap, relationships = [] } = options;
const isOnAPageMode = pageType === undefined || ON_A_PAGE_TYPES.includes(pageType);
const nodes: SpaceNode[] = [];
@@ -244,8 +300,6 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio
? [{ depth: 0, title: pageTitle, nodeType: pageType, refTarget: pageTitle }]
: [];
- let currentContextNode: SpaceNode = rootNode;
-
type ParseState = 'preamble' | 'active' | 'done';
let parseState: ParseState = 'preamble';
@@ -254,6 +308,36 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio
terminatedHeadings: [],
};
+ function getParentContextType(): string | undefined {
+ for (let i = stack.length - 1; i >= 0; i--) {
+ if (stack[i]!.nodeType) return stack[i]!.nodeType;
+ }
+ return undefined;
+ }
+
+ function matchRelationship(title: string, parentType: string | undefined): MetadataContractRelationship | undefined {
+ if (!parentType) return undefined;
+ const lowerTitle = title.toLowerCase();
+ for (const rel of relationships) {
+ if (rel.parent === parentType) {
+ for (const matcher of rel.matchers || []) {
+ if (matcher.startsWith('^') && matcher.endsWith('$')) {
+ if (new RegExp(matcher, 'i').test(title)) return rel;
+ } else if (matcher.startsWith('/') && matcher.endsWith('/')) {
+ const pattern = matcher.slice(1, -1);
+ if (new RegExp(pattern, 'i').test(title)) return rel;
+ } else if (lowerTitle === matcher.toLowerCase()) {
+ return rel;
+ }
+ }
+ if (lowerTitle === rel.type.toLowerCase()) {
+ return rel; // fallback implicit match
+ }
+ }
+ }
+ return undefined;
+ }
+
function makeLabel(title: string): string {
return title;
}
@@ -297,6 +381,9 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio
return normalized ? [`${pageTitle}#${normalized}`] : [title];
}
+ let pendingRelationship: MetadataContractRelationship | undefined;
+ let currentActiveNode: SpaceNode = rootNode;
+
for (const child of tree.children) {
if (parseState === 'done') {
if (child.type === 'heading') {
@@ -325,20 +412,21 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio
const inlineFields = applyFieldMap(rawInlineFields, fieldMap) as Record;
const { cleanText: title, anchor } = extractAnchor(afterBracketed);
- const anchorType = anchor ? anchorToNodeType(anchor, hierarchy) : undefined;
+ const parentContextType = getParentContextType();
+ const anchorType = anchor ? anchorToNodeType(anchor, hierarchy, relationships) : undefined;
+ const relationshipMatch = matchRelationship(title, parentContextType);
const hasExplicitType = !!inlineFields.type;
- const hasImpliedType = !!anchorType;
+ const hasImpliedType = !!anchorType || !!relationshipMatch;
if (!isOnAPageMode && !hasExplicitType && !hasImpliedType) {
- // Untyped heading in typed-page mode: update depth stack but don't create a node.
while (stack.length > 0 && stack[stack.length - 1]!.depth >= depth) {
stack.pop();
}
stack.push({ depth, title, nodeType: '', refTarget: title });
+ pendingRelationship = undefined;
continue;
}
- // In space_on_a_page mode, enforce the no-level-skip rule.
if (isOnAPageMode && stack.length > 0) {
const topDepth = stack[stack.length - 1]!.depth;
if (depth > topDepth + 1) {
@@ -350,7 +438,7 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio
stack.pop();
}
- const type = inlineFields.type ?? anchorType ?? defaultNodeType(stack, hierarchy);
+ const type = inlineFields.type ?? anchorType ?? relationshipMatch?.type ?? defaultNodeType(stack, hierarchy);
const parentRef = currentParentRef();
const schemaData: Record = {
@@ -369,8 +457,20 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio
resolvedParents: [],
resolvedType: resolveNodeType(type, typeAliases),
};
- nodes.push(headingNode);
- currentContextNode = headingNode;
+
+ // If this match came from a relationship and no explicit type was given,
+ // delay adding the node until we see the following content (agnostic parsing).
+ if (!hasExplicitType && !anchorType && relationshipMatch) {
+ pendingRelationship = relationshipMatch;
+ // Optimization: if it's multi:false, we might still want the heading node if it has text body.
+ // We set currentActiveNode to headingNode so if text follows, it goes there.
+ // But if a table follows, we'll use the parent's context.
+ currentActiveNode = headingNode;
+ } else {
+ nodes.push(headingNode);
+ currentActiveNode = headingNode;
+ pendingRelationship = undefined;
+ }
const refTarget = linkTargets[0] ?? title;
stack.push({ depth, title, nodeType: type, refTarget });
@@ -378,57 +478,202 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio
diagnostics.preambleNodeCount++;
} else if (child.type === 'list') {
const parentRef = currentParentRef();
- for (const item of (child as List).children) {
- processListItem(
- item,
- parentRef,
- currentContextNode,
- nodes,
- makeLabel,
- buildListItemLinkTargets,
- typeAliases,
- fieldMap,
- );
+ const list = child as List;
+
+ if (pendingRelationship) {
+ // Grandparent is the true semantic parent for relationship-driven items
+ let semanticParentRef = parentRef;
+ let semanticParentNode: SpaceNode | undefined;
+ for (let i = stack.length - 2; i >= 0; i--) {
+ if (stack[i]!.nodeType !== '') {
+ semanticParentRef = `[[${stack[i]!.refTarget}]]`;
+ const refTarget = stack[i]!.refTarget;
+ semanticParentNode = nodes.find((n) => n.linkTargets.includes(refTarget));
+ break;
+ }
+ }
+
+ const isParentSide = pendingRelationship.fieldOn === 'parent';
+ const parentFieldAppend =
+ isParentSide && semanticParentNode && pendingRelationship.field
+ ? { node: semanticParentNode, field: pendingRelationship.field }
+ : undefined;
+
+ for (const item of list.children) {
+ processListItem(
+ item,
+ isParentSide ? undefined : semanticParentRef,
+ currentActiveNode,
+ nodes,
+ makeLabel,
+ buildListItemLinkTargets,
+ typeAliases,
+ fieldMap,
+ pendingRelationship.type,
+ parentFieldAppend,
+ );
+ }
+ pendingRelationship = undefined;
+ } else {
+ for (const item of list.children) {
+ processListItem(
+ item,
+ parentRef,
+ currentActiveNode,
+ nodes,
+ makeLabel,
+ buildListItemLinkTargets,
+ typeAliases,
+ fieldMap,
+ );
+ }
}
- } else if (child.type === 'paragraph') {
- const rawText = mdastToString(child);
- const { cleanText: afterBracketed, fields: bracketedFields } = extractBracketedFields(rawText);
- const { remainingText, fields: unbracketedFields } = extractUnbracketedFields(afterBracketed);
-
- const allFields = applyFieldMap({ ...unbracketedFields, ...bracketedFields }, fieldMap);
- if ('type' in allFields) {
- const title = currentContextNode.schemaData.title as string | undefined;
- throw new Error(
- `Type override via paragraph field is not supported at "${title ?? currentContextNode.label}". ` +
- `Put [type:: ${(allFields as Record).type}] directly in the heading text.`,
- );
+ } else if (child.type === 'table') {
+ const parentRef = currentParentRef();
+ const parentContextType = getParentContextType();
+ const table = child as Table;
+
+ if (table.children && table.children.length > 0) {
+ const headerRow = table.children[0] as TableRow;
+ const rows = table.children.slice(1);
+ const columnNames = headerRow.children.map((cell) => mdastToString(cell).trim());
+ const firstColName = columnNames[0]?.toLowerCase();
+
+ let rowTypeStr: string | undefined;
+
+ if (pendingRelationship) {
+ rowTypeStr = pendingRelationship.type;
+ } else if (firstColName) {
+ if (hierarchy.includes(firstColName) || typeAliases[firstColName]) {
+ rowTypeStr = firstColName;
+ } else {
+ const rootRel = matchRelationship(firstColName, parentContextType);
+ if (rootRel) rowTypeStr = rootRel.type;
+ }
+ }
+
+ if (!rowTypeStr && currentActiveNode !== rootNode && currentActiveNode.schemaData.type) {
+ const contextAsParentRel = matchRelationship(firstColName || '', currentActiveNode.schemaData.type as string);
+ if (contextAsParentRel) rowTypeStr = contextAsParentRel.type;
+ }
+
+ if (rowTypeStr) {
+ let semanticParentRef = parentRef;
+ let semanticParentNode: SpaceNode | undefined;
+ if (pendingRelationship || rowTypeStr === parentContextType) {
+ for (let i = stack.length - 2; i >= 0; i--) {
+ if (stack[i]!.nodeType !== '') {
+ semanticParentRef = `[[${stack[i]!.refTarget}]]`;
+ const refTarget = stack[i]!.refTarget;
+ semanticParentNode = nodes.find((n) => n.linkTargets.includes(refTarget));
+ break;
+ }
+ }
+ }
+
+ const isParentSide = pendingRelationship?.fieldOn === 'parent';
+ const tableParentFieldAppend =
+ isParentSide && semanticParentNode && pendingRelationship?.field
+ ? { node: semanticParentNode, field: pendingRelationship.field }
+ : undefined;
+
+ for (const row of rows) {
+ const cells = row.children;
+ if (!cells || cells.length === 0) continue;
+
+ const titleRaw = mdastToString(cells[0]).trim();
+ const { cleanText: title, fields: rawInlineFields } = extractBracketedFields(titleRaw);
+ const inlineFields = applyFieldMap(rawInlineFields, fieldMap) as Record;
+
+ const schemaData: Record = {
+ title,
+ type: rowTypeStr,
+ status: DEFAULT_STATUS,
+ ...inlineFields,
+ };
+ if (semanticParentRef && !tableParentFieldAppend) schemaData.parent = semanticParentRef;
+
+ for (let i = 1; i < columnNames.length; i++) {
+ const colName = columnNames[i]!;
+ const cellContent = i < cells.length ? mdastToString(cells[i]).trim() : '';
+ if (colName && cellContent) {
+ const mappedColName = fieldMap?.[colName] ?? colName;
+ schemaData[mappedColName] = cellContent;
+ }
+ }
+
+ const linkTargets = buildListItemLinkTargets(title);
+ const rowNode: SpaceNode = {
+ label: makeLabel(title),
+ schemaData,
+ linkTargets,
+ resolvedParents: [],
+ resolvedType: resolveNodeType(rowTypeStr, typeAliases),
+ };
+ nodes.push(rowNode);
+
+ if (tableParentFieldAppend) {
+ const linkRef = `[[${linkTargets[0] ?? title}]]`;
+ const fieldName = tableParentFieldAppend.field;
+ const fieldValue = tableParentFieldAppend.node.schemaData[fieldName];
+
+ if (fieldValue === undefined) {
+ // Field doesn't exist yet - create new array
+ tableParentFieldAppend.node.schemaData[fieldName] = [linkRef];
+ } else if (Array.isArray(fieldValue)) {
+ // Field is already an array - append to it
+ fieldValue.push(linkRef);
+ } else {
+ // Field exists but is not an array - this is an error
+ throw new Error(
+ `Cannot append child link to field '${fieldName}' on node '${tableParentFieldAppend.node.label}': ` +
+ `field exists but is not an array (found ${typeof fieldValue}). ` +
+ `Child link: ${linkRef}`,
+ );
+ }
+ }
+ }
+ pendingRelationship = undefined;
+ } else {
+ appendContent(currentActiveNode, mdastToString(child));
+ }
+ }
+ } else {
+ // For any other content (paragraph, code, etc), if we had a pending relationship,
+ // it means the heading itself is the node. Add it now.
+ if (pendingRelationship) {
+ nodes.push(currentActiveNode);
+ pendingRelationship = undefined;
}
- Object.assign(currentContextNode.schemaData, allFields);
- if (remainingText) appendContent(currentContextNode, remainingText);
- } else if (child.type === 'code') {
- const code = child as Code;
- if (code.lang?.trim() === 'yaml') {
- const parsed = yamlLoad(code.value);
+ if (child.type === 'paragraph') {
+ const rawText = mdastToString(child);
+ const { cleanText: afterBracketed, fields: bracketedFields } = extractBracketedFields(rawText);
+ const { remainingText, fields: unbracketedFields } = extractUnbracketedFields(afterBracketed);
+ const allFields = applyFieldMap({ ...unbracketedFields, ...bracketedFields }, fieldMap);
- if (Array.isArray(parsed)) {
+ if ('type' in allFields) {
throw new Error(
- `YAML block must be an object (key-value properties for the current node), not an array. ` +
- `Use typed bullets - e.g. "- [type:: solution] Title" - to define child nodes inline.`,
+ `Type override via paragraph field is not supported at "${currentActiveNode.schemaData.title ?? currentActiveNode.label}". ` +
+ `Put [type:: ${(allFields as Record).type}] directly in the heading text.`,
);
}
- if (parsed && typeof parsed === 'object') {
- Object.assign(currentContextNode.schemaData, applyFieldMap(parsed as Record, fieldMap));
+ Object.assign(currentActiveNode.schemaData, allFields);
+ if (remainingText) appendContent(currentActiveNode, remainingText);
+ } else if (child.type === 'code' && (child as Code).lang?.trim() === 'yaml') {
+ const code = child as Code;
+ const parsed = yamlLoad(code.value);
+ if (parsed && !Array.isArray(parsed) && typeof parsed === 'object') {
+ Object.assign(currentActiveNode.schemaData, applyFieldMap(parsed as Record, fieldMap));
+ } else if (Array.isArray(parsed)) {
+ throw new Error(`YAML block must be an object at "${currentActiveNode.label}".`);
} else {
- appendContent(currentContextNode, code.value);
+ appendContent(currentActiveNode, code.value);
}
} else {
- appendContent(currentContextNode, code.value);
+ appendContent(currentActiveNode, mdastToString(child));
}
- } else {
- const text = mdastToString(child);
- appendContent(currentContextNode, text);
}
}
diff --git a/src/read-space-directory.ts b/src/read-space-directory.ts
index 70a416c..6ef9061 100644
--- a/src/read-space-directory.ts
+++ b/src/read-space-directory.ts
@@ -4,7 +4,7 @@ import { glob } from 'glob';
import matter from 'gray-matter';
import { applyFieldMap, loadConfig, resolveSchema } from './config';
import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded';
-import { resolveLinks } from './resolve-links';
+import { resolveHierarchyEdges } from './resolve-hierarchy-edges';
import { loadMetadata, resolveNodeType } from './schema';
import type { SpaceDirectoryReadResult, SpaceNode } from './types';
@@ -20,13 +20,14 @@ export async function readSpaceDirectory(
const metadata = loadMetadata(resolvedSchemaPath);
const hierarchyLevels = metadata.hierarchy?.levels ?? [];
const hierarchyTypes = hierarchyLevels.map((level) => level.type);
+ const relationships = metadata.relationships ?? [];
const { typeAliases } = metadata;
const fieldMap = space?.fieldMap;
const templateDir = options?.templateDir ?? space?.templateDir ?? config.templateDir;
const absoluteTemplateDir = templateDir ? resolve(templateDir) : undefined;
- const files = await glob('**/*.md', { cwd: directory, absolute: false });
+ const files = await glob('**/*.md', { cwd: directory, absolute: false, follow: true });
const nodes: SpaceNode[] = [];
const skipped: string[] = [];
const nonSpace: string[] = [];
@@ -75,6 +76,7 @@ export async function readSpaceDirectory(
pageTitle: fileBase,
pageType,
hierarchy: hierarchyTypes,
+ relationships: relationships,
typeAliases: typeAliases,
fieldMap,
});
@@ -82,6 +84,6 @@ export async function readSpaceDirectory(
}
}
- resolveLinks(nodes, hierarchyLevels);
+ resolveHierarchyEdges(nodes, hierarchyLevels);
return { nodes, skipped, nonSpace };
}
diff --git a/src/read-space-on-a-page.ts b/src/read-space-on-a-page.ts
index d9b8a16..5988d3c 100644
--- a/src/read-space-on-a-page.ts
+++ b/src/read-space-on-a-page.ts
@@ -3,7 +3,7 @@ import { basename, resolve } from 'node:path';
import matter from 'gray-matter';
import { loadConfig, resolveSchema } from './config';
import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded';
-import { resolveLinks } from './resolve-links';
+import { resolveHierarchyEdges } from './resolve-hierarchy-edges';
import { loadMetadata } from './schema';
import type { SpaceOnAPageReadResult } from './types';
@@ -30,6 +30,7 @@ export function readSpaceOnAPage(filePath: string, schemaPath?: string): SpaceOn
);
}
const hierarchyTypes = hierarchyLevels.map((level) => level.type);
+ const relationships = metadata.relationships ?? [];
const { typeAliases } = metadata;
const pageTitle = basename(filePath, '.md');
@@ -37,8 +38,9 @@ export function readSpaceOnAPage(filePath: string, schemaPath?: string): SpaceOn
pageTitle,
pageType: 'space_on_a_page',
hierarchy: hierarchyTypes,
+ relationships: relationships,
typeAliases,
});
- resolveLinks(nodes, hierarchyLevels);
+ resolveHierarchyEdges(nodes, hierarchyLevels);
return { nodes, diagnostics };
}
diff --git a/src/resolve-hierarchy-edges.ts b/src/resolve-hierarchy-edges.ts
new file mode 100644
index 0000000..79e608a
--- /dev/null
+++ b/src/resolve-hierarchy-edges.ts
@@ -0,0 +1,122 @@
+import type { HierarchyLevel, SpaceNode } from './types';
+import { buildTargetIndex, wikilinkToTarget } from './wikilink-utils';
+
+/**
+ * Extract wikilink refs from a field value.
+ * If multiple=true: expects an array; returns string elements.
+ * If multiple=false: expects a single string; returns it in a one-element array.
+ */
+function getRefs(rawField: unknown, multiple: boolean): string[] {
+ if (multiple) {
+ return Array.isArray(rawField) ? rawField.filter((v): v is string => typeof v === 'string') : [];
+ }
+ return typeof rawField === 'string' ? [rawField] : [];
+}
+
+/**
+ * Resolves hierarchy parent-child links for a specific child/parent type pair.
+ *
+ * NOTE: This handles primary structural hierarchy links defined in `$metadata.hierarchy`.
+ * It is distinct from "Adjacent Relationships" defined in `$metadata.relationships`.
+ *
+ * @param nodesByType Map of node type to nodes
+ * @param targetIndex Map of link targets to nodes
+ * @param childType The type of child nodes
+ * @param parentType The type of parent nodes
+ * @param field The field name in frontmatter that contains the wikilink(s)
+ * @param fieldOn 'parent' if parent nodes have the field pointing to children, 'child' if child nodes have the field pointing to parents
+ * @param multiple Whether the field contains an array of refs
+ */
+function resolveHierarchyLink(
+ nodesByType: Map,
+ targetIndex: Map,
+ childType: string,
+ parentType: string,
+ field: string,
+ fieldOn: 'child' | 'parent',
+ multiple: boolean,
+): void {
+ if (fieldOn === 'parent') {
+ // Parent nodes have the field pointing to children
+ for (const parentNode of nodesByType.get(parentType) ?? []) {
+ const rawField = parentNode.schemaData[field];
+ const refs = getRefs(rawField, multiple);
+ for (const ref of refs) {
+ const target = wikilinkToTarget(ref);
+ const childNode = targetIndex.get(target);
+ if (!childNode) continue;
+ const parentTitle = parentNode.schemaData.title;
+ if (typeof parentTitle !== 'string') continue;
+ childNode.resolvedParents.push(parentTitle);
+ }
+ }
+ } else {
+ // Child nodes have the field pointing to parents
+ for (const childNode of nodesByType.get(childType) ?? []) {
+ const rawField = childNode.schemaData[field];
+ const refs = getRefs(rawField, multiple);
+ for (const ref of refs) {
+ const target = wikilinkToTarget(ref);
+ const parentNode = targetIndex.get(target);
+ if (!parentNode) continue;
+ const parentTitle = parentNode.schemaData.title;
+ if (typeof parentTitle !== 'string') continue;
+ childNode.resolvedParents.push(parentTitle);
+ }
+ }
+ }
+}
+
+/**
+ * Resolve parent links using the levels configuration from schema metadata.
+ * Supports DAG relationships via configurable edge fields per hierarchy level.
+ * Also supports same-type parent relationships via the selfRefField property.
+ */
+export function resolveHierarchyEdges(nodes: SpaceNode[], levels: HierarchyLevel[]): void {
+ // Initialize all nodes' resolvedParents to empty array
+ for (const node of nodes) {
+ node.resolvedParents = [];
+ }
+
+ const targetIndex = buildTargetIndex(nodes);
+
+ // Build nodesByType map
+ const nodesByType = new Map();
+ for (const node of nodes) {
+ const type = node.resolvedType;
+ if (!nodesByType.has(type)) {
+ nodesByType.set(type, []);
+ }
+ nodesByType.get(type)!.push(node);
+ }
+
+ // Process non-root levels (i >= 1)
+ for (let i = 1; i < levels.length; i++) {
+ const level = levels[i]!;
+ const parentLevel = levels[i - 1]!;
+
+ // Regular relationship (child type β parent type)
+ resolveHierarchyLink(
+ nodesByType,
+ targetIndex,
+ level.type,
+ parentLevel.type,
+ level.field,
+ level.fieldOn,
+ level.multiple,
+ );
+
+ // Same-type relationship (child type β same type) if selfRefField is set
+ if (level.selfRefField) {
+ resolveHierarchyLink(
+ nodesByType,
+ targetIndex,
+ level.type,
+ level.type,
+ level.selfRefField,
+ 'child', // always child-side for self-ref
+ false, // never multiple for self-ref
+ );
+ }
+ }
+}
diff --git a/src/resolve-links.ts b/src/resolve-links.ts
deleted file mode 100644
index c089244..0000000
--- a/src/resolve-links.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import type { HierarchyLevel, SpaceNode } from './types';
-
-function addTarget(index: Map, target: string, node: SpaceNode): void {
- const normalized = target.trim();
- if (!normalized) return;
-
- const existing = index.get(normalized);
- if (existing === undefined) {
- index.set(normalized, node);
- return;
- }
-
- if (existing !== node) {
- index.set(normalized, null);
- }
-}
-
-export function buildTargetIndex(nodes: SpaceNode[]): Map {
- const index = new Map();
- for (const node of nodes) {
- for (const target of node.linkTargets) {
- addTarget(index, target, node);
- }
- }
- return index;
-}
-
-/**
- * Extract the lookup key from a wikilink string such as:
- * [[Personal Vision]] β "Personal Vision"
- * [[Personal Vision#Our Mission]] β "Personal Vision#Our Mission"
- * [[vision_page#^ourmission]] β "vision_page#^ourmission"
- */
-export function wikilinkToTarget(wikilink: string): string {
- const cleaned = wikilink.replace(/^"|"$/g, '').trim();
- if (!cleaned.startsWith('[[') || !cleaned.endsWith(']]')) {
- return cleaned;
- }
- return cleaned.slice(2, -2).trim();
-}
-
-/**
- * Extract wikilink refs from a field value.
- * If multiple=true: expects an array; returns string elements.
- * If multiple=false: expects a single string; returns it in a one-element array.
- */
-function getRefs(rawField: unknown, multiple: boolean): string[] {
- if (multiple) {
- return Array.isArray(rawField) ? rawField.filter((v): v is string => typeof v === 'string') : [];
- }
- return typeof rawField === 'string' ? [rawField] : [];
-}
-
-/**
- * Resolve parent links using the levels configuration from schema metadata.
- * Supports DAG relationships via configurable edge fields per hierarchy level.
- */
-export function resolveLinks(nodes: SpaceNode[], levels: HierarchyLevel[]): void {
- // Initialize all nodes' resolvedParents to empty array
- for (const node of nodes) {
- node.resolvedParents = [];
- }
-
- const targetIndex = buildTargetIndex(nodes);
-
- // Build nodesByType map
- const nodesByType = new Map();
- for (const node of nodes) {
- const type = node.resolvedType;
- if (!nodesByType.has(type)) {
- nodesByType.set(type, []);
- }
- nodesByType.get(type)!.push(node);
- }
-
- // Process non-root levels (i >= 1)
- for (let i = 1; i < levels.length; i++) {
- const level = levels[i]!;
- const parentLevel = levels[i - 1]!;
-
- if (level.fieldOn === 'parent') {
- // Parent nodes have the field pointing to children
- // Iterate parent-type nodes
- for (const parentNode of nodesByType.get(parentLevel.type) ?? []) {
- const rawField = parentNode.schemaData[level.field];
- const refs = getRefs(rawField, level.multiple);
- for (const ref of refs) {
- const target = wikilinkToTarget(ref);
- const childNode = targetIndex.get(target);
- if (!childNode) continue;
- const parentTitle = parentNode.schemaData.title;
- if (typeof parentTitle !== 'string') continue;
- childNode.resolvedParents.push(parentTitle);
- }
- }
- } else {
- // Child nodes have the field pointing to parents
- // Iterate child-type nodes
- for (const childNode of nodesByType.get(level.type) ?? []) {
- const rawField = childNode.schemaData[level.field];
- const refs = getRefs(rawField, level.multiple);
- for (const ref of refs) {
- const target = wikilinkToTarget(ref);
- const parentNode = targetIndex.get(target);
- if (!parentNode) continue;
- const parentTitle = parentNode.schemaData.title;
- if (typeof parentTitle !== 'string') continue;
- childNode.resolvedParents.push(parentTitle);
- }
- }
- }
- }
-}
diff --git a/src/schema.ts b/src/schema.ts
index ebb21da..baaa6dc 100644
--- a/src/schema.ts
+++ b/src/schema.ts
@@ -6,6 +6,7 @@ import Ajv, { type ValidateFunction } from 'ajv';
import JSON5 from 'json5';
import {
type MetadataContract,
+ type MetadataContractRelationship,
type MetadataContractRule,
type MetadataContractRuleEntry,
OST_TOOLS_DIALECT_META_SCHEMA,
@@ -336,6 +337,7 @@ export function loadMetadata(schemaPath: string): SchemaMetadata {
let mergedHierarchy: MetadataContract['hierarchy'] | undefined;
const mergedAliases: Record = {};
const mergedRules = new Map();
+ const mergedRelationships: MetadataContractRelationship[] = [];
for (const provider of metadataProviders) {
if (provider.metadata.hierarchy) {
@@ -352,6 +354,10 @@ export function loadMetadata(schemaPath: string): SchemaMetadata {
Object.assign(mergedAliases, provider.metadata.aliases);
}
+ if (provider.metadata.relationships) {
+ mergedRelationships.push(...provider.metadata.relationships);
+ }
+
if (provider.metadata.rules) {
for (const entry of provider.metadata.rules) {
const resolvedRules = resolveRuleEntries(entry, provider, registry, new Set());
@@ -381,12 +387,15 @@ export function loadMetadata(schemaPath: string): SchemaMetadata {
if (typeof entry === 'string') {
return { type: entry, field: 'parent', fieldOn: 'child', multiple: false, selfRef: false };
}
+ // If selfRefField is set, imply selfRef: true
+ const selfRef = entry.selfRefField !== undefined ? true : (entry.selfRef ?? false);
return {
type: entry.type,
field: entry.field ?? 'parent',
fieldOn: entry.fieldOn === 'parent' ? 'parent' : 'child',
multiple: entry.multiple ?? false,
- selfRef: entry.selfRef ?? false,
+ selfRef,
+ selfRefField: entry.selfRefField,
};
});
@@ -400,5 +409,6 @@ export function loadMetadata(schemaPath: string): SchemaMetadata {
: undefined,
typeAliases: Object.keys(mergedAliases).length > 0 ? mergedAliases : undefined,
rules: mergedRules.size > 0 ? [...mergedRules.values()].map(({ rule }) => normalizeRule(rule)) : undefined,
+ relationships: mergedRelationships.length > 0 ? mergedRelationships : undefined,
};
}
diff --git a/src/types.ts b/src/types.ts
index 7370525..f869e71 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,4 +1,9 @@
-import type { MetadataContractResolvedRules, MetadataContractRule } from './metadata-contract';
+import type { SchemaObject } from 'ajv';
+import type {
+ MetadataContractRelationship,
+ MetadataContractResolvedRules,
+ MetadataContractRule,
+} from './metadata-contract';
export interface HierarchyLevel {
type: string;
@@ -6,6 +11,7 @@ export interface HierarchyLevel {
fieldOn: 'child' | 'parent'; // default "child" - "parent" means the parent node has the field pointing to children
multiple: boolean; // default false - when true, field is an array of wikilinks
selfRef: boolean; // default false - when true, a node of this type may have a parent of the same type
+ selfRefField?: string; // optional field for same-type parent relationships (implies selfRef: true)
}
export interface SpaceNode {
@@ -69,4 +75,9 @@ export interface SchemaMetadata {
};
typeAliases?: Record;
rules?: RulesMetadata;
+ relationships?: MetadataContractRelationship[];
+}
+
+export interface SchemaWithMetadata extends SchemaObject {
+ $metadata?: SchemaMetadata;
}
diff --git a/src/validate-hierarchy.ts b/src/validate-hierarchy.ts
index 5417fe0..573a00d 100644
--- a/src/validate-hierarchy.ts
+++ b/src/validate-hierarchy.ts
@@ -1,6 +1,256 @@
import type { HierarchyViolation, SchemaMetadata, SpaceNode } from './types';
+import { buildTargetIndex, wikilinkToTarget } from './wikilink-utils';
-export function validateHierarchy(nodes: SpaceNode[], metadata: SchemaMetadata): HierarchyViolation[] {
+export interface HierarchyValidationResult {
+ violations: HierarchyViolation[];
+ refErrors: Array<{ file: string; parent: string; error: string }>;
+}
+
+/**
+ * Validate all hierarchy-related constraints including field references.
+ * Use this when you have a linkTargetIndex available.
+ *
+ * @param nodes The nodes to validate
+ * @param metadata Schema metadata containing hierarchy configuration
+ * @param linkTargetIndex Map of link targets to nodes
+ * @returns Object containing hierarchy violations and reference errors
+ */
+export function validateHierarchyWithFields(nodes: SpaceNode[], metadata: SchemaMetadata): HierarchyValidationResult {
+ const refErrors: Array<{ file: string; parent: string; error: string }> = [];
+ const levels = metadata.hierarchy?.levels ?? [];
+ const linkTargetIndex = buildTargetIndex(nodes);
+
+ // Validate regular field references for each hierarchy level
+ for (let i = 1; i < levels.length; i++) {
+ const level = levels[i]!;
+ const parentLevel = levels[i - 1]!;
+
+ // Determine which node type has the field
+ const nodeTypeToCheck = level.fieldOn === 'parent' ? parentLevel.type : level.type;
+
+ validateFieldReferences(nodes, linkTargetIndex, nodeTypeToCheck, level.field, level.multiple, refErrors);
+ }
+
+ // Validate selfRefField references for each hierarchy level that has it
+ for (const level of levels) {
+ if (!level.selfRefField) continue;
+
+ // selfRefField is always on child-side and its multiplicity is strictly false
+ validateFieldReferences(nodes, linkTargetIndex, level.type, level.selfRefField, false, refErrors);
+ }
+
+ // Validate hierarchy structure rules
+ const violations = validateHierarchyStructure(nodes, metadata);
+
+ return { violations, refErrors };
+}
+
+/**
+ * Validate that adjacent relationship (sub-entity) link references in the `parent` field
+ * point to valid nodes of the correct type.
+ * Use this when you have a linkTargetIndex available.
+ *
+ * @param nodes The nodes to validate
+ * @param metadata Schema metadata containing relationships configuration
+ * @param linkTargetIndex Map of link targets to nodes
+ * @returns Object containing hierarchy violations (for type errors) and reference errors
+ */
+export function validateRelationships(
+ nodes: SpaceNode[],
+ metadata: SchemaMetadata,
+ linkTargetIndex?: Map,
+): HierarchyValidationResult {
+ const refErrors: Array<{ file: string; parent: string; error: string }> = [];
+ const violations: HierarchyViolation[] = [];
+ const relationships = metadata.relationships ?? [];
+
+ if (relationships.length === 0) {
+ return { violations, refErrors };
+ }
+
+ const index = linkTargetIndex ?? buildTargetIndex(nodes);
+
+ for (const rel of relationships) {
+ const parentType = rel.parent;
+ const childType = rel.type;
+
+ if (rel.fieldOn === 'parent') {
+ // Parent-side: the parent node holds an array field pointing to child nodes
+ if (!rel.field) continue;
+ const field = rel.field;
+ const parentNodes = nodes.filter((n) => n.resolvedType === parentType);
+ for (const node of parentNodes) {
+ const rawField = node.schemaData[field];
+ if (rawField === undefined || rawField === null) continue;
+
+ if (!Array.isArray(rawField)) {
+ refErrors.push({
+ file: node.label,
+ parent: String(rawField),
+ error: `Field "${field}" must be an array of wikilinks, got ${typeof rawField}`,
+ });
+ continue;
+ }
+
+ for (const ref of rawField) {
+ if (typeof ref !== 'string') continue;
+ const target = wikilinkToTarget(ref);
+ const resolved = index.get(target);
+ if (resolved === undefined) {
+ refErrors.push({
+ file: node.label,
+ parent: ref,
+ error: `Link target "${target}" in field "${field}" not found`,
+ });
+ } else if (resolved === null) {
+ refErrors.push({
+ file: node.label,
+ parent: ref,
+ error: `Link target "${target}" in field "${field}" is ambiguous (matches multiple nodes)`,
+ });
+ } else if (resolved.resolvedType !== childType) {
+ violations.push({
+ file: node.label,
+ nodeType: parentType,
+ nodeTitle: node.schemaData.title as string,
+ parentType: resolved.resolvedType,
+ parentTitle: resolved.schemaData.title as string,
+ description: `Invalid relationship field: ${parentType} "${node.schemaData.title}" has "${resolved.schemaData.title}" in field "${field}" which is of type ${resolved.resolvedType}, expected ${childType}`,
+ });
+ }
+ }
+ }
+ } else {
+ // Child-side (default): the child node holds a field pointing to its parent
+ const field = rel.field ?? 'parent';
+ const nodesToCheck = nodes.filter((n) => n.resolvedType === childType);
+ for (const node of nodesToCheck) {
+ const rawField = node.schemaData[field];
+ if (rawField === undefined || rawField === null) continue;
+
+ if (typeof rawField !== 'string') {
+ refErrors.push({
+ file: node.label,
+ parent: String(rawField),
+ error: `Field "${field}" must be a wikilink string, got ${typeof rawField}`,
+ });
+ continue;
+ }
+
+ const target = wikilinkToTarget(rawField);
+ const resolved = index.get(target);
+
+ if (resolved === undefined) {
+ refErrors.push({
+ file: node.label,
+ parent: rawField,
+ error: `Link target "${target}" in field "${field}" not found`,
+ });
+ } else if (resolved === null) {
+ refErrors.push({
+ file: node.label,
+ parent: rawField,
+ error: `Link target "${target}" in field "${field}" is ambiguous (matches multiple nodes)`,
+ });
+ } else if (resolved.resolvedType !== parentType) {
+ violations.push({
+ file: node.label,
+ nodeType: childType,
+ nodeTitle: node.schemaData.title as string,
+ parentType: resolved.resolvedType,
+ parentTitle: resolved.schemaData.title as string,
+ description: `Invalid relationship parent: ${childType} "${node.schemaData.title}" points to "${resolved.schemaData.title}" which is of type ${resolved.resolvedType}, expected ${parentType}`,
+ });
+ }
+ }
+ }
+ }
+
+ return { violations, refErrors };
+}
+
+/**
+ * Validate that link references in a field point to valid nodes.
+ * @param nodes The nodes to check
+ * @param linkTargetIndex Map of link targets to nodes
+ * @param nodeType The type of nodes that have the field
+ * @param field The field name to validate
+ * @param multiple Whether the field contains an array of refs
+ * @param refErrors Array to collect validation errors
+ */
+function validateFieldReferences(
+ nodes: SpaceNode[],
+ linkTargetIndex: Map,
+ nodeType: string,
+ field: string,
+ multiple: boolean,
+ refErrors: Array<{ file: string; parent: string; error: string }>,
+): void {
+ const nodesToCheck = nodes.filter((n) => n.resolvedType === nodeType);
+
+ for (const node of nodesToCheck) {
+ const rawField = node.schemaData[field];
+ if (rawField === undefined || rawField === null) continue;
+
+ if (multiple) {
+ if (!Array.isArray(rawField)) {
+ refErrors.push({
+ file: node.label,
+ parent: String(rawField),
+ error: `Field "${field}" must be an array of wikilinks, got ${typeof rawField}`,
+ });
+ continue;
+ }
+ for (const ref of rawField) {
+ if (typeof ref !== 'string') continue;
+ const target = wikilinkToTarget(ref);
+ const resolved = linkTargetIndex.get(target);
+ if (resolved === undefined) {
+ refErrors.push({
+ file: node.label,
+ parent: ref,
+ error: `Link target "${target}" in field "${field}" not found`,
+ });
+ } else if (resolved === null) {
+ refErrors.push({
+ file: node.label,
+ parent: ref,
+ error: `Link target "${target}" in field "${field}" is ambiguous (matches multiple nodes)`,
+ });
+ }
+ }
+ } else {
+ if (typeof rawField !== 'string') {
+ refErrors.push({
+ file: node.label,
+ parent: String(rawField),
+ error: `Field "${field}" must be a wikilink string, got ${typeof rawField}`,
+ });
+ continue;
+ }
+ const target = wikilinkToTarget(rawField);
+ const resolved = linkTargetIndex.get(target);
+ if (resolved === undefined) {
+ refErrors.push({
+ file: node.label,
+ parent: rawField,
+ error: `Link target "${target}" in field "${field}" not found`,
+ });
+ } else if (resolved === null) {
+ refErrors.push({
+ file: node.label,
+ parent: rawField,
+ error: `Link target "${target}" in field "${field}" is ambiguous (matches multiple nodes)`,
+ });
+ }
+ }
+ }
+}
+
+/**
+ * Validate that resolved parents follow hierarchy level rules.
+ */
+export function validateHierarchyStructure(nodes: SpaceNode[], metadata: SchemaMetadata): HierarchyViolation[] {
const violations: HierarchyViolation[] = [];
const levels = metadata.hierarchy?.levels;
if (!levels || levels.length === 0) return violations;
@@ -29,7 +279,7 @@ export function validateHierarchy(nodes: SpaceNode[], metadata: SchemaMetadata):
const parentIndex = hierarchy.indexOf(parentType);
if (parentIndex === -1) continue;
- const canSelfRef = levels[typeIndex]?.selfRef ?? false;
+ const canSelfRef = (levels[typeIndex]?.selfRef ?? false) || levels[typeIndex]?.selfRefField !== undefined;
let isValid = parentIndex === typeIndex - 1;
if (canSelfRef) isValid = isValid || parentIndex === typeIndex;
if (allowSkipLevels && parentIndex < typeIndex) isValid = true;
diff --git a/src/wikilink-utils.ts b/src/wikilink-utils.ts
new file mode 100644
index 0000000..bf97594
--- /dev/null
+++ b/src/wikilink-utils.ts
@@ -0,0 +1,42 @@
+import type { SpaceNode } from './types';
+
+/**
+ * Extract the lookup key from a wikilink string such as:
+ * [[Personal Vision]] β "Personal Vision"
+ * [[Personal Vision#Our Mission]] β "Personal Vision#Our Mission"
+ * [[vision_page#^ourmission]] β "vision_page#^ourmission"
+ */
+export function wikilinkToTarget(wikilink: string): string {
+ const cleaned = wikilink.replace(/^"|"$/g, '').trim();
+ if (!cleaned.startsWith('[[') || !cleaned.endsWith(']]')) {
+ return cleaned;
+ }
+ return cleaned.slice(2, -2).trim();
+}
+
+/**
+ * Builds a fast lookup index mapping link targets to nodes.
+ * Used for both hierarchy and relationship validation.
+ *
+ * @param nodes The complete set of SpaceNodes
+ * @returns Map of target strings to nodes. If a target is ambiguous (points to multiple nodes), its value is null.
+ */
+export function buildTargetIndex(nodes: SpaceNode[]): Map {
+ const index = new Map();
+
+ for (const node of nodes) {
+ for (const target of node.linkTargets) {
+ const normalized = target.trim();
+ if (!normalized) continue; // Skip empty strings after trimming
+
+ const existing = index.get(normalized);
+ if (existing === undefined) {
+ index.set(normalized, node);
+ } else if (existing !== node) {
+ index.set(normalized, null); // mark as ambiguous
+ }
+ }
+ }
+
+ return index;
+}
diff --git a/tests/build-target-index.test.ts b/tests/build-target-index.test.ts
new file mode 100644
index 0000000..c1f16be
--- /dev/null
+++ b/tests/build-target-index.test.ts
@@ -0,0 +1,188 @@
+import { describe, expect, it } from 'bun:test';
+import type { SpaceNode } from '../src/types';
+import { buildTargetIndex } from '../src/wikilink-utils';
+
+/**
+ * Helper to build a test node
+ */
+function makeNode(title: string, type: string, extra: Record = {}, linkTargets?: string[]): SpaceNode {
+ return {
+ label: `${title}.md`,
+ schemaData: { title, type, ...extra },
+ linkTargets: linkTargets ?? [title],
+ resolvedParents: [],
+ resolvedType: type,
+ };
+}
+
+describe('buildTargetIndex', () => {
+ describe('normalization bug - targets should be trimmed', () => {
+ it('should trim whitespace from targets before indexing', () => {
+ const node1 = makeNode('Target A', 'Type', {}, ['Target A']);
+ const node2 = makeNode('Target B', 'Type', {}, [' Target B ']); // leading/trailing spaces
+ const node3 = makeNode('Target C', 'Type', {}, ['Target C']);
+
+ const index = buildTargetIndex([node1, node2, node3]);
+
+ // Should be able to lookup with trimmed key
+ expect(index.get('Target A')).toBe(node1);
+ expect(index.get('Target B')).toBe(node2); // This FAILS with current code - no trimming
+ expect(index.get('Target C')).toBe(node3);
+
+ // Should NOT have the untrimmed version
+ expect(index.has(' Target B ')).toBe(false); // This FAILS with current code
+ });
+
+ it('should treat targets with different whitespace as the same target', () => {
+ const node1 = makeNode('Node 1', 'Type', {}, ['Target']);
+ const node2 = makeNode('Node 2', 'Type', {}, [' Target']); // leading space
+ const node3 = makeNode('Node 3', 'Type', {}, ['Target ']); // trailing space
+
+ const index = buildTargetIndex([node1, node2, node3]);
+
+ // All three nodes point to the same normalized target, so it should be marked as ambiguous
+ expect(index.get('Target')).toBe(null); // This FAILS with current code - no trimming
+ });
+
+ it('should handle empty strings after trimming', () => {
+ const node1 = makeNode('Node 1', 'Type', {}, [' ']); // only spaces
+ const node2 = makeNode('Node 2', 'Type', {}, ['\t\n']); // tabs and newlines
+ const node3 = makeNode('Node 3', 'Type', {}, ['Target']);
+
+ const index = buildTargetIndex([node1, node2, node3]);
+
+ // Empty targets should be skipped (not added to index)
+ expect(index.has('')).toBe(false); // This FAILS with current code - no empty check
+ expect(index.has(' ')).toBe(false); // This FAILS with current code
+ expect(index.get('Target')).toBe(node3);
+ });
+
+ it('should normalize targets with mixed whitespace', () => {
+ const node1 = makeNode('Node 1', 'Type', {}, [' \t Target \n ']);
+
+ const index = buildTargetIndex([node1]);
+
+ // Should be stored with normalized key
+ expect(index.get('Target')).toBe(node1); // This FAILS with current code
+ expect(index.has(' \t Target \n ')).toBe(false); // This FAILS with current code
+ });
+ });
+
+ describe('duplicate detection - targets pointing to multiple nodes', () => {
+ it('should mark target as null when multiple nodes reference it', () => {
+ const node1 = makeNode('Node 1', 'Type', {}, ['Shared Target']);
+ const node2 = makeNode('Node 2', 'Type', {}, ['Shared Target']);
+ const node3 = makeNode('Node 3', 'Type', {}, ['Unique Target']);
+
+ const index = buildTargetIndex([node1, node2, node3]);
+
+ // Shared target should be marked as ambiguous
+ expect(index.get('Shared Target')).toBe(null);
+ expect(index.get('Unique Target')).toBe(node3);
+ });
+
+ it('should mark target as null even when duplicates are not adjacent', () => {
+ const node1 = makeNode('Node 1', 'Type', {}, ['Target A']);
+ const node2 = makeNode('Node 2', 'Type', {}, ['Target B']);
+ const node3 = makeNode('Node 3', 'Type', {}, ['Target A']); // duplicate of node1
+ const node4 = makeNode('Node 4', 'Type', {}, ['Target C']);
+
+ const index = buildTargetIndex([node1, node2, node3, node4]);
+
+ expect(index.get('Target A')).toBe(null);
+ expect(index.get('Target B')).toBe(node2);
+ expect(index.get('Target C')).toBe(node4);
+ });
+
+ it('should handle multiple duplicates of different targets', () => {
+ const node1 = makeNode('Node 1', 'Type', {}, ['Target A', 'Target B']);
+ const node2 = makeNode('Node 2', 'Type', {}, ['Target A', 'Target C']);
+ const node3 = makeNode('Node 3', 'Type', {}, ['Target B', 'Target C']);
+
+ const index = buildTargetIndex([node1, node2, node3]);
+
+ // All three targets are referenced by multiple nodes
+ expect(index.get('Target A')).toBe(null);
+ expect(index.get('Target B')).toBe(null);
+ expect(index.get('Target C')).toBe(null);
+ });
+ });
+
+ describe('combination of normalization and duplicate detection', () => {
+ it('should detect duplicates after normalization', () => {
+ const node1 = makeNode('Node 1', 'Type', {}, ['Target']);
+ const node2 = makeNode('Node 2', 'Type', {}, [' Target ']); // same target, with spaces
+ const node3 = makeNode('Node 3', 'Type', {}, ['Unique']);
+
+ const index = buildTargetIndex([node1, node2, node3]);
+
+ // After normalization, both 'Target' and ' Target ' are the same
+ expect(index.get('Target')).toBe(null); // This FAILS with current code - no trimming
+ expect(index.get('Unique')).toBe(node3);
+ });
+
+ it('should handle complex case with trimming and duplicates', () => {
+ const node1 = makeNode('Node 1', 'Type', {}, ['A', 'B']);
+ const node2 = makeNode('Node 2', 'Type', {}, [' A ', 'C']); // 'A' with spaces
+ const node3 = makeNode('Node 3', 'Type', {}, [' ', 'D']); // empty and 'D'
+
+ const index = buildTargetIndex([node1, node2, node3]);
+
+ // 'A' appears twice (with and without spaces) β ambiguous
+ expect(index.get('A')).toBe(null); // This FAILS with current code
+
+ // 'B' appears once
+ expect(index.get('B')).toBe(node1);
+
+ // 'C' appears once
+ expect(index.get('C')).toBe(node2);
+
+ // 'D' appears once
+ expect(index.get('D')).toBe(node3);
+
+ // Empty string should be skipped
+ expect(index.has('')).toBe(false); // This FAILS with current code
+ expect(index.size).toBe(4); // A, B, C, D (empty string not included)
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty node list', () => {
+ const index = buildTargetIndex([]);
+ expect(index.size).toBe(0);
+ });
+
+ it('should handle node with empty linkTargets array', () => {
+ const node1 = makeNode('Node 1', 'Type', {}, []);
+ const node2 = makeNode('Node 2', 'Type', {}, ['Target']);
+
+ const index = buildTargetIndex([node1, node2]);
+
+ expect(index.size).toBe(1);
+ expect(index.get('Target')).toBe(node2);
+ });
+
+ it('should handle node with multiple targets including duplicates', () => {
+ const node1 = makeNode('Node 1', 'Type', {}, ['Target A', 'Target B', 'Target A']);
+ const node2 = makeNode('Node 2', 'Type', {}, ['Target C']);
+
+ const index = buildTargetIndex([node1, node2]);
+
+ // Same node referencing the same target multiple times is OK
+ expect(index.get('Target A')).toBe(node1);
+ expect(index.get('Target B')).toBe(node1);
+ expect(index.get('Target C')).toBe(node2);
+ });
+
+ it('should preserve all targets from a node', () => {
+ const node1 = makeNode('Node 1', 'Type', {}, ['Target A', 'Target B', 'Target C']);
+
+ const index = buildTargetIndex([node1]);
+
+ expect(index.size).toBe(3);
+ expect(index.get('Target A')).toBe(node1);
+ expect(index.get('Target B')).toBe(node1);
+ expect(index.get('Target C')).toBe(node1);
+ });
+ });
+});
diff --git a/tests/parse-embedded-relationships.test.ts b/tests/parse-embedded-relationships.test.ts
new file mode 100644
index 0000000..2bba2ad
--- /dev/null
+++ b/tests/parse-embedded-relationships.test.ts
@@ -0,0 +1,364 @@
+import { describe, expect, it } from 'bun:test';
+import type { MetadataContractRelationship } from '../src/metadata-contract';
+import { extractEmbeddedNodes } from '../src/parse-embedded';
+
+describe('extractEmbeddedNodes - relationships', () => {
+ const hierarchy = ['vision', 'mission', 'goal', 'opportunity', 'solution', 'experiment'];
+
+ it('extracts table rows as typed nodes when first col matches relation type', () => {
+ const relationships: MetadataContractRelationship[] = [
+ {
+ parent: 'opportunity',
+ type: 'assumption',
+ format: 'table',
+ matchers: ['Assumptions'],
+ embeddedTemplateFields: ['assumption', 'status'],
+ multi: true,
+ },
+ ];
+
+ const body = `
+# My Big Opportunity [type:: opportunity]
+
+### Assumptions
+
+| assumption | status | confidence |
+|---|---|---|
+| We can build this | active | medium |
+| Users want this | identified | low |
+`;
+
+ const { nodes } = extractEmbeddedNodes(body, {
+ pageType: 'opportunity',
+ hierarchy,
+ relationships,
+ });
+
+ const opp = nodes.find((n) => n.schemaData.type === 'opportunity');
+ expect(opp).toBeDefined();
+
+ const assumptions = nodes.filter((n) => n.schemaData.type === 'assumption');
+ expect(assumptions).toHaveLength(2);
+ expect(assumptions[0]?.schemaData.title).toBe('We can build this');
+ expect(assumptions[0]?.schemaData.status).toBe('active');
+ expect(assumptions[0]?.schemaData.confidence).toBe('medium');
+ expect(assumptions[0]?.schemaData.parent).toBe('[[My Big Opportunity]]');
+ });
+
+ it('infers row types from the first column if no relationship explicitly matches the heading (untyped table)', () => {
+ const body = `
+# My Big Opportunity [type:: opportunity]
+
+| assumption | status |
+|---|---|
+| They will buy it | identified |
+`;
+ const { nodes } = extractEmbeddedNodes(body, {
+ pageType: 'opportunity',
+ hierarchy: [...hierarchy, 'assumption'],
+ relationships: [],
+ });
+
+ const assumptions = nodes.filter((n) => n.schemaData.type === 'assumption');
+ expect(assumptions).toHaveLength(1);
+ expect(assumptions[0]?.schemaData.title).toBe('They will buy it');
+ });
+
+ it('translates explicit heading matchers to typed parent context nodes', () => {
+ const relationships: MetadataContractRelationship[] = [
+ {
+ parent: 'opportunity',
+ type: 'problem_statement',
+ format: 'heading',
+ matchers: ['What problem are we solving?'],
+ multi: false,
+ },
+ ];
+
+ const body = `
+# Opportunity 1 [type:: opportunity]
+
+### What problem are we solving?
+Our users are sad.
+`;
+ const { nodes } = extractEmbeddedNodes(body, {
+ pageType: 'opportunity',
+ hierarchy,
+ relationships,
+ });
+
+ const probNodes = nodes.filter((n) => n.schemaData.type === 'problem_statement');
+ expect(probNodes).toHaveLength(1);
+ expect(probNodes[0]?.schemaData.title).toBe('What problem are we solving?');
+ expect(probNodes[0]?.schemaData.content).toContain('Our users are sad.');
+ expect(probNodes[0]?.schemaData.parent).toBe('[[Opportunity 1]]');
+ });
+
+ it('supports list-based sub-entities after relationship heading', () => {
+ const relationships: MetadataContractRelationship[] = [
+ {
+ parent: 'opportunity',
+ type: 'solution',
+ format: 'list',
+ matchers: ['Possible Solutions'],
+ multi: true,
+ },
+ ];
+
+ const body = `
+# Multi Mode [type:: opportunity]
+
+### Possible Solutions
+- Build a web app
+- Build a mobile app
+`;
+ const { nodes } = extractEmbeddedNodes(body, {
+ pageType: 'opportunity',
+ hierarchy,
+ relationships,
+ });
+
+ const solutions = nodes.filter((n) => n.schemaData.type === 'solution');
+ expect(solutions).toHaveLength(2);
+ expect(solutions[0]?.schemaData.title).toBe('Build a web app');
+ expect(solutions[1]?.schemaData.title).toBe('Build a mobile app');
+ expect(solutions[0]?.schemaData.parent).toBe('[[Multi Mode]]');
+ });
+
+ it('supports /regex/ syntax and case-insensitive matching', () => {
+ const relationships: MetadataContractRelationship[] = [
+ {
+ parent: 'opportunity',
+ type: 'assumption',
+ format: 'table',
+ matchers: ['/assum.*/'],
+ embeddedTemplateFields: ['assumption', 'status'],
+ multi: true,
+ },
+ ];
+
+ const body = `
+# Case Sensitivity [type:: opportunity]
+
+### assuMPTIONS
+
+| assumption | status |
+|---|---|
+| Match me | active |
+`;
+
+ const { nodes } = extractEmbeddedNodes(body, {
+ pageType: 'opportunity',
+ hierarchy,
+ relationships,
+ });
+
+ const assumptions = nodes.filter((n) => n.schemaData.type === 'assumption');
+ expect(assumptions).toHaveLength(1);
+ expect(assumptions[0]?.schemaData.title).toBe('Match me');
+ });
+
+ it('supports case-insensitive implicit type match', () => {
+ const relationships: MetadataContractRelationship[] = [
+ {
+ parent: 'opportunity',
+ type: 'assumption',
+ format: 'table',
+ matchers: [],
+ embeddedTemplateFields: ['assumption', 'status'],
+ multi: true,
+ },
+ ];
+
+ const body = `
+# Implicit Match [type:: opportunity]
+
+### ASSUMPTION
+
+| assumption | status |
+|---|---|
+| Implicit Match | active |
+`;
+
+ const { nodes } = extractEmbeddedNodes(body, {
+ pageType: 'opportunity',
+ hierarchy,
+ relationships,
+ });
+
+ const assumptions = nodes.filter((n) => n.schemaData.type === 'assumption');
+ expect(assumptions).toHaveLength(1);
+ expect(assumptions[0]?.schemaData.title).toBe('Implicit Match');
+ });
+
+ describe('fieldOn: parent array mutation bug', () => {
+ it('should throw error when parent field is not an array (list format)', () => {
+ const relationships: MetadataContractRelationship[] = [
+ {
+ parent: 'opportunity',
+ type: 'solution',
+ format: 'list',
+ matchers: ['Possible Solutions'],
+ multi: true,
+ field: 'solutions',
+ fieldOn: 'parent',
+ },
+ ];
+
+ const body = `
+# My Opportunity [type:: opportunity] [solutions:: "string value"]
+
+### Possible Solutions
+- Solution A
+- Solution B
+`;
+
+ expect(() =>
+ extractEmbeddedNodes(body, {
+ pageType: 'opportunity',
+ hierarchy,
+ relationships,
+ }),
+ ).toThrow(/Cannot append child link to field 'solutions'.*field exists but is not an array/);
+ });
+
+ it('should throw error when parent field is not an array (table format)', () => {
+ const relationships: MetadataContractRelationship[] = [
+ {
+ parent: 'opportunity',
+ type: 'assumption',
+ format: 'table',
+ matchers: ['Assumptions'],
+ multi: true,
+ field: 'assumptions',
+ fieldOn: 'parent',
+ embeddedTemplateFields: ['assumption', 'status'],
+ },
+ ];
+
+ const body = `
+# My Opportunity [type:: opportunity] [assumptions:: "string value"]
+
+### Assumptions
+
+| assumption | status |
+|---|---|
+| Assumption One | active |
+| Assumption Two | identified |
+`;
+
+ expect(() =>
+ extractEmbeddedNodes(body, {
+ pageType: 'opportunity',
+ hierarchy,
+ relationships,
+ }),
+ ).toThrow(/Cannot append child link to field 'assumptions'.*field exists but is not an array/);
+ });
+
+ it('should throw error when parent field is a number', () => {
+ const relationships: MetadataContractRelationship[] = [
+ {
+ parent: 'opportunity',
+ type: 'solution',
+ format: 'list',
+ matchers: ['Solutions'],
+ multi: true,
+ field: 'count',
+ fieldOn: 'parent',
+ },
+ ];
+
+ const body = `
+# My Opportunity [type:: opportunity] [count:: 42]
+
+### Solutions
+- Solution A
+- Solution B
+`;
+
+ expect(() =>
+ extractEmbeddedNodes(body, {
+ pageType: 'opportunity',
+ hierarchy,
+ relationships,
+ }),
+ ).toThrow(/Cannot append child link to field 'count'.*field exists but is not an array/);
+ });
+
+ it('should append to existing parent array field (list format)', () => {
+ const relationships: MetadataContractRelationship[] = [
+ {
+ parent: 'opportunity',
+ type: 'solution',
+ format: 'list',
+ matchers: ['Solutions'],
+ multi: true,
+ field: 'solutions',
+ fieldOn: 'parent',
+ },
+ ];
+
+ const body = `
+# My Opportunity [type:: opportunity]
+
+### Solutions
+- Solution A
+- Solution B
+- Solution C
+`;
+
+ const { nodes } = extractEmbeddedNodes(body, {
+ pageType: 'opportunity',
+ hierarchy,
+ relationships,
+ });
+
+ const opportunity = nodes.find((n) => n.schemaData.type === 'opportunity');
+ expect(opportunity).toBeDefined();
+
+ // When field doesn't exist, a new array is created
+ const solutionsField = opportunity?.schemaData.solutions;
+ expect(solutionsField).toEqual(['[[Solution A]]', '[[Solution B]]', '[[Solution C]]']);
+ });
+
+ it('should append to existing parent array field (table format)', () => {
+ const relationships: MetadataContractRelationship[] = [
+ {
+ parent: 'opportunity',
+ type: 'assumption',
+ format: 'table',
+ matchers: ['Assumptions'],
+ multi: true,
+ field: 'assumptions',
+ fieldOn: 'parent',
+ embeddedTemplateFields: ['assumption', 'status'],
+ },
+ ];
+
+ const body = `
+# My Opportunity [type:: opportunity]
+
+### Assumptions
+
+| assumption | status |
+|---|---|
+| Assumption One | active |
+| Assumption Two | identified |
+`;
+
+ const { nodes } = extractEmbeddedNodes(body, {
+ pageType: 'opportunity',
+ hierarchy,
+ relationships,
+ });
+
+ const opportunity = nodes.find((n) => n.schemaData.type === 'opportunity');
+ expect(opportunity).toBeDefined();
+
+ // When field doesn't exist, a new array is created
+ const assumptionsField = opportunity?.schemaData.assumptions;
+ expect(assumptionsField).toEqual(['[[Assumption One]]', '[[Assumption Two]]']);
+ });
+ });
+});
diff --git a/tests/resolve-links.test.ts b/tests/resolve-hierarchy-edges.test.ts
similarity index 55%
rename from tests/resolve-links.test.ts
rename to tests/resolve-hierarchy-edges.test.ts
index b07d637..c6a9048 100644
--- a/tests/resolve-links.test.ts
+++ b/tests/resolve-hierarchy-edges.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'bun:test';
-import { resolveLinks } from '../src/resolve-links';
+import { resolveHierarchyEdges } from '../src/resolve-hierarchy-edges';
import type { SpaceNode } from '../src/types';
import { makeLevel } from './test-helpers';
@@ -14,7 +14,7 @@ function makeNode(title: string, type: string, extra: Record =
};
}
-describe('resolveLinks', () => {
+describe('resolveHierarchyEdges', () => {
describe("default behavior (fieldOn: 'child', multiple: false)", () => {
it('resolves parent field on child node to parent title', () => {
const levels = [makeLevel('Phase'), makeLevel('Activity')];
@@ -22,7 +22,7 @@ describe('resolveLinks', () => {
const phase = makeNode('Phase 1', 'Phase');
const activity = makeNode('Activity 1', 'Activity', { parent: '[[Phase 1]]' });
- resolveLinks([phase, activity], levels);
+ resolveHierarchyEdges([phase, activity], levels);
expect(activity.resolvedParents).toEqual(['Phase 1']);
expect(phase.resolvedParents).toEqual([]);
@@ -33,7 +33,7 @@ describe('resolveLinks', () => {
const activity = makeNode('Activity 1', 'Activity', { parent: '[[Nonexistent Phase]]' });
- resolveLinks([activity], levels);
+ resolveHierarchyEdges([activity], levels);
expect(activity.resolvedParents).toEqual([]);
});
@@ -51,7 +51,7 @@ describe('resolveLinks', () => {
const reqB = makeNode('Req B', 'Requirement');
const tool = makeNode('Tool X', 'Tool', { fulfills: ['[[Req A]]', '[[Req B]]'] });
- resolveLinks([reqA, reqB, tool], levels);
+ resolveHierarchyEdges([reqA, reqB, tool], levels);
expect(tool.resolvedParents).toContain('Req A');
expect(tool.resolvedParents).toContain('Req B');
@@ -64,7 +64,7 @@ describe('resolveLinks', () => {
const req = makeNode('Req A', 'Requirement');
const tool = makeNode('Tool X', 'Tool', { fulfills: ['[[Req A]]', 42, null] });
- resolveLinks([req, tool], levels);
+ resolveHierarchyEdges([req, tool], levels);
expect(tool.resolvedParents).toEqual(['Req A']);
});
@@ -74,7 +74,7 @@ describe('resolveLinks', () => {
const tool = makeNode('Tool X', 'Tool', { fulfills: '[[Req A]]' }); // string, not array
- resolveLinks([tool], levels);
+ resolveHierarchyEdges([tool], levels);
expect(tool.resolvedParents).toEqual([]);
});
@@ -93,7 +93,7 @@ describe('resolveLinks', () => {
const reqA = makeNode('Req A', 'Requirement');
const reqB = makeNode('Req B', 'Requirement');
- resolveLinks([activity, reqA, reqB], levels);
+ resolveHierarchyEdges([activity, reqA, reqB], levels);
expect(reqA.resolvedParents).toEqual(['Activity 1']);
expect(reqB.resolvedParents).toEqual(['Activity 1']);
@@ -114,7 +114,7 @@ describe('resolveLinks', () => {
});
const sharedReq = makeNode('Shared Req', 'Requirement');
- resolveLinks([activity1, activity2, sharedReq], levels);
+ resolveHierarchyEdges([activity1, activity2, sharedReq], levels);
expect(sharedReq.resolvedParents).toContain('Activity 1');
expect(sharedReq.resolvedParents).toContain('Activity 2');
@@ -140,7 +140,7 @@ describe('resolveLinks', () => {
const reqB = makeNode('Req B', 'Requirement');
const tool = makeNode('Tool X', 'Tool', { fulfills: ['[[Req A]]'] });
- resolveLinks([phase, activity, reqA, reqB, tool], levels);
+ resolveHierarchyEdges([phase, activity, reqA, reqB, tool], levels);
expect(activity.resolvedParents).toEqual(['Phase 1']);
expect(reqA.resolvedParents).toEqual(['Activity 1']);
@@ -157,7 +157,7 @@ describe('resolveLinks', () => {
const phase = makeNode('Phase 1', 'Phase', { parent: '[[Activity 1]]' });
const activity = makeNode('Activity 1', 'Activity', { parent: '[[Phase 1]]' });
- resolveLinks([phase, activity], levels);
+ resolveHierarchyEdges([phase, activity], levels);
// Phase is level 0 (root) β its parent field is not processed
expect(phase.resolvedParents).toEqual([]);
@@ -171,9 +171,108 @@ describe('resolveLinks', () => {
const activity = makeNode('Activity 1', 'Activity', { parent: '[[Ghost Phase]]' });
- resolveLinks([activity], levels);
+ resolveHierarchyEdges([activity], levels);
expect(activity.resolvedParents).toEqual([]);
});
});
+
+ describe('selfRefField (same-type parent relationships)', () => {
+ it('resolves both regular parents and same-type parents when selfRefField is set', () => {
+ const levels = [
+ makeLevel('Activity'),
+ makeLevel('Capability', { field: 'capabilities', fieldOn: 'parent', multiple: true, selfRefField: 'parent' }),
+ ];
+
+ const activity = makeNode('Activity 1', 'Activity', {
+ capabilities: ['[[Core Capability]]', '[[Sub Capability]]'],
+ });
+ const coreCapability = makeNode('Core Capability', 'Capability');
+ const subCapability = makeNode('Sub Capability', 'Capability', { parent: '[[Core Capability]]' });
+
+ resolveHierarchyEdges([activity, coreCapability, subCapability], levels);
+
+ // Regular relationship: Activity β Capabilities via capabilities field on parent
+ expect(coreCapability.resolvedParents).toContain('Activity 1');
+ expect(subCapability.resolvedParents).toContain('Activity 1');
+
+ // Same-type relationship: Capability β Capability via parent field on child
+ expect(subCapability.resolvedParents).toContain('Core Capability');
+
+ expect(activity.resolvedParents).toEqual([]);
+ expect(coreCapability.resolvedParents).toHaveLength(1);
+ expect(subCapability.resolvedParents).toHaveLength(2); // Both Activity 1 and Core Capability
+ });
+
+ it('supports same-type parents via selfRefField even when primary field is multiple=true', () => {
+ const levels = [
+ makeLevel('Activity'),
+ makeLevel('Tool', { field: 'tools', fieldOn: 'parent', multiple: true, selfRefField: 'partOf' }),
+ ];
+
+ const activity = makeNode('Activity 1', 'Activity', {
+ tools: ['[[Tool A]]', '[[Tool B]]', '[[Tool C]]'],
+ });
+ const toolA = makeNode('Tool A', 'Tool');
+ const toolB = makeNode('Tool B', 'Tool', { partOf: '[[Tool A]]' });
+ const toolC = makeNode('Tool C', 'Tool', { partOf: '[[Tool B]]' });
+
+ resolveHierarchyEdges([activity, toolA, toolB, toolC], levels);
+
+ // Regular relationships: Activity β Tools
+ expect(toolA.resolvedParents).toContain('Activity 1');
+ expect(toolB.resolvedParents).toContain('Activity 1');
+ expect(toolC.resolvedParents).toContain('Activity 1');
+
+ // Same-type relationships: Tools β Tools via partOf field (multiple: false)
+ expect(toolB.resolvedParents).toContain('Tool A');
+ expect(toolC.resolvedParents).toContain('Tool B');
+
+ expect(toolA.resolvedParents).toHaveLength(1);
+ expect(toolB.resolvedParents).toHaveLength(2);
+ expect(toolC.resolvedParents).toHaveLength(2);
+ });
+
+ it('selfRef without selfRefField uses field for both relationships', () => {
+ const levels = [makeLevel('Goal'), makeLevel('Objective', { selfRef: true })];
+
+ const goal = makeNode('Goal 1', 'Goal');
+ const objective1 = makeNode('Objective 1', 'Objective', { parent: '[[Goal 1]]' });
+ const objective2 = makeNode('Objective 2', 'Objective', { parent: '[[Objective 1]]' });
+
+ resolveHierarchyEdges([goal, objective1, objective2], levels);
+
+ // Regular: Goal β Objective
+ expect(objective1.resolvedParents).toContain('Goal 1');
+
+ // Same-type: Objective β Objective (uses same field 'parent')
+ expect(objective2.resolvedParents).toContain('Objective 1');
+
+ expect(goal.resolvedParents).toEqual([]);
+ expect(objective1.resolvedParents).toHaveLength(1);
+ expect(objective2.resolvedParents).toHaveLength(1);
+ });
+
+ it('handles missing selfRefField targets gracefully', () => {
+ const levels = [
+ makeLevel('Activity'),
+ makeLevel('Capability', { field: 'capabilities', fieldOn: 'parent', multiple: true, selfRefField: 'parent' }),
+ ];
+
+ const activity = makeNode('Activity 1', 'Activity', {
+ capabilities: ['[[Capability A]]'],
+ });
+ const capabilityA = makeNode('Capability A', 'Capability', {
+ parent: '[[Nonexistent Parent]]', // selfRefField target that doesn't exist
+ });
+
+ resolveHierarchyEdges([activity, capabilityA], levels);
+
+ // Regular relationship resolves
+ expect(capabilityA.resolvedParents).toContain('Activity 1');
+
+ // Missing selfRefField target is silently ignored
+ expect(capabilityA.resolvedParents).toHaveLength(1);
+ });
+ });
});
diff --git a/tests/template-sync.test.ts b/tests/template-sync.test.ts
new file mode 100644
index 0000000..47fab73
--- /dev/null
+++ b/tests/template-sync.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from 'bun:test';
+import type { AnySchemaObject } from 'ajv';
+import type { TypeVariant } from '../src/commands/template-sync';
+import { generateNewContent } from '../src/commands/template-sync';
+import type { SchemaWithMetadata } from '../src/types';
+
+describe('template-sync - generateNewContent', () => {
+ const schema: SchemaWithMetadata = {
+ title: 'Test Schema',
+ oneOf: [],
+ $metadata: {
+ relationships: [
+ {
+ parent: 'opportunity',
+ type: 'assumption',
+ format: 'table',
+ matchers: ['Assumptions'],
+ embeddedTemplateFields: ['assumption', 'status'],
+ multi: true,
+ },
+ {
+ parent: 'opportunity',
+ type: 'problem_statement',
+ format: 'heading',
+ matchers: ['What problem are we solving?'],
+ multi: false,
+ },
+ {
+ parent: 'opportunity',
+ type: 'solution',
+ format: 'list',
+ matchers: ['Solutions'],
+ multi: true,
+ },
+ ],
+ },
+ };
+
+ const variant: TypeVariant = {
+ required: [],
+ optional: ['status'],
+ properties: {
+ status: { type: 'string', enum: ['active', 'closed'] },
+ },
+ example: { type: 'opportunity' },
+ description: 'An opportunity',
+ relationships: schema.$metadata!.relationships!.filter((r) => r.parent === 'opportunity'),
+ };
+
+ const assumptionVariant: TypeVariant = {
+ required: ['assumption'],
+ optional: ['status'],
+ properties: { assumption: { type: 'string' }, status: { type: 'string' } },
+ example: { type: 'assumption', assumption: 'User will pay', status: 'high' },
+ description: 'An assumption',
+ relationships: [],
+ };
+
+ const allVariants = new Map([
+ ['opportunity', variant],
+ ['assumption', assumptionVariant],
+ ]);
+
+ const registry = new Map();
+
+ it('generates relationship stubs for new templates with examples', () => {
+ const content = generateNewContent('opportunity', variant, schema, registry, allVariants, '');
+
+ expect(content).toContain('### Assumptions');
+ expect(content).toContain('| assumption | status |');
+ expect(content).toContain('| User will pay | high |'); // from assumptionVariant example
+ expect(content).toContain('### What problem are we solving?');
+ expect(content).toContain('### Solutions');
+ expect(content).toContain('- [type:: solution] TODO');
+ });
+
+ it('is idempotent and does not duplicate stubs', () => {
+ const existingBody = '\n### Assumptions\n\n| assumption | status |\n| ---|---|\n| existing | active |\n';
+ const content = generateNewContent('opportunity', variant, schema, registry, allVariants, existingBody);
+
+ const assumptionMatches = content.match(/### Assumptions/g);
+ expect(assumptionMatches).toHaveLength(1);
+ expect(content).toContain('### What problem are we solving?'); // Still adds the ones that are missing
+ });
+});
diff --git a/tests/validate-general.test.ts b/tests/validate-general.test.ts
index ba85247..4b4c497 100644
--- a/tests/validate-general.test.ts
+++ b/tests/validate-general.test.ts
@@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from 'bun:test';
import { join } from 'node:path';
import { readSpaceDirectory } from '../src/read-space-directory';
import { readSpaceOnAPage } from '../src/read-space-on-a-page';
-import { resolveLinks } from '../src/resolve-links';
+import { resolveHierarchyEdges } from '../src/resolve-hierarchy-edges';
import { bundledSchemasDir, createValidator } from '../src/schema';
import type { SpaceNode } from '../src/types';
import { makeLevel } from './test-helpers';
@@ -142,7 +142,12 @@ describe('Schema validation', () => {
},
];
- resolveLinks(nodes, [makeLevel('vision'), makeLevel('mission'), makeLevel('goal'), makeLevel('solution')]);
+ resolveHierarchyEdges(nodes, [
+ makeLevel('vision'),
+ makeLevel('mission'),
+ makeLevel('goal'),
+ makeLevel('solution'),
+ ]);
expect(nodes.find((n) => n.label === 'Another Goal')?.schemaData.parent).toBe('[[anchor_vision#^mission]]');
expect(nodes.find((n) => n.label === 'Another Goal')?.resolvedParents[0]).toBe('Our Mission');
@@ -174,7 +179,12 @@ describe('Schema validation', () => {
},
];
- resolveLinks(nodes, [makeLevel('vision'), makeLevel('mission'), makeLevel('goal'), makeLevel('solution')]);
+ resolveHierarchyEdges(nodes, [
+ makeLevel('vision'),
+ makeLevel('mission'),
+ makeLevel('goal'),
+ makeLevel('solution'),
+ ]);
const errors = checkRefErrors(nodes);
expect(errors).toHaveLength(1);
@@ -216,7 +226,12 @@ describe('Schema validation', () => {
},
];
- resolveLinks(nodes, [makeLevel('vision'), makeLevel('mission'), makeLevel('goal'), makeLevel('solution')]);
+ resolveHierarchyEdges(nodes, [
+ makeLevel('vision'),
+ makeLevel('mission'),
+ makeLevel('goal'),
+ makeLevel('solution'),
+ ]);
expect(nodes.find((n) => n.label === 'solution_page.md')?.resolvedParents).toHaveLength(0);
const errors = checkRefErrors(nodes);
diff --git a/tests/validate-hierarchy.test.ts b/tests/validate-hierarchy.test.ts
index 2c376cf..fafeb2b 100644
--- a/tests/validate-hierarchy.test.ts
+++ b/tests/validate-hierarchy.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test';
import type { SchemaMetadata, SpaceNode } from '../src/types';
-import { validateHierarchy } from '../src/validate-hierarchy';
+import { validateHierarchyStructure } from '../src/validate-hierarchy';
import { makeLevel } from './test-helpers';
describe('validate-hierarchy', () => {
@@ -29,7 +29,7 @@ describe('validate-hierarchy', () => {
buildNode('My Goal', 'goal', 'My Mission'),
];
- const violations = validateHierarchy(nodes, metadata);
+ const violations = validateHierarchyStructure(nodes, metadata);
expect(violations).toHaveLength(0);
});
@@ -41,14 +41,14 @@ describe('validate-hierarchy', () => {
buildNode('Opportunity 1', 'opportunity', 'My Outcome'),
];
- const violations = validateHierarchy(nodes, metadata);
+ const violations = validateHierarchyStructure(nodes, metadata);
expect(violations).toHaveLength(0);
});
it('fails when node has same-type parent if selfRef is false for that type', () => {
const nodes: SpaceNode[] = [buildNode('Mission 1', 'mission'), buildNode('Mission 2', 'mission', 'Mission 1')];
- const violations = validateHierarchy(nodes, metadata);
+ const violations = validateHierarchyStructure(nodes, metadata);
expect(violations).toHaveLength(1);
expect(violations[0]?.nodeType).toBe('mission');
expect(violations[0]?.parentType).toBe('mission');
@@ -61,7 +61,7 @@ describe('validate-hierarchy', () => {
buildNode('My Goal', 'goal', 'My Vision'), // Skips mission
];
- const violations = validateHierarchy(nodes, metadata);
+ const violations = validateHierarchyStructure(nodes, metadata);
expect(violations).toHaveLength(1);
expect(violations[0]?.nodeType).toBe('goal');
expect(violations[0]?.parentType).toBe('vision');
@@ -71,7 +71,7 @@ describe('validate-hierarchy', () => {
it('fails when solution has goal as parent', () => {
const nodes: SpaceNode[] = [buildNode('My Goal', 'goal'), buildNode('My Solution', 'solution', 'My Goal')];
- const violations = validateHierarchy(nodes, metadata);
+ const violations = validateHierarchyStructure(nodes, metadata);
expect(violations).toHaveLength(1);
expect(violations[0]?.nodeType).toBe('solution');
expect(violations[0]?.parentType).toBe('goal');
@@ -94,7 +94,7 @@ describe('validate-hierarchy', () => {
buildNode('My Solution', 'solution', 'My Vision'), // Skips mission, goal, outcome, opportunity
];
- const violations = validateHierarchy(nodes, metadata);
+ const violations = validateHierarchyStructure(nodes, metadata);
expect(violations).toHaveLength(0);
});
@@ -104,7 +104,7 @@ describe('validate-hierarchy', () => {
buildNode('My Vision', 'vision', 'My Goal'), // Vision under goal - backwards
];
- const violations = validateHierarchy(nodes, metadata);
+ const violations = validateHierarchyStructure(nodes, metadata);
expect(violations).toHaveLength(1);
expect(violations[0]?.nodeType).toBe('vision');
expect(violations[0]?.parentType).toBe('goal');
@@ -124,14 +124,14 @@ describe('validate-hierarchy', () => {
it('skips nodes not in hierarchy', () => {
const nodes: SpaceNode[] = [buildNode('Dashboard', 'dashboard'), buildNode('Some Node', 'custom_type')];
- const violations = validateHierarchy(nodes, metadata);
+ const violations = validateHierarchyStructure(nodes, metadata);
expect(violations).toHaveLength(0);
});
it('skips nodes without a parent', () => {
const nodes: SpaceNode[] = [buildNode('My Vision', 'vision'), buildNode('Orphan Goal', 'goal')];
- const violations = validateHierarchy(nodes, metadata);
+ const violations = validateHierarchyStructure(nodes, metadata);
expect(violations).toHaveLength(0);
});
@@ -141,12 +141,12 @@ describe('validate-hierarchy', () => {
// whose parent cannot be found to avoid double-reporting.
const nodes: SpaceNode[] = [buildNode('My Goal', 'goal', 'Nonexistent Parent')];
- const violations = validateHierarchy(nodes, metadata);
+ const violations = validateHierarchyStructure(nodes, metadata);
expect(violations).toHaveLength(0);
});
it('handles empty nodes array', () => {
- const violations = validateHierarchy([], metadata);
+ const violations = validateHierarchyStructure([], metadata);
expect(violations).toHaveLength(0);
});
});
@@ -164,7 +164,7 @@ describe('validate-hierarchy', () => {
it('includes all required fields in violation', () => {
const nodes: SpaceNode[] = [buildNode('My Vision', 'vision'), buildNode('My Goal', 'goal', 'My Vision')];
- const violations = validateHierarchy(nodes, metadata);
+ const violations = validateHierarchyStructure(nodes, metadata);
expect(violations).toHaveLength(1);
const v = violations[0]!;
diff --git a/tests/validate-relationships.test.ts b/tests/validate-relationships.test.ts
new file mode 100644
index 0000000..0f5e312
--- /dev/null
+++ b/tests/validate-relationships.test.ts
@@ -0,0 +1,211 @@
+import { describe, expect, it } from 'bun:test';
+import type { SchemaMetadata, SpaceNode } from '../src/types';
+import { validateRelationships } from '../src/validate-hierarchy';
+import { buildTargetIndex } from '../src/wikilink-utils';
+
+function makeNode(title: string, type: string, extra: Record = {}, linkTargets?: string[]): SpaceNode {
+ return {
+ label: `${title}.md`,
+ schemaData: { title, type, ...extra },
+ linkTargets: linkTargets ?? [title],
+ resolvedParents: [],
+ resolvedType: type,
+ };
+}
+
+describe('validateRelationships', () => {
+ const metadata = {
+ hierarchy: { levels: [{ type: 'opportunity' }] },
+ relationships: [
+ {
+ parent: 'opportunity',
+ type: 'assumption',
+ format: 'table',
+ },
+ ],
+ } as SchemaMetadata;
+
+ it('passes when child linked to correct parent type', () => {
+ const opp = makeNode('Opp 1', 'opportunity');
+ const assumption = makeNode('Assumption 1', 'assumption', { parent: '[[Opp 1]]' });
+
+ const nodes = [opp, assumption];
+ const index = buildTargetIndex(nodes);
+
+ const { violations, refErrors } = validateRelationships(nodes, metadata, index);
+
+ expect(violations).toBeEmpty();
+ expect(refErrors).toBeEmpty();
+ });
+
+ it('fails when child linked to missing parent', () => {
+ const assumption = makeNode('Assumption 1', 'assumption', { parent: '[[Missing Opp]]' });
+
+ const nodes = [assumption];
+ const index = buildTargetIndex(nodes);
+
+ const { violations, refErrors } = validateRelationships(nodes, metadata, index);
+
+ expect(violations).toBeEmpty();
+ expect(refErrors).toHaveLength(1);
+ expect(refErrors[0]?.error).toContain('not found');
+ });
+
+ it('fails (type mismatch) when child linked to node of incorrect type', () => {
+ const someOtherNode = makeNode('Wrong Type Node', 'solution');
+ const assumption = makeNode('Assumption 1', 'assumption', { parent: '[[Wrong Type Node]]' });
+
+ const nodes = [someOtherNode, assumption];
+ const index = buildTargetIndex(nodes);
+
+ const { violations, refErrors } = validateRelationships(nodes, metadata, index);
+
+ expect(refErrors).toBeEmpty();
+ expect(violations).toHaveLength(1);
+ expect(violations[0]?.description).toContain('expected opportunity');
+ });
+
+ it('skips child nodes with no parent field (no false errors)', () => {
+ const assumption = makeNode('Assumption 1', 'assumption');
+
+ const nodes = [assumption];
+ const index = buildTargetIndex(nodes);
+
+ const { violations, refErrors } = validateRelationships(nodes, metadata, index);
+
+ expect(violations).toBeEmpty();
+ expect(refErrors).toBeEmpty();
+ });
+
+ it('uses custom field name when fieldOn is child', () => {
+ const metaWithCustomField = {
+ hierarchy: { levels: [{ type: 'opportunity' }] },
+ relationships: [
+ {
+ parent: 'opportunity',
+ type: 'assumption',
+ field: 'linked_opportunity',
+ fieldOn: 'child',
+ },
+ ],
+ } as SchemaMetadata;
+
+ const opp = makeNode('Opp 1', 'opportunity');
+ const assumption = makeNode('Assumption 1', 'assumption', { linked_opportunity: '[[Opp 1]]' });
+
+ const nodes = [opp, assumption];
+ const index = buildTargetIndex(nodes);
+
+ const { violations, refErrors } = validateRelationships(nodes, metaWithCustomField, index);
+
+ expect(violations).toBeEmpty();
+ expect(refErrors).toBeEmpty();
+ });
+
+ it('reports error for custom field pointing to wrong type', () => {
+ const metaWithCustomField = {
+ hierarchy: { levels: [{ type: 'opportunity' }] },
+ relationships: [
+ {
+ parent: 'opportunity',
+ type: 'assumption',
+ field: 'linked_opportunity',
+ fieldOn: 'child',
+ },
+ ],
+ } as SchemaMetadata;
+
+ const solution = makeNode('Solution 1', 'solution');
+ const assumption = makeNode('Assumption 1', 'assumption', { linked_opportunity: '[[Solution 1]]' });
+
+ const nodes = [solution, assumption];
+ const index = buildTargetIndex(nodes);
+
+ const { violations, refErrors } = validateRelationships(nodes, metaWithCustomField, index);
+
+ expect(refErrors).toBeEmpty();
+ expect(violations).toHaveLength(1);
+ expect(violations[0]?.description).toContain('expected opportunity');
+ });
+});
+
+describe('validateRelationships β fieldOn: parent', () => {
+ const metadata = {
+ hierarchy: { levels: [{ type: 'activity' }] },
+ relationships: [
+ {
+ parent: 'activity',
+ type: 'task',
+ field: 'tasks',
+ fieldOn: 'parent',
+ multi: true,
+ },
+ ],
+ } as SchemaMetadata;
+
+ it('passes when parent field array contains correct child types', () => {
+ const task1 = makeNode('Task 1', 'task');
+ const task2 = makeNode('Task 2', 'task');
+ const activity = makeNode('Activity 1', 'activity', { tasks: ['[[Task 1]]', '[[Task 2]]'] });
+
+ const nodes = [activity, task1, task2];
+ const index = buildTargetIndex(nodes);
+
+ const { violations, refErrors } = validateRelationships(nodes, metadata, index);
+
+ expect(violations).toBeEmpty();
+ expect(refErrors).toBeEmpty();
+ });
+
+ it('fails when parent field array contains a missing link', () => {
+ const activity = makeNode('Activity 1', 'activity', { tasks: ['[[Missing Task]]'] });
+
+ const nodes = [activity];
+ const index = buildTargetIndex(nodes);
+
+ const { violations, refErrors } = validateRelationships(nodes, metadata, index);
+
+ expect(violations).toBeEmpty();
+ expect(refErrors).toHaveLength(1);
+ expect(refErrors[0]?.error).toContain('not found');
+ });
+
+ it('fails (type mismatch) when parent field array entry is wrong type', () => {
+ const wrong = makeNode('Some Solution', 'solution');
+ const activity = makeNode('Activity 1', 'activity', { tasks: ['[[Some Solution]]'] });
+
+ const nodes = [activity, wrong];
+ const index = buildTargetIndex(nodes);
+
+ const { violations, refErrors } = validateRelationships(nodes, metadata, index);
+
+ expect(refErrors).toBeEmpty();
+ expect(violations).toHaveLength(1);
+ expect(violations[0]?.description).toContain('expected task');
+ });
+
+ it('skips parent nodes with no field value (no false errors)', () => {
+ const activity = makeNode('Activity 1', 'activity');
+
+ const nodes = [activity];
+ const index = buildTargetIndex(nodes);
+
+ const { violations, refErrors } = validateRelationships(nodes, metadata, index);
+
+ expect(violations).toBeEmpty();
+ expect(refErrors).toBeEmpty();
+ });
+
+ it('reports error when field is not an array', () => {
+ const activity = makeNode('Activity 1', 'activity', { tasks: '[[Task 1]]' });
+
+ const nodes = [activity];
+ const index = buildTargetIndex(nodes);
+
+ const { violations, refErrors } = validateRelationships(nodes, metadata, index);
+
+ expect(violations).toBeEmpty();
+ expect(refErrors).toHaveLength(1);
+ expect(refErrors[0]?.error).toContain('must be an array');
+ });
+});