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'); + }); +});