diff --git a/README.md b/README.md index d6a663a..1756dca 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ The `selfRefField` property enables different fields for regular vs same-type re | `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 | +| `multiple` | `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]]"]`). diff --git a/docs/architecture.md b/docs/architecture.md index f1f0a52..68661a2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,8 +55,8 @@ flowchart LR | Boundary | Data | |---|---| | Space → Read | Raw markdown files / `space_on_a_page` file | -| Read → Nodes | `SpaceNode[]` — schemaData (canonical fields), resolvedType, resolvedParents, linkTargets | -| Schema → Read | Hierarchy levels (type names, edge fields, direction, cardinality), type aliases | +| Read → Nodes | `SpaceNode[]` — schemaData (canonical fields), resolvedType, resolvedParents (`ResolvedParentRef[]`), linkTargets | +| Schema → Read | Hierarchy levels + relationships (type names, edge fields, direction, cardinality), type aliases | | Schema → Validate | AJV validator, hierarchy rules, JSONata rule expressions | | Nodes → Output | Validated node set; output commands interpret as needed | | Config → Output | `fieldMap` (reverse) applied by template-sync for file field names | diff --git a/docs/concepts.md b/docs/concepts.md index 133fbc9..6a7cb3c 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -146,25 +146,20 @@ See [docs/rules.md](rules.md) for the rules reference, including JSONata express ## Hierarchy -The **hierarchy** is the ordered list of node types in a space, from root to leaf. It is defined in the schema's `$metadata.hierarchy.levels` array and drives depth-based type inference (for `space on a page`), tree rendering, and hierarchy validation. The root type has no parent; every other type has parents in the level immediately above (unless `$metadata.hierarchy.allowSkipLevels` is set). +The **hierarchy** is the ordered list of node types in a space, from root to leaf. It is defined in the schema's `$metadata.hierarchy.levels` array and drives depth-based type inference (for `space on a page`), tree rendering, and structural validation. The root type has no parent; every other type has parents in the level immediately above (unless `$metadata.hierarchy.allowSkipLevels` is set). -Relationships between levels are modelled as a layered DAG: a non-root node may have zero parents (orphaned), one parent, or multiple parents. The `show` command renders this as an indented tree, marking repeated nodes with `(*)` where the subtree is already shown elsewhere. +The hierarchy is modelled as a layered DAG: a non-root node may have zero parents (orphaned), one parent, or multiple parents. The `show` command renders this as an indented tree, marking repeated nodes with `(*)` where the subtree is already shown elsewhere. -### Edge configuration - -A **hierarchy edge** is a directional link connecting a child node to one or more parent nodes. Each non-root level in the hierarchy defines how its edges are expressed in frontmatter. The default is a single `parent` wikilink on the child node, but any field name, direction, and cardinality can be configured. +Each non-root level uses the shared `field`, `fieldOn`, and `multiple` edge options (see [Graph edges](#graph-edges)). Two additional options are hierarchy-specific: | Option | Default | Meaning | |---|---|---| -| `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 (uses `field` for same-type relationships) | -| `selfRefField` | _undefined_ | When set, specifies a different field for same-type parent relationships (always on child-side) | +| `selfRef` | `false` | When `true`, a node may have a parent of the same resolved type, using `field` for both regular and same-type parents | +| `selfRefField` | _undefined_ | When set, specifies a separate field for same-type parent relationships (always on the child). Requires `selfRef: true`. | **Example: Activities listing Capabilities with sub-capabilities** -``` +```json "levels": [ "Activities", { @@ -177,41 +172,63 @@ A **hierarchy edge** is a directional link connecting a child node to one or mor ] ``` -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) +This defines two edge types for Capabilities: +- **Activities → Capabilities**: Via `capabilities` array field on Activity nodes +- **Capability → Capability**: Via `parent` field on Capability nodes (same-type, child-side) -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. +## Relationships -### Resolved parents +A **relationship** is a link between a parent type and a child type that is not part of the primary structural hierarchy. For example, an `opportunity` might have a relationship with `assumption` (multiple) or `problem_statement` (single). -**Resolved parents** (`resolvedParents`) is the set of parent node titles derived from a node's edge field(s) at *parse* time. It is always an array (empty if unresolved or root-level). Tooling uses `resolvedParents` for tree rendering, hierarchy validation, rule evaluation, and diagram/Miro sync. +Relationships are defined in `$metadata.relationships`. Like hierarchy levels, they use the shared `field`, `fieldOn`, and `multiple` edge options (see [Graph edges](#graph-edges)), but carry additional metadata used for parsing and template generation: -### Wikilink +- **`parent`** / **`type`** — the parent and child canonical types (required) +- **`format`** — parsing/generation hint: `"heading"`, `"list"`, `"table"`, or `"page"` +- **`matchers`** — heading text patterns (strings or `/regex/`) used to detect relationship sections during embedded parsing + +A heading in a typed page that matches a relationship's `matchers` signals to the parser that following content (single nodes, list items, or table rows) should be typed as that relationship's child type — without requiring explicit inline `[type:: x]` annotations. + +--- -A **wikilink** is the `[[Title]]` linking syntax (compatible with Obsidian) used to express hierarchy edges between `space nodes`. Any edge field — whether named `parent` or a custom name — holds wikilinks to linked nodes. +## Graph edges -Two forms are supported: +Both `hierarchy.levels` and `relationships` define **edges** in a directed graph over the node set. All edges use the same three configuration options: + +| Option | Default | Meaning | +|---|---|---| +| `field` | `"parent"` | The frontmatter field holding the wikilink(s) | +| `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 | + +The `fieldOn: "parent"` pattern is used when the content model lists children on the parent node (e.g. `tasks: ["[[Task A]]", "[[Task B]]"]`). Embedded parsing then appends child wikilinks to the parent's field array rather than setting a `parent` field on each child. + +Dangling wikilinks — edge field values that do not resolve to any known node — are reported as reference errors during validation. + +### Wikilink + +A **wikilink** is the `[[Title]]` linking syntax (compatible with Obsidian) used in edge fields to reference other `space nodes`. Two forms are supported: | Form | Example | Resolves to | |---|---|---| | 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). +### Resolved parents -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. +**Resolved parents** (`resolvedParents`) is the set of parent references derived from a node's edge fields at *parse* time. It is always an array (empty if unresolved or root-level). Both `hierarchy.levels` and `relationships` edges resolve into this single array — forming a unified labelled directed graph over the node set. -Relationships support two link directions via `field` and `fieldOn`: +Each entry is a `ResolvedParentRef` object: -- **`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. +| Field | Type | Description | +|---|---|---| +| `title` | `string` | The parent node's title | +| `field` | `string` | The frontmatter field that held the wikilink | +| `source` | `'hierarchy' \| 'relationship'` | Whether the edge came from a hierarchy level or a relationship | +| `selfRef` | `boolean` | Whether the edge is a same-type (self-referential) parent link | -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. +The `source` label lets downstream consumers distinguish edge types without re-inspecting the schema. Validation routes `hierarchy` edges to structural checks (parent-type rules, skip-level detection) and `relationship` edges to field reference checks (type-match, missing-target). Tree rendering and rule evaluation use the full set. ### Anchor diff --git a/docs/schemas.md b/docs/schemas.md index 2511a2c..9d0db16 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -97,13 +97,13 @@ 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 | +| `relationships` | `Relationship[]` | Optional; defines related node links outside the primary hierarchy | | `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. +Relationships define links between node types that are not part of the primary structural hierarchy. They are handled during parsing and template generation. | Field | Type | Default | Description | |---|---|---|---| @@ -114,7 +114,7 @@ Adjacent relationships define how related nodes (not part of the hierarchy) are | `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 | +| `multiple` | `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. @@ -145,7 +145,7 @@ Adjacent relationships define how related nodes (not part of the hierarchy) are "fieldOn": "parent", "format": "list", "matchers": ["Tasks"], - "multi": true + "multiple": true } ] ``` diff --git a/schemas/general.json b/schemas/general.json index 4cc41b7..cfa970d 100644 --- a/schemas/general.json +++ b/schemas/general.json @@ -25,7 +25,7 @@ "type": "problem_statement", "format": "heading", "matchers": ["Problem statement", "What problem are we solving?"], - "multi": false + "multiple": false }, { "parent": "opportunity", @@ -33,7 +33,7 @@ "format": "table", "matchers": ["Assumptions", "^Assumptions?$"], "embeddedTemplateFields": ["assumption", "status", "confidence"], - "multi": true + "multiple": true } ] }, diff --git a/schemas/generated/_ost_tools_schema_meta.json b/schemas/generated/_ost_tools_schema_meta.json index cf494eb..7743f0d 100644 --- a/schemas/generated/_ost_tools_schema_meta.json +++ b/schemas/generated/_ost_tools_schema_meta.json @@ -86,7 +86,7 @@ "format": { "enum": ["heading", "list", "table", "page"] }, - "multi": { + "multiple": { "type": "boolean" }, "matchers": { diff --git a/skills/ost-tools/references/schema-authoring.md b/skills/ost-tools/references/schema-authoring.md index 274d563..218287e 100644 --- a/skills/ost-tools/references/schema-authoring.md +++ b/skills/ost-tools/references/schema-authoring.md @@ -46,9 +46,9 @@ Use object entries to override defaults: - `multiple: true` for array wikilinks - `selfRef: true` for same-type parent links -### Adjacent Relationships (`$metadata.relationships`) +### Relationships (`$metadata.relationships`) -Adjacent relationships define how sub-entities (nodes inside other files) are parsed and generated. +Relationships define how sub-entities (nodes inside other files) are parsed and generated. | Field | Default | Description | |---|---|---| @@ -58,7 +58,7 @@ Adjacent relationships define how sub-entities (nodes inside other files) are pa | `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 | +| `multiple` | `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`. @@ -146,6 +146,12 @@ Each rule evaluation receives: `nodes`, `current`, `parent`, `parents`. Use `resolvedType` in comparisons (not raw `type`) so aliases are respected. +Each node also carries convenience fields for common queries: +- `resolvedParentTitle` — title of the first resolved parent (or `undefined`) +- `resolvedParentTitles` — array of all resolved parent titles + +`resolvedParents` on the raw node is an array of `ResolvedParentRef` objects (`{ title, field, source, selfRef }`); use the convenience fields for simple title-matching. + ```jsonata $count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution']) ``` diff --git a/src/commands/validate.ts b/src/commands/validate.ts index b519970..4f76026 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,10 +1,9 @@ import type { ErrorObject } from 'ajv'; import { readSpace } from '../read/read-space'; -import { buildTargetIndex } from '../read/wikilink-utils'; import { buildFullRegistry, createValidator, loadMetadata, readRawSchema } from '../schema/schema'; -import { validateHierarchyWithFields, validateRelationships } from '../schema/validate-hierarchy'; +import { validateGraph } from '../schema/validate-graph'; import { validateRules } from '../schema/validate-rules'; -import type { HierarchyViolation, RuleViolation } from '../types'; +import type { GraphViolation, RuleViolation } from '../types'; import { classifyNodes } from '../util/graph-helpers'; import { extractEntityInfo } from './schemas'; @@ -20,7 +19,7 @@ interface ValidationResult { refErrors: Array<{ file: string; parent: string; error: string }>; duplicateErrors: Array<{ title: string; files: string[] }>; ruleViolations: RuleViolation[]; - hierarchyViolations: HierarchyViolation[]; + hierarchyViolations: GraphViolation[]; orphanCount: number; skipped: string[]; nonSpace: string[]; @@ -187,12 +186,10 @@ export async function validate(path: string, options: { schema: string; template } // Validate all hierarchy constraints (field references and structure) - const linkTargetIndex = buildTargetIndex(nodes); - const hierarchyValidation = validateHierarchyWithFields(nodes, metadata); - const relValidation = validateRelationships(nodes, metadata, linkTargetIndex); + const hierarchyValidation = validateGraph(nodes, metadata); - result.refErrors.push(...hierarchyValidation.refErrors, ...relValidation.refErrors); - result.hierarchyViolations = [...hierarchyValidation.violations, ...relValidation.violations]; + result.refErrors.push(...hierarchyValidation.refErrors); + result.hierarchyViolations = [...hierarchyValidation.violations]; // Calculate orphan count (informational, not a validation error) if (metadata.hierarchy) { diff --git a/src/integrations/miro/cache.ts b/src/integrations/miro/cache.ts index 222a5fb..7a5b3b6 100644 --- a/src/integrations/miro/cache.ts +++ b/src/integrations/miro/cache.ts @@ -54,7 +54,7 @@ export function computeNodeHash(node: SpaceNode): string { status: node.schemaData.status, summary: node.schemaData.summary, priority: node.schemaData.priority, - parents: node.resolvedParents, + parents: node.resolvedParents.filter((r) => r.source === 'hierarchy').map((r) => r.title), }; return createHash('sha256').update(JSON.stringify(relevant)).digest('hex').slice(0, 16); } diff --git a/src/integrations/miro/sync.ts b/src/integrations/miro/sync.ts index c92fd02..5ccafe1 100644 --- a/src/integrations/miro/sync.ts +++ b/src/integrations/miro/sync.ts @@ -325,7 +325,8 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi const desiredEdges = new Map(); for (const node of nodes) { const childTitle = node.schemaData.title as string; - for (const parentTitle of node.resolvedParents) { + for (const { title: parentTitle, source } of node.resolvedParents) { + if (source !== 'hierarchy') continue; // Both endpoints must have verified cards on the board if (verifiedCardIds.has(parentTitle) && verifiedCardIds.has(childTitle)) { const key = `${parentTitle}\u2192${childTitle}`; diff --git a/src/read/parse-embedded.ts b/src/read/parse-embedded.ts index 3614e69..867d12a 100644 --- a/src/read/parse-embedded.ts +++ b/src/read/parse-embedded.ts @@ -23,6 +23,11 @@ export interface StackEntry { refTarget: string; } +/** Internal unified match result for both hierarchy and relationships. */ +interface UnifiedMatch extends MetadataContractRelationship { + isHierarchy: boolean; +} + /** Extract [key:: value] bracketed inline fields, return cleaned text and fields. */ export function extractBracketedFields(text: string): { cleanText: string; @@ -111,6 +116,7 @@ export function anchorToNodeType( * Turn a full heading string into an Obsidian section-target key component. * - normalizes observed Obsidian separators (#, ^, :, \) to spaces * - compresses whitespace runs to single spaces + * - does _NOT_ (and should not) manipulate anchors or inline fields */ export function normalizeHeadingSectionTarget(rawHeadingText: string): string { return rawHeadingText @@ -268,7 +274,8 @@ export interface ExtractEmbeddedResult { */ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptions): ExtractEmbeddedResult { const { pageTitle, pageType, metadata, fieldMap } = options; - const hierarchy = metadata.hierarchy?.levels.map((l) => l.type) ?? []; + const levels = metadata.hierarchy?.levels ?? []; + const hierarchy = levels.map((l) => l.type); const relationships = metadata.relationships ?? []; const typeAliases = metadata.typeAliases ?? {}; const isOnAPageMode = pageType === undefined || ON_A_PAGE_TYPES.includes(pageType); @@ -307,26 +314,47 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio return undefined; } - function matchRelationship(title: string, parentType: string | undefined): MetadataContractRelationship | undefined { + function matchUnified(title: string, parentType: string | undefined): UnifiedMatch | undefined { if (!parentType) return undefined; const lowerTitle = title.toLowerCase(); + + // 1. Check relationships first (explicit matches) 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; + if (new RegExp(matcher, 'i').test(title)) return { ...rel, isHierarchy: false }; } else if (matcher.startsWith('/') && matcher.endsWith('/')) { const pattern = matcher.slice(1, -1); - if (new RegExp(pattern, 'i').test(title)) return rel; + if (new RegExp(pattern, 'i').test(title)) return { ...rel, isHierarchy: false }; } else if (lowerTitle === matcher.toLowerCase()) { - return rel; + return { ...rel, isHierarchy: false }; } } if (lowerTitle === rel.type.toLowerCase()) { - return rel; // fallback implicit match + return { ...rel, isHierarchy: false }; // fallback implicit match } } } + + // 2. Check hierarchy (implicit matches) + const parentIdx = hierarchy.indexOf(parentType); + if (parentIdx !== -1 && parentIdx < hierarchy.length - 1) { + const nextType = hierarchy[parentIdx + 1]!; + const level = levels[parentIdx + 1]!; + if (lowerTitle === nextType.toLowerCase()) { + return { + parent: parentType, + type: nextType, + field: level.field, + fieldOn: level.fieldOn, + multiple: level.multiple, + format: 'heading', // Default for hierarchy + isHierarchy: true, + }; + } + } + return undefined; } @@ -373,7 +401,7 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio return normalized ? [`${pageTitle}#${normalized}`] : [title]; } - let pendingRelationship: MetadataContractRelationship | undefined; + let pendingMatch: UnifiedMatch | undefined; let currentActiveNode: SpaceNode = rootNode; for (const child of tree.children) { @@ -406,16 +434,16 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio const parentContextType = getParentContextType(); const anchorType = anchor ? anchorToNodeType(anchor, hierarchy, relationships) : undefined; - const relationshipMatch = matchRelationship(title, parentContextType); + const unifiedMatch = matchUnified(title, parentContextType); const hasExplicitType = !!inlineFields.type; - const hasImpliedType = !!anchorType || !!relationshipMatch; + const hasImpliedType = !!anchorType || !!unifiedMatch; if (!isOnAPageMode && !hasExplicitType && !hasImpliedType) { while (stack.length > 0 && stack[stack.length - 1]!.depth >= depth) { stack.pop(); } stack.push({ depth, title, nodeType: '', refTarget: title }); - pendingRelationship = undefined; + pendingMatch = undefined; continue; } @@ -430,7 +458,7 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio stack.pop(); } - const type = inlineFields.type ?? anchorType ?? relationshipMatch?.type ?? defaultNodeType(stack, hierarchy); + const type = inlineFields.type ?? anchorType ?? unifiedMatch?.type ?? defaultNodeType(stack, hierarchy); const parentRef = currentParentRef(); const schemaData: Record = { @@ -450,18 +478,15 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio resolvedType: resolveNodeType(type, typeAliases), }; - // If this match came from a relationship and no explicit type was given, + // If this match came from a relationship/hierarchy 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. + if (!hasExplicitType && !anchorType && unifiedMatch) { + pendingMatch = unifiedMatch; currentActiveNode = headingNode; } else { nodes.push(headingNode); currentActiveNode = headingNode; - pendingRelationship = undefined; + pendingMatch = undefined; } const refTarget = linkTargets[0] ?? title; @@ -472,7 +497,7 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio const parentRef = currentParentRef(); const list = child as List; - if (pendingRelationship) { + if (pendingMatch) { // Grandparent is the true semantic parent for relationship-driven items let semanticParentRef = parentRef; let semanticParentNode: SpaceNode | undefined; @@ -485,10 +510,10 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio } } - const isParentSide = pendingRelationship.fieldOn === 'parent'; + const isParentSide = pendingMatch.fieldOn === 'parent'; const parentFieldAppend = - isParentSide && semanticParentNode && pendingRelationship.field - ? { node: semanticParentNode, field: pendingRelationship.field } + isParentSide && semanticParentNode && pendingMatch.field + ? { node: semanticParentNode, field: pendingMatch.field } : undefined; for (const item of list.children) { @@ -501,11 +526,11 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio buildListItemLinkTargets, typeAliases, fieldMap, - pendingRelationship.type, + pendingMatch.type, parentFieldAppend, ); } - pendingRelationship = undefined; + pendingMatch = undefined; } else { for (const item of list.children) { processListItem( @@ -532,27 +557,34 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio const firstColName = columnNames[0]?.toLowerCase(); let rowTypeStr: string | undefined; + let activeMatch = pendingMatch; - if (pendingRelationship) { - rowTypeStr = pendingRelationship.type; + if (activeMatch) { + rowTypeStr = activeMatch.type; } else if (firstColName) { if (hierarchy.includes(firstColName) || typeAliases[firstColName]) { rowTypeStr = firstColName; } else { - const rootRel = matchRelationship(firstColName, parentContextType); - if (rootRel) rowTypeStr = rootRel.type; + const rootRel = matchUnified(firstColName, parentContextType); + if (rootRel) { + rowTypeStr = rootRel.type; + activeMatch = rootRel; + } } } if (!rowTypeStr && currentActiveNode !== rootNode && currentActiveNode.schemaData.type) { - const contextAsParentRel = matchRelationship(firstColName || '', currentActiveNode.schemaData.type as string); - if (contextAsParentRel) rowTypeStr = contextAsParentRel.type; + const contextAsParentRel = matchUnified(firstColName || '', currentActiveNode.schemaData.type as string); + if (contextAsParentRel) { + rowTypeStr = contextAsParentRel.type; + activeMatch = contextAsParentRel; + } } if (rowTypeStr) { let semanticParentRef = parentRef; let semanticParentNode: SpaceNode | undefined; - if (pendingRelationship || rowTypeStr === parentContextType) { + if (activeMatch || rowTypeStr === parentContextType) { for (let i = stack.length - 2; i >= 0; i--) { if (stack[i]!.nodeType !== '') { semanticParentRef = `[[${stack[i]!.refTarget}]]`; @@ -563,10 +595,10 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio } } - const isParentSide = pendingRelationship?.fieldOn === 'parent'; + const isParentSide = activeMatch?.fieldOn === 'parent'; const tableParentFieldAppend = - isParentSide && semanticParentNode && pendingRelationship?.field - ? { node: semanticParentNode, field: pendingRelationship.field } + isParentSide && semanticParentNode && activeMatch?.field + ? { node: semanticParentNode, field: activeMatch.field } : undefined; for (const row of rows) { @@ -625,17 +657,17 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio } } } - pendingRelationship = undefined; + pendingMatch = undefined; } else { appendContent(currentActiveNode, mdastToString(child)); } } } else { - // For any other content (paragraph, code, etc), if we had a pending relationship, + // For any other content (paragraph, code, etc), if we had a pending match, // it means the heading itself is the node. Add it now. - if (pendingRelationship) { + if (pendingMatch) { nodes.push(currentActiveNode); - pendingRelationship = undefined; + pendingMatch = undefined; } if (child.type === 'paragraph') { diff --git a/src/read/read-space.ts b/src/read/read-space.ts index c7f1463..6cbfe9a 100644 --- a/src/read/read-space.ts +++ b/src/read/read-space.ts @@ -6,7 +6,7 @@ import { applyFieldMap, loadConfig, resolveSchema } from '../config'; import { loadMetadata, resolveNodeType } from '../schema/schema'; import type { SchemaMetadata, SpaceDirectoryReadResult, SpaceNode, SpaceOnAPageReadResult } from '../types'; import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded'; -import { resolveHierarchyEdges } from './resolve-hierarchy-edges'; +import { resolveGraphEdges } from './resolve-graph-edges'; export interface ReadSpaceDirectoryOptions { includeOnAPageFiles?: boolean; @@ -59,7 +59,8 @@ export function readSpaceOnAPage(filePath: string, schemaPath?: string): SpaceOn pageType: 'space_on_a_page', metadata, }); - resolveHierarchyEdges(nodes, hierarchyLevels); + + resolveGraphEdges(nodes, hierarchyLevels, metadata.relationships, metadata.typeAliases); return { nodes, diagnostics }; } @@ -109,11 +110,12 @@ export async function readSpaceDirectory( const pageType = data.type as string; const fileBase = basename(file, '.md'); + const title = (data.title as string) ?? fileBase; nodes.push({ label: file, - schemaData: { title: fileBase, ...data }, - linkTargets: [fileBase], + schemaData: { title, ...data }, + linkTargets: [title, fileBase], resolvedParents: [], resolvedType: resolveNodeType(pageType, metadata.typeAliases), }); @@ -129,7 +131,7 @@ export async function readSpaceDirectory( } } - resolveHierarchyEdges(nodes, hierarchyLevels); + resolveGraphEdges(nodes, hierarchyLevels, metadata.relationships, metadata.typeAliases); return { nodes, skipped, nonSpace }; } @@ -139,3 +141,4 @@ export async function readSpace(path: string, options: ReadSpaceDirectoryOptions } return { kind: 'directory', ...(await readSpaceDirectory(path, options)) }; } +resolveGraphEdges; diff --git a/src/read/resolve-graph-edges.ts b/src/read/resolve-graph-edges.ts new file mode 100644 index 0000000..b50543c --- /dev/null +++ b/src/read/resolve-graph-edges.ts @@ -0,0 +1,172 @@ +import type { MetadataContractRelationship } from '../schema/metadata-contract'; +import { resolveNodeType } from '../schema/schema'; +import type { EdgeDefinition, HierarchyLevel, ResolvedParentRef, 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 parent-child links for a specific edge definition, pushing a ResolvedParentRef + * onto each child's resolvedParents array. + * + * For fieldOn:'child' edges, any existing node is accepted as the parent target (permissive). + * Type correctness is enforced by the validator, not the resolver. + * + * For fieldOn:'parent' edges, only nodes of the expected child type are updated, since the + * parent explicitly enumerates its children and filtering is necessary to target the right nodes. + * + * @param nodesByType Map of node type to nodes + * @param targetIndex Map of link targets to nodes + * @param edge The edge definition (child type, parent type, field, fieldOn, multiple) + * @param source Whether this edge comes from hierarchy.levels or relationships + * @param selfRef Whether child and parent are the same node type + * @param typeAliases Optional type aliases for resolution + */ +function resolveEdge( + nodesByType: Map, + targetIndex: Map, + edge: EdgeDefinition, + source: ResolvedParentRef['source'], + selfRef: boolean, + typeAliases?: Record, +): void { + const { type: rawChildType, parent: rawParentType, field, fieldOn, multiple } = edge; + + const childType = resolveNodeType(rawChildType, typeAliases); + const parentType = resolveNodeType(rawParentType, typeAliases); + + function pushParentRef(childNode: SpaceNode, parentTitle: string): void { + // Deduplicate by (field, title) — same parent via different fields is intentional + if (!childNode.resolvedParents.some((r) => r.field === field && r.title === parentTitle)) { + childNode.resolvedParents.push({ title: parentTitle, field, source, selfRef }); + } + } + + if (fieldOn === 'parent') { + // Parent nodes have the field pointing to children; filter by expected child type + 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 || childNode.resolvedType !== childType) continue; + const parentTitle = parentNode.schemaData.title; + if (typeof parentTitle !== 'string') continue; + pushParentRef(childNode, parentTitle); + } + } + } else { + // Child nodes have the field pointing to parents; accept any resolved target + 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; + pushParentRef(childNode, parentTitle); + } + } + } +} + +/** + * Resolve parent links using the hierarchy levels and relationships configuration from schema metadata. + * Supports DAG relationships via configurable edge fields. + */ +export function resolveGraphEdges( + nodes: SpaceNode[], + levels: HierarchyLevel[], + relationships: MetadataContractRelationship[] = [], + typeAliases?: Record, +): 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); + } + + // 1. Process hierarchy levels + for (let i = 0; i < levels.length; i++) { + const level = levels[i]!; + + // Regular relationship (child type → parent type) + if (i > 0) { + const parentLevel = levels[i - 1]!; + resolveEdge( + nodesByType, + targetIndex, + { + type: level.type, + parent: parentLevel.type, + field: level.field, + fieldOn: level.fieldOn, + multiple: level.multiple, + }, + 'hierarchy', + false, + typeAliases, + ); + } + + // Same-type relationship (child type → same type) via primary field + if (level.selfRef) { + resolveEdge( + nodesByType, + targetIndex, + { type: level.type, parent: level.type, field: level.field, fieldOn: level.fieldOn, multiple: level.multiple }, + 'hierarchy', + true, + typeAliases, + ); + } + + // Same-type relationship via explicit selfRefField + if (level.selfRefField) { + resolveEdge( + nodesByType, + targetIndex, + { type: level.type, parent: level.type, field: level.selfRefField, fieldOn: 'child', multiple: false }, + 'hierarchy', + true, + typeAliases, + ); + } + } + + // 2. Process additional relationships + for (const rel of relationships) { + const edge: EdgeDefinition = { + type: rel.type, + parent: rel.parent, + field: rel.field ?? 'parent', + fieldOn: rel.fieldOn ?? 'child', + multiple: rel.multiple ?? false, + }; + resolveEdge(nodesByType, targetIndex, edge, 'relationship', rel.type === rel.parent, typeAliases); + } +} diff --git a/src/read/resolve-hierarchy-edges.ts b/src/read/resolve-hierarchy-edges.ts deleted file mode 100644 index f5c14bd..0000000 --- a/src/read/resolve-hierarchy-edges.ts +++ /dev/null @@ -1,122 +0,0 @@ -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/schema/evaluate-rule.ts b/src/schema/evaluate-rule.ts index 6c4a700..01b3991 100644 --- a/src/schema/evaluate-rule.ts +++ b/src/schema/evaluate-rule.ts @@ -68,8 +68,8 @@ function flattenNode(node: SpaceNode): Record { return { ...node.schemaData, resolvedType: node.resolvedType, - resolvedParentTitle: node.resolvedParents[0], // first parent or undefined, provided for convenience - resolvedParentTitles: node.resolvedParents, // full array + resolvedParentTitle: node.resolvedParents[0]?.title, // first parent or undefined, provided for convenience + resolvedParentTitles: node.resolvedParents.map((r) => r.title), // full array of parent titles }; } @@ -91,7 +91,7 @@ export function buildEvalContext( // Build all parent objects from resolvedParents array const flattenedParents: Record[] = []; - for (const parentTitle of node.resolvedParents) { + for (const { title: parentTitle } of node.resolvedParents) { const parentNode = nodeIndex.get(parentTitle); if (parentNode) { flattenedParents.push(flattenNode(parentNode)); diff --git a/src/schema/metadata-contract.ts b/src/schema/metadata-contract.ts index 3b095b3..33cfa0c 100644 --- a/src/schema/metadata-contract.ts +++ b/src/schema/metadata-contract.ts @@ -49,7 +49,7 @@ const RELATIONSHIP_SCHEMA = { field: { type: 'string', minLength: 1 }, fieldOn: { enum: ['child', 'parent'] }, format: { enum: ['heading', 'list', 'table', 'page'] }, - multi: { type: 'boolean' }, + multiple: { type: 'boolean' }, matchers: { type: 'array', items: { type: 'string', minLength: 1 }, diff --git a/src/schema/validate-graph.ts b/src/schema/validate-graph.ts new file mode 100644 index 0000000..8d7f468 --- /dev/null +++ b/src/schema/validate-graph.ts @@ -0,0 +1,279 @@ +import { buildTargetIndex, wikilinkToTarget } from '../read/wikilink-utils'; +import { resolveNodeType } from '../schema/schema'; +import type { GraphViolation, SchemaMetadata, SpaceNode } from '../types'; + +export interface GraphValidationResult { + violations: GraphViolation[]; + refErrors: Array<{ file: string; parent: string; error: string }>; +} + +/** + * Validate all hierarchy and relationship constraints including field references. + * Use this when you have a linkTargetIndex available. + * + * @param nodes The nodes to validate + * @param metadata Schema metadata containing hierarchy and relationships configuration + * @returns Object containing hierarchy violations and reference errors + */ +export function validateGraph(nodes: SpaceNode[], metadata: SchemaMetadata): GraphValidationResult { + const refErrors: Array<{ file: string; parent: string; error: string }> = []; + const violations: GraphViolation[] = []; + const levels = metadata.hierarchy?.levels ?? []; + const relationships = metadata.relationships ?? []; + const linkTargetIndex = buildTargetIndex(nodes); + + // 1. Validate field references for each hierarchy level + for (let i = 1; i < levels.length; i++) { + const level = levels[i]!; + const parentLevel = levels[i - 1]!; + + // Case 1: Child has field pointing to parent + if (level.fieldOn !== 'parent') { + validateFieldReferences( + nodes, + linkTargetIndex, + level.type, + parentLevel.type, + level.field, + level.multiple, + refErrors, + violations, + metadata.typeAliases, + ); + } + // Case 2: Parent has field pointing to child + else { + validateFieldReferences( + nodes, + linkTargetIndex, + parentLevel.type, + level.type, + level.field, + level.multiple, + refErrors, + violations, + metadata.typeAliases, + ); + } + } + + // 2. 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.type, + level.selfRefField, + false, + refErrors, + violations, + metadata.typeAliases, + ); + } + + // 3. Validate field references for each relationship + for (const rel of relationships) { + const nodeTypeWithField = rel.fieldOn === 'parent' ? rel.parent : rel.type; + const expectedTargetType = rel.fieldOn === 'parent' ? rel.type : rel.parent; + const field = rel.field ?? 'parent'; + const multiple = rel.multiple ?? rel.fieldOn === 'parent'; + + validateFieldReferences( + nodes, + linkTargetIndex, + nodeTypeWithField, + expectedTargetType, + field, + multiple, + refErrors, + violations, + metadata.typeAliases, + ); + } + + // 4. Validate resolved parent structure rules + const structureViolations = validateHierarchyStructure(nodes, metadata); + violations.push(...structureViolations); + + return { violations, refErrors }; +} + +/** + * Validate that link references in a field point to valid nodes of the expected type. + * @param nodes The nodes to check + * @param linkTargetIndex Map of link targets to nodes + * @param nodeTypeWithField The type of nodes that have the field + * @param expectedTargetType The expected type of the resolved node + * @param field The field name to validate + * @param multiple Whether the field contains an array of refs + * @param refErrors Array to collect reference errors (not found, ambiguous) + * @param violations Array to collect hierarchy/type violations + * @param typeAliases Optional type aliases for resolution + */ +function validateFieldReferences( + nodes: SpaceNode[], + linkTargetIndex: Map, + nodeTypeWithField: string, + expectedTargetType: string, + field: string, + multiple: boolean, + refErrors: Array<{ file: string; parent: string; error: string }>, + violations: GraphViolation[], + typeAliases?: Record, +): void { + const resolvedNodeTypeWithField = resolveNodeType(nodeTypeWithField, typeAliases); + const resolvedExpectedTargetType = resolveNodeType(expectedTargetType, typeAliases); + const nodesToCheck = nodes.filter((n) => n.resolvedType === resolvedNodeTypeWithField); + + 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 (resolved.resolvedType !== resolvedExpectedTargetType) { + violations.push({ + file: node.label, + nodeType: resolvedNodeTypeWithField, + nodeTitle: node.schemaData.title as string, + parentType: resolved.resolvedType, + parentTitle: resolved.schemaData.title as string, + description: `Invalid relationship field: ${nodeTypeWithField} "${node.schemaData.title}" has "${resolved.schemaData.title}" in field "${field}" which is of type ${resolved.resolvedType}, expected ${expectedTargetType}`, + }); + } + } + } 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)`, + }); + } else if (resolved.resolvedType !== resolvedExpectedTargetType) { + // Different description for child-side parent field for backward compatibility with test expectations + const isParentField = field === 'parent' && node.resolvedType === resolvedNodeTypeWithField; + const description = isParentField + ? `Invalid relationship parent: ${nodeTypeWithField} "${node.schemaData.title}" points to "${resolved.schemaData.title}" which is of type ${resolved.resolvedType}, expected ${expectedTargetType}` + : `Invalid relationship field: ${nodeTypeWithField} "${node.schemaData.title}" has "${resolved.schemaData.title}" in field "${field}" which is of type ${resolved.resolvedType}, expected ${expectedTargetType}`; + + violations.push({ + file: node.label, + nodeType: resolvedNodeTypeWithField, + nodeTitle: node.schemaData.title as string, + parentType: resolved.resolvedType, + parentTitle: resolved.schemaData.title as string, + description, + }); + } + } + } +} + +/** + * Validate that resolved parents follow hierarchy level rules or relationship definitions. + */ +export function validateHierarchyStructure(nodes: SpaceNode[], metadata: SchemaMetadata): GraphViolation[] { + const violations: GraphViolation[] = []; + const levels = metadata.hierarchy?.levels ?? []; + const allowSkipLevels = metadata.hierarchy?.allowSkipLevels ?? false; + const typeAliases = metadata.typeAliases; + + const hierarchy = levels.map((level) => resolveNodeType(level.type, typeAliases)); + + const nodeIndex = new Map(); + for (const node of nodes) { + const title = node.schemaData.title as string; + if (title) { + nodeIndex.set(title, node); + } + } + + for (const node of nodes) { + const nodeType = node.resolvedType; + const nodeTitle = node.schemaData.title as string; + + for (const parentRef of node.resolvedParents) { + // Relationship-sourced parents are type-validated by validateFieldReferences; skip here + if (parentRef.source === 'relationship') continue; + + const parentNode = nodeIndex.get(parentRef.title); + if (!parentNode) continue; + + const parentType = parentNode.resolvedType; + const parentTitle = parentRef.title; + + const typeIndex = hierarchy.indexOf(nodeType); + const parentIndex = hierarchy.indexOf(parentType); + + let isValidHierarchy = false; + if (typeIndex !== -1 && parentIndex !== -1) { + const level = levels[typeIndex]!; + const canSelfRef = level.selfRef || level.selfRefField !== undefined; + + if (parentIndex === typeIndex - 1) { + isValidHierarchy = true; + } else if (canSelfRef && parentIndex === typeIndex) { + isValidHierarchy = true; + } else if (allowSkipLevels && parentIndex < typeIndex) { + isValidHierarchy = true; + } + } + + if (!isValidHierarchy) { + violations.push({ + file: node.label, + nodeType, + nodeTitle, + parentType, + parentTitle, + description: `Invalid parent: ${nodeType} "${nodeTitle}" cannot have ${parentType} "${parentTitle}" as parent`, + }); + } + } + } + + return violations; +} diff --git a/src/schema/validate-hierarchy.ts b/src/schema/validate-hierarchy.ts deleted file mode 100644 index fb3d13d..0000000 --- a/src/schema/validate-hierarchy.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { buildTargetIndex, wikilinkToTarget } from '../read/wikilink-utils'; -import type { HierarchyViolation, SchemaMetadata, SpaceNode } from '../types'; - -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; - const hierarchy = levels.map((level) => level.type); - const allowSkipLevels = metadata.hierarchy?.allowSkipLevels ?? false; - - const nodeIndex = new Map(); - for (const node of nodes) { - const title = node.schemaData.title as string; - if (title) { - nodeIndex.set(title, node); - } - } - - for (const node of nodes) { - const nodeType = node.resolvedType; - const nodeTitle = node.schemaData.title as string; - const typeIndex = hierarchy.indexOf(nodeType); - if (typeIndex === -1) continue; - - for (const parentTitle of node.resolvedParents) { - const parentNode = nodeIndex.get(parentTitle); - if (!parentNode) continue; - - const parentType = parentNode.resolvedType; - const parentIndex = hierarchy.indexOf(parentType); - if (parentIndex === -1) continue; - - 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; - - if (!isValid) { - violations.push({ - file: node.label, - nodeType, - nodeTitle, - parentType, - parentTitle, - description: `Invalid parent: ${nodeType} "${nodeTitle}" cannot have ${parentType} "${parentTitle}" as parent`, - }); - } - } - } - - return violations; -} diff --git a/src/types.ts b/src/types.ts index 2f15f94..1b5422c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,14 @@ import type { MetadataContractRule, } from './schema/metadata-contract'; +export interface EdgeDefinition { + type: string; + parent: string; + field: string; // default "parent" + fieldOn: 'child' | 'parent'; // default "child" + multiple: boolean; // default false +} + export interface HierarchyLevel { type: string; field: string; // default "parent" @@ -14,6 +22,22 @@ export interface HierarchyLevel { selfRefField?: string; // optional field for same-type parent relationships (implies selfRef: true) } +/** + * A resolved parent reference, capturing not just the parent title but the edge context + * from which it was resolved. All hierarchy levels and relationships produce entries of + * this type, forming a single labelled graph rather than separate structures. + */ +export interface ResolvedParentRef { + /** Canonical title of the parent node. */ + title: string; + /** The frontmatter field name that contained the wikilink (e.g. 'parent', 'key_activities', 'produces_data'). */ + field: string; + /** Whether this edge originates from a hierarchy level or a relationship definition. */ + source: 'hierarchy' | 'relationship'; + /** Whether the parent and child are the same node type (self-referential edge). */ + selfRef: boolean; +} + export interface SpaceNode { /** Source identifier for error messages (filename or heading title) */ label: string; @@ -21,8 +45,12 @@ export interface SpaceNode { schemaData: Record; /** Valid navigation targets this node can be linked to (wikilink key without [[ ]]). */ linkTargets: string[]; - /** Resolved canonical parent titles (derived from edge fields + linkTargets). Always present, empty if no parents. */ - resolvedParents: string[]; + /** + * Resolved parent references derived from all edge fields (hierarchy levels + relationships). + * Each entry carries the parent title, the field it came from, and its edge context. + * Always present, empty if no parents resolved. + */ + resolvedParents: ResolvedParentRef[]; /** Resolved canonical type (after applying type aliases from schema metadata). */ resolvedType: string; } @@ -59,7 +87,7 @@ export interface RuleViolation { description: string; } -export interface HierarchyViolation { +export interface GraphViolation { file: string; nodeType: string; nodeTitle: string; diff --git a/src/util/graph-helpers.ts b/src/util/graph-helpers.ts index cf53b6c..5287929 100644 --- a/src/util/graph-helpers.ts +++ b/src/util/graph-helpers.ts @@ -43,7 +43,10 @@ export function classifyNodes(nodes: SpaceNode[], hierarchyLevels: HierarchyLeve continue; // non-hierarchy nodes don't participate in the DAG } - if (node.resolvedParents.length === 0) { + // Only hierarchy-sourced parents determine structural position in the DAG + const hierarchyParents = node.resolvedParents.filter((r) => r.source === 'hierarchy'); + + if (hierarchyParents.length === 0) { if (nodeType === rootType) { hierarchyRoots.push(node); } else { @@ -51,10 +54,9 @@ export function classifyNodes(nodes: SpaceNode[], hierarchyLevels: HierarchyLeve } } else { let addedToAParent = false; - for (const parent of node.resolvedParents) { - // Only add to children map if parent exists in the node set - if (nodeTitles.has(parent)) { - const siblings = children.get(parent); + for (const parentRef of hierarchyParents) { + if (nodeTitles.has(parentRef.title)) { + const siblings = children.get(parentRef.title); if (siblings) { siblings.push(node); addedToAParent = true; @@ -62,7 +64,7 @@ export function classifyNodes(nodes: SpaceNode[], hierarchyLevels: HierarchyLeve } } if (!addedToAParent) { - // All parents dangling — treat as orphan + // All hierarchy parents dangling — treat as orphan orphans.push(node); } } diff --git a/tests/read/parse-embedded-relationships.test.ts b/tests/read/parse-embedded-relationships.test.ts index 33a68ca..5ea2ff8 100644 --- a/tests/read/parse-embedded-relationships.test.ts +++ b/tests/read/parse-embedded-relationships.test.ts @@ -18,7 +18,7 @@ describe('extractEmbeddedNodes - relationships', () => { format: 'table', matchers: ['Assumptions'], embeddedTemplateFields: ['assumption', 'status'], - multi: true, + multiple: true, }, ]; @@ -74,7 +74,7 @@ describe('extractEmbeddedNodes - relationships', () => { type: 'problem_statement', format: 'heading', matchers: ['What problem are we solving?'], - multi: false, + multiple: false, }, ]; @@ -103,7 +103,7 @@ Our users are sad. type: 'solution', format: 'list', matchers: ['Possible Solutions'], - multi: true, + multiple: true, }, ]; @@ -134,7 +134,7 @@ Our users are sad. format: 'table', matchers: ['/assum.*/'], embeddedTemplateFields: ['assumption', 'status'], - multi: true, + multiple: true, }, ]; @@ -166,7 +166,7 @@ Our users are sad. format: 'table', matchers: [], embeddedTemplateFields: ['assumption', 'status'], - multi: true, + multiple: true, }, ]; @@ -198,7 +198,7 @@ Our users are sad. type: 'solution', format: 'list', matchers: ['Possible Solutions'], - multi: true, + multiple: true, field: 'solutions', fieldOn: 'parent', }, @@ -227,7 +227,7 @@ Our users are sad. type: 'assumption', format: 'table', matchers: ['Assumptions'], - multi: true, + multiple: true, field: 'assumptions', fieldOn: 'parent', embeddedTemplateFields: ['assumption', 'status'], @@ -260,7 +260,7 @@ Our users are sad. type: 'solution', format: 'list', matchers: ['Solutions'], - multi: true, + multiple: true, field: 'count', fieldOn: 'parent', }, @@ -289,7 +289,7 @@ Our users are sad. type: 'solution', format: 'list', matchers: ['Solutions'], - multi: true, + multiple: true, field: 'solutions', fieldOn: 'parent', }, @@ -324,7 +324,7 @@ Our users are sad. type: 'assumption', format: 'table', matchers: ['Assumptions'], - multi: true, + multiple: true, field: 'assumptions', fieldOn: 'parent', embeddedTemplateFields: ['assumption', 'status'], diff --git a/tests/read/parse-embedded.test.ts b/tests/read/parse-embedded.test.ts index 1e857de..7edfea0 100644 --- a/tests/read/parse-embedded.test.ts +++ b/tests/read/parse-embedded.test.ts @@ -7,4 +7,14 @@ describe('normalizeHeadingSectionTarget', () => { const expected = 'T!e@s t$ %h e&a*d(i)n-g+ _w=i[t]h{ } ;e"x t\'r,a. >c?h/a r`s~'; expect(normalizeHeadingSectionTarget(input)).toBe(expected); }); + it('matches observed Obsidian bookmark normalization for full links to heading with anchor', () => { + const input = 'blah ^foo'; + const expected = 'blah foo'; + expect(normalizeHeadingSectionTarget(input)).toBe(expected); + }); + it('matches observed Obsidian bookmark normalization for inline fields', () => { + const input = 'blah [type:: blah] wow'; + const expected = 'blah [type blah] wow'; + expect(normalizeHeadingSectionTarget(input)).toBe(expected); + }); }); diff --git a/tests/read/read-space-directory-general.test.ts b/tests/read/read-space-directory-general.test.ts index d2545ae..71f36bd 100644 --- a/tests/read/read-space-directory-general.test.ts +++ b/tests/read/read-space-directory-general.test.ts @@ -81,7 +81,7 @@ describe('readSpaceDirectory', () => { it('embedded mission parent points to the containing page', () => { const node = result.nodes.find((n) => n.label === 'Embedded Mission'); expect(node?.schemaData.parent).toBe('[[vision_page]]'); - expect(node?.resolvedParents[0]).toBe('vision_page'); + expect(node?.resolvedParents[0]?.title).toBe('vision_page'); }); it('stores navigation targets for embedded mission', () => { @@ -99,13 +99,13 @@ describe('readSpaceDirectory', () => { it('embedded goal parent is stored as an implied section target and resolved to title', () => { const node = result.nodes.find((n) => n.label === 'Embedded Goal'); expect(node?.schemaData.parent).toBe('[[vision_page#[type mission] Embedded Mission embmission]]'); - expect(node?.resolvedParents[0]).toBe('Embedded Mission'); + expect(node?.resolvedParents[0]?.title).toBe('Embedded Mission'); }); it('solution_page.md keeps source parent link and resolves to embedded goal title', () => { const node = result.nodes.find((n) => n.label === 'solution_page.md'); expect(node?.schemaData.parent).toBe('[[vision_page#^embgoal]]'); - expect(node?.resolvedParents[0]).toBe('Embedded Goal'); + expect(node?.resolvedParents[0]?.title).toBe('Embedded Goal'); }); }); @@ -145,9 +145,9 @@ describe('readSpaceDirectory', () => { const goal = result.nodes.find((n) => n.label === 'Embedded Goal'); const solutionPage = result.nodes.find((n) => n.label === 'solution_page.md'); expect(goal?.schemaData.parent).toBe('[[vision_page#[type mission] Embedded Mission embmission]]'); - expect(goal?.resolvedParents[0]).toBe('Embedded Mission'); + expect(goal?.resolvedParents[0]?.title).toBe('Embedded Mission'); expect(solutionPage?.schemaData.parent).toBe('[[vision_page#^embgoal]]'); - expect(solutionPage?.resolvedParents[0]).toBe('Embedded Goal'); + expect(solutionPage?.resolvedParents[0]?.title).toBe('Embedded Goal'); }); }); diff --git a/tests/read/read-space-on-a-page-general.test.ts b/tests/read/read-space-on-a-page-general.test.ts index a3d4f03..0de1d19 100644 --- a/tests/read/read-space-on-a-page-general.test.ts +++ b/tests/read/read-space-on-a-page-general.test.ts @@ -23,28 +23,28 @@ describe('readSpaceOnAPage - on-a-page-valid.md (space_on_a_page)', () => { it('infers H2 as mission with parent from H1', () => { const node = result.nodes.find((n) => n.label === 'Personal Mission'); expect(node?.schemaData.type).toBe('mission'); - expect(node?.resolvedParents[0]).toBe('Personal Vision'); + expect(node?.resolvedParents[0]?.title).toBe('Personal Vision'); expect(node?.schemaData.parent).toContain('[[on-a-page-valid#'); }); it('infers H3 as goal with parent from H2', () => { const node = result.nodes.find((n) => n.label === 'Career Growth'); expect(node?.schemaData.type).toBe('goal'); - expect(node?.resolvedParents[0]).toBe('Personal Mission'); + expect(node?.resolvedParents[0]?.title).toBe('Personal Mission'); expect(node?.schemaData.parent).toContain('[[on-a-page-valid#'); }); it('infers H4 as opportunity with parent from H3', () => { const node = result.nodes.find((n) => n.label === 'Technical Skills'); expect(node?.schemaData.type).toBe('opportunity'); - expect(node?.resolvedParents[0]).toBe('Career Growth'); + expect(node?.resolvedParents[0]?.title).toBe('Career Growth'); expect(node?.schemaData.parent).toContain('[[on-a-page-valid#'); }); it('infers H5 as solution with parent from H4', () => { const node = result.nodes.find((n) => n.label === 'Build a Side Project'); expect(node?.schemaData.type).toBe('solution'); - expect(node?.resolvedParents[0]).toBe('Technical Skills'); + expect(node?.resolvedParents[0]?.title).toBe('Technical Skills'); expect(node?.schemaData.parent).toContain('[[on-a-page-valid#'); }); }); @@ -88,7 +88,7 @@ describe('readSpaceOnAPage - on-a-page-valid.md (space_on_a_page)', () => { it('sets parent and summary on Learn TypeScript from dash separator', () => { const node = result.nodes.find((n) => n.label === 'Learn TypeScript'); - expect(node?.resolvedParents[0]).toBe('Technical Skills'); + expect(node?.resolvedParents[0]?.title).toBe('Technical Skills'); expect(node?.schemaData.parent).toContain('[[on-a-page-valid#'); expect(node?.schemaData.summary).toBe('Master TypeScript for tool development'); }); diff --git a/tests/read/relationship-embedded-nodes.test.ts b/tests/read/relationship-embedded-nodes.test.ts index 770e780..80d1ec9 100644 --- a/tests/read/relationship-embedded-nodes.test.ts +++ b/tests/read/relationship-embedded-nodes.test.ts @@ -27,7 +27,7 @@ describe('Embedded nodes with parent-side relationships', () => { field: 'applications', fieldOn: 'parent', matchers: ['Applications'], - multi: true, + multiple: true, }, ], }, diff --git a/tests/read/resolve-hierarchy-edges.test.ts b/tests/read/resolve-hierarchy-edges.test.ts index 3b58d00..ae7d304 100644 --- a/tests/read/resolve-hierarchy-edges.test.ts +++ b/tests/read/resolve-hierarchy-edges.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { resolveHierarchyEdges } from '../../src/read/resolve-hierarchy-edges'; +import { resolveGraphEdges } from '../../src/read/resolve-graph-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('resolveHierarchyEdges', () => { +describe('resolveGraphEdges', () => { describe("default behavior (fieldOn: 'child', multiple: false)", () => { it('resolves parent field on child node to parent title', () => { const levels = [makeLevel('Phase'), makeLevel('Activity')]; @@ -22,9 +22,9 @@ describe('resolveHierarchyEdges', () => { const phase = makeNode('Phase 1', 'Phase'); const activity = makeNode('Activity 1', 'Activity', { parent: '[[Phase 1]]' }); - resolveHierarchyEdges([phase, activity], levels); + resolveGraphEdges([phase, activity], levels); - expect(activity.resolvedParents).toEqual(['Phase 1']); + expect(activity.resolvedParents.map((r) => r.title)).toEqual(['Phase 1']); expect(phase.resolvedParents).toEqual([]); }); @@ -33,7 +33,7 @@ describe('resolveHierarchyEdges', () => { const activity = makeNode('Activity 1', 'Activity', { parent: '[[Nonexistent Phase]]' }); - resolveHierarchyEdges([activity], levels); + resolveGraphEdges([activity], levels); expect(activity.resolvedParents).toEqual([]); }); @@ -51,10 +51,10 @@ describe('resolveHierarchyEdges', () => { const reqB = makeNode('Req B', 'Requirement'); const tool = makeNode('Tool X', 'Tool', { fulfills: ['[[Req A]]', '[[Req B]]'] }); - resolveHierarchyEdges([reqA, reqB, tool], levels); + resolveGraphEdges([reqA, reqB, tool], levels); - expect(tool.resolvedParents).toContain('Req A'); - expect(tool.resolvedParents).toContain('Req B'); + expect(tool.resolvedParents.map((r) => r.title)).toContain('Req A'); + expect(tool.resolvedParents.map((r) => r.title)).toContain('Req B'); expect(tool.resolvedParents).toHaveLength(2); }); @@ -64,9 +64,9 @@ describe('resolveHierarchyEdges', () => { const req = makeNode('Req A', 'Requirement'); const tool = makeNode('Tool X', 'Tool', { fulfills: ['[[Req A]]', 42, null] }); - resolveHierarchyEdges([req, tool], levels); + resolveGraphEdges([req, tool], levels); - expect(tool.resolvedParents).toEqual(['Req A']); + expect(tool.resolvedParents.map((r) => r.title)).toEqual(['Req A']); }); it('returns empty resolvedParents when field is not an array for multiple=true', () => { @@ -74,7 +74,7 @@ describe('resolveHierarchyEdges', () => { const tool = makeNode('Tool X', 'Tool', { fulfills: '[[Req A]]' }); // string, not array - resolveHierarchyEdges([tool], levels); + resolveGraphEdges([tool], levels); expect(tool.resolvedParents).toEqual([]); }); @@ -93,10 +93,10 @@ describe('resolveHierarchyEdges', () => { const reqA = makeNode('Req A', 'Requirement'); const reqB = makeNode('Req B', 'Requirement'); - resolveHierarchyEdges([activity, reqA, reqB], levels); + resolveGraphEdges([activity, reqA, reqB], levels); - expect(reqA.resolvedParents).toEqual(['Activity 1']); - expect(reqB.resolvedParents).toEqual(['Activity 1']); + expect(reqA.resolvedParents.map((r) => r.title)).toEqual(['Activity 1']); + expect(reqB.resolvedParents.map((r) => r.title)).toEqual(['Activity 1']); expect(activity.resolvedParents).toEqual([]); }); @@ -114,10 +114,10 @@ describe('resolveHierarchyEdges', () => { }); const sharedReq = makeNode('Shared Req', 'Requirement'); - resolveHierarchyEdges([activity1, activity2, sharedReq], levels); + resolveGraphEdges([activity1, activity2, sharedReq], levels); - expect(sharedReq.resolvedParents).toContain('Activity 1'); - expect(sharedReq.resolvedParents).toContain('Activity 2'); + expect(sharedReq.resolvedParents.map((r) => r.title)).toContain('Activity 1'); + expect(sharedReq.resolvedParents.map((r) => r.title)).toContain('Activity 2'); expect(sharedReq.resolvedParents).toHaveLength(2); }); }); @@ -140,12 +140,12 @@ describe('resolveHierarchyEdges', () => { const reqB = makeNode('Req B', 'Requirement'); const tool = makeNode('Tool X', 'Tool', { fulfills: ['[[Req A]]'] }); - resolveHierarchyEdges([phase, activity, reqA, reqB, tool], levels); + resolveGraphEdges([phase, activity, reqA, reqB, tool], levels); - expect(activity.resolvedParents).toEqual(['Phase 1']); - expect(reqA.resolvedParents).toEqual(['Activity 1']); - expect(reqB.resolvedParents).toEqual(['Activity 1']); - expect(tool.resolvedParents).toEqual(['Req A']); + expect(activity.resolvedParents.map((r) => r.title)).toEqual(['Phase 1']); + expect(reqA.resolvedParents.map((r) => r.title)).toEqual(['Activity 1']); + expect(reqB.resolvedParents.map((r) => r.title)).toEqual(['Activity 1']); + expect(tool.resolvedParents.map((r) => r.title)).toEqual(['Req A']); expect(phase.resolvedParents).toEqual([]); }); }); @@ -157,11 +157,11 @@ describe('resolveHierarchyEdges', () => { const phase = makeNode('Phase 1', 'Phase', { parent: '[[Activity 1]]' }); const activity = makeNode('Activity 1', 'Activity', { parent: '[[Phase 1]]' }); - resolveHierarchyEdges([phase, activity], levels); + resolveGraphEdges([phase, activity], levels); // Phase is level 0 (root) — its parent field is not processed expect(phase.resolvedParents).toEqual([]); - expect(activity.resolvedParents).toEqual(['Phase 1']); + expect(activity.resolvedParents.map((r) => r.title)).toEqual(['Phase 1']); }); }); @@ -171,12 +171,62 @@ describe('resolveHierarchyEdges', () => { const activity = makeNode('Activity 1', 'Activity', { parent: '[[Ghost Phase]]' }); - resolveHierarchyEdges([activity], levels); + resolveGraphEdges([activity], levels); expect(activity.resolvedParents).toEqual([]); }); }); + describe('relationship edges', () => { + it('assigns source: hierarchy when type appears in both hierarchy and a relationship', () => { + const levels = [makeLevel('project'), makeLevel('task')]; + const relationships = [ + { + parent: 'project', + type: 'task', + field: 'project', + fieldOn: 'child' as const, + format: 'heading' as const, + multiple: false, + matchers: ['Tasks'], + }, + ]; + + const project = makeNode('Project A', 'project'); + const task = makeNode('Task 1', 'task', { parent: '[[Project A]]' }); + + resolveGraphEdges([project, task], levels, relationships); + + const ref = task.resolvedParents.find((r) => r.title === 'Project A'); + expect(ref).toBeDefined(); + expect(ref?.source).toBe('hierarchy'); + }); + + it('assigns source: relationship when type appears only in relationships', () => { + const levels = [makeLevel('project')]; + const relationships = [ + { + parent: 'project', + type: 'resource', + field: 'parent', + fieldOn: 'child' as const, + format: 'heading' as const, + multiple: false, + matchers: ['Resources'], + }, + ]; + + const project = makeNode('Project A', 'project'); + const resource = makeNode('Resource 1', 'resource', { parent: '[[Project A]]' }); + + resolveGraphEdges([project, resource], levels, relationships); + + const ref = resource.resolvedParents.find((r) => r.title === 'Project A'); + expect(ref).toBeDefined(); + expect(ref?.source).toBe('relationship'); + }); + }); + describe('selfRefField (same-type parent relationships)', () => { it('resolves both regular parents and same-type parents when selfRefField is set', () => { const levels = [ @@ -190,14 +240,14 @@ describe('resolveHierarchyEdges', () => { const coreCapability = makeNode('Core Capability', 'Capability'); const subCapability = makeNode('Sub Capability', 'Capability', { parent: '[[Core Capability]]' }); - resolveHierarchyEdges([activity, coreCapability, subCapability], levels); + resolveGraphEdges([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'); + expect(coreCapability.resolvedParents.map((r) => r.title)).toContain('Activity 1'); + expect(subCapability.resolvedParents.map((r) => r.title)).toContain('Activity 1'); // Same-type relationship: Capability → Capability via parent field on child - expect(subCapability.resolvedParents).toContain('Core Capability'); + expect(subCapability.resolvedParents.map((r) => r.title)).toContain('Core Capability'); expect(activity.resolvedParents).toEqual([]); expect(coreCapability.resolvedParents).toHaveLength(1); @@ -217,16 +267,16 @@ describe('resolveHierarchyEdges', () => { const toolB = makeNode('Tool B', 'Tool', { partOf: '[[Tool A]]' }); const toolC = makeNode('Tool C', 'Tool', { partOf: '[[Tool B]]' }); - resolveHierarchyEdges([activity, toolA, toolB, toolC], levels); + resolveGraphEdges([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'); + expect(toolA.resolvedParents.map((r) => r.title)).toContain('Activity 1'); + expect(toolB.resolvedParents.map((r) => r.title)).toContain('Activity 1'); + expect(toolC.resolvedParents.map((r) => r.title)).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(toolB.resolvedParents.map((r) => r.title)).toContain('Tool A'); + expect(toolC.resolvedParents.map((r) => r.title)).toContain('Tool B'); expect(toolA.resolvedParents).toHaveLength(1); expect(toolB.resolvedParents).toHaveLength(2); @@ -240,13 +290,13 @@ describe('resolveHierarchyEdges', () => { const objective1 = makeNode('Objective 1', 'Objective', { parent: '[[Goal 1]]' }); const objective2 = makeNode('Objective 2', 'Objective', { parent: '[[Objective 1]]' }); - resolveHierarchyEdges([goal, objective1, objective2], levels); + resolveGraphEdges([goal, objective1, objective2], levels); // Regular: Goal → Objective - expect(objective1.resolvedParents).toContain('Goal 1'); + expect(objective1.resolvedParents.map((r) => r.title)).toContain('Goal 1'); // Same-type: Objective → Objective (uses same field 'parent') - expect(objective2.resolvedParents).toContain('Objective 1'); + expect(objective2.resolvedParents.map((r) => r.title)).toContain('Objective 1'); expect(goal.resolvedParents).toEqual([]); expect(objective1.resolvedParents).toHaveLength(1); @@ -266,10 +316,10 @@ describe('resolveHierarchyEdges', () => { parent: '[[Nonexistent Parent]]', // selfRefField target that doesn't exist }); - resolveHierarchyEdges([activity, capabilityA], levels); + resolveGraphEdges([activity, capabilityA], levels); // Regular relationship resolves - expect(capabilityA.resolvedParents).toContain('Activity 1'); + expect(capabilityA.resolvedParents.map((r) => r.title)).toContain('Activity 1'); // Missing selfRefField target is silently ignored expect(capabilityA.resolvedParents).toHaveLength(1); diff --git a/tests/schema/evaluate-rule.test.ts b/tests/schema/evaluate-rule.test.ts index 1fc2330..da8aa08 100644 --- a/tests/schema/evaluate-rule.test.ts +++ b/tests/schema/evaluate-rule.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { buildEvalContext, evaluateExpression } from '../../src/schema/evaluate-rule'; import type { SpaceNode } from '../../src/types'; +import { makeParentRef } from '../test-helpers'; describe('evaluate-rule', () => { describe('evaluateExpression', () => { @@ -13,7 +14,7 @@ describe('evaluate-rule', () => { parent: '[[Parent Opportunity]]', }, linkTargets: ['Test Node'], - resolvedParents: ['Parent Opportunity'], + resolvedParents: [makeParentRef('Parent Opportunity')], resolvedType: 'solution', }; @@ -121,7 +122,7 @@ describe('evaluate-rule', () => { label: 'child.md', schemaData: { title: 'Child', type: 'solution', parent: '[[Parent]]' }, linkTargets: ['Child'], - resolvedParents: ['Parent'], + resolvedParents: [makeParentRef('Parent')], resolvedType: 'solution', }; diff --git a/tests/schema/validate-hierarchy.test.ts b/tests/schema/validate-graph.test.ts similarity index 95% rename from tests/schema/validate-hierarchy.test.ts rename to tests/schema/validate-graph.test.ts index 71ee381..1f5af21 100644 --- a/tests/schema/validate-hierarchy.test.ts +++ b/tests/schema/validate-graph.test.ts @@ -1,15 +1,18 @@ import { describe, expect, it } from 'bun:test'; -import { validateHierarchyStructure } from '../../src/schema/validate-hierarchy'; +import { resolveNodeType } from '../../src/schema/schema'; +import { validateHierarchyStructure } from '../../src/schema/validate-graph'; import type { SchemaMetadata, SpaceNode } from '../../src/types'; -import { makeLevel } from '../test-helpers'; +import { makeLevel, makeParentRef } from '../test-helpers'; + +describe('validate-graph', () => { + const typeAliases = { outcome: 'goal' }; -describe('validate-hierarchy', () => { const buildNode = (title: string, type: string, parentTitle?: string): SpaceNode => ({ label: `${title}.md`, schemaData: { title, type, status: 'active' }, linkTargets: [title], - resolvedParents: parentTitle ? [parentTitle] : [], - resolvedType: type, // In tests, no alias resolution needed + resolvedParents: parentTitle ? [makeParentRef(parentTitle)] : [], + resolvedType: resolveNodeType(type, typeAliases), }); describe('hierarchy with selfRef', () => { @@ -19,7 +22,7 @@ describe('validate-hierarchy', () => { levels: hierarchy.map((t) => makeLevel(t, { selfRef: ['goal', 'opportunity', 'solution'].includes(t) })), allowSkipLevels: false, }, - typeAliases: {}, + typeAliases, }; it('passes when node has immediate parent in hierarchy', () => { diff --git a/tests/schema/validate-rules.test.ts b/tests/schema/validate-rules.test.ts index 62dd50f..c5b30ae 100644 --- a/tests/schema/validate-rules.test.ts +++ b/tests/schema/validate-rules.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { validateRules } from '../../src/schema/validate-rules'; import type { RulesMetadata, SpaceNode } from '../../src/types'; +import { makeParentRef } from '../test-helpers'; describe('validate-rules', () => { describe('validateRules', () => { @@ -22,21 +23,21 @@ describe('validate-rules', () => { source: 'Interview', }, linkTargets: ['Opportunity'], - resolvedParents: ['Outcome'], + resolvedParents: [makeParentRef('Outcome')], resolvedType: 'opportunity', }, { label: 'solution.md', schemaData: { title: 'Solution', type: 'solution', status: 'exploring', parent: '[[Opportunity]]' }, linkTargets: ['Solution'], - resolvedParents: ['Opportunity'], + resolvedParents: [makeParentRef('Opportunity')], resolvedType: 'solution', }, { label: 'bad-solution.md', schemaData: { title: 'Bad Solution', type: 'solution', status: 'exploring', parent: '[[Solution]]' }, linkTargets: ['Bad Solution'], - resolvedParents: ['Solution'], + resolvedParents: [makeParentRef('Solution')], resolvedType: 'solution', }, { @@ -49,7 +50,7 @@ describe('validate-rules', () => { assumption: 'Test', }, linkTargets: ['Experiment'], - resolvedParents: ['Solution'], + resolvedParents: [makeParentRef('Solution')], resolvedType: 'experiment', }, { @@ -62,7 +63,7 @@ describe('validate-rules', () => { assumption: 'Test', }, linkTargets: ['Bad Experiment'], - resolvedParents: ['Opportunity'], + resolvedParents: [makeParentRef('Opportunity')], resolvedType: 'experiment', }, ]; @@ -124,7 +125,7 @@ describe('validate-rules', () => { label: 'bad-outcome.md', schemaData: { title: 'Bad Outcome', type: 'outcome', status: 'active', parent: '[[Vision]]' }, linkTargets: ['Bad Outcome'], - resolvedParents: ['Vision'], + resolvedParents: [makeParentRef('Vision')], resolvedType: 'outcome', }; const visionNode: SpaceNode = { @@ -163,7 +164,7 @@ describe('validate-rules', () => { source: 'Interview', }, linkTargets: ['Opportunity'], - resolvedParents: ['Outcome'], + resolvedParents: [makeParentRef('Outcome')], resolvedType: 'opportunity', }; @@ -176,7 +177,7 @@ describe('validate-rules', () => { parent: '[[Opportunity]]', }, linkTargets: [`Solution ${i}`], - resolvedParents: ['Opportunity'], + resolvedParents: [makeParentRef('Opportunity')], resolvedType: 'solution', })); @@ -196,7 +197,7 @@ describe('validate-rules', () => { source: 'Interview', }, linkTargets: ['Opportunity'], - resolvedParents: ['Outcome'], + resolvedParents: [makeParentRef('Outcome')], resolvedType: 'opportunity', }; @@ -204,7 +205,7 @@ describe('validate-rules', () => { label: 'solution.md', schemaData: { title: 'Solution', type: 'solution', status: 'exploring', parent: '[[Opportunity]]' }, linkTargets: ['Solution'], - resolvedParents: ['Opportunity'], + resolvedParents: [makeParentRef('Opportunity')], resolvedType: 'solution', }; @@ -288,7 +289,7 @@ describe('validate-rules', () => { source: 'Interview', }, linkTargets: ['Opportunity'], - resolvedParents: ['Outcome'], + resolvedParents: [makeParentRef('Outcome')], resolvedType: 'opportunity', }; const violations = await validateRules([parentNode, childNode], [workflowRules[1]!]); @@ -316,7 +317,7 @@ describe('validate-rules', () => { source: 'Interview', }, linkTargets: ['Opportunity'], - resolvedParents: ['Outcome'], + resolvedParents: [makeParentRef('Outcome')], resolvedType: 'opportunity', }; const violations = await validateRules([parentNode, childNode], [workflowRules[1]!]); @@ -362,14 +363,14 @@ describe('validate-rules', () => { label: 'solution.md', schemaData: { title: 'Solution', type: 'solution', status: 'exploring', parent: '[[Opportunity]]' }, linkTargets: ['Solution'], - resolvedParents: ['Opportunity'], + resolvedParents: [makeParentRef('Opportunity')], resolvedType: 'solution', }, { label: 'bad-solution.md', schemaData: { title: 'Bad Solution', type: 'solution', status: 'exploring', parent: '[[Solution]]' }, linkTargets: ['Bad Solution'], - resolvedParents: ['Solution'], + resolvedParents: [makeParentRef('Solution')], resolvedType: 'solution', }, ]; diff --git a/tests/template-sync.test.ts b/tests/template-sync.test.ts index 47fab73..df84e74 100644 --- a/tests/template-sync.test.ts +++ b/tests/template-sync.test.ts @@ -16,21 +16,21 @@ describe('template-sync - generateNewContent', () => { format: 'table', matchers: ['Assumptions'], embeddedTemplateFields: ['assumption', 'status'], - multi: true, + multiple: true, }, { parent: 'opportunity', type: 'problem_statement', format: 'heading', matchers: ['What problem are we solving?'], - multi: false, + multiple: false, }, { parent: 'opportunity', type: 'solution', format: 'list', matchers: ['Solutions'], - multi: true, + multiple: true, }, ], }, diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index d8f1b45..4b3d8a7 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -1,4 +1,16 @@ -import type { HierarchyLevel } from '../src/types'; +import type { HierarchyLevel, ResolvedParentRef } from '../src/types'; + +/** + * Creates a ResolvedParentRef with sensible defaults for use in tests. + * Override any field to represent specific edge contexts. + */ +export const makeParentRef = (title: string, overrides: Partial = {}): ResolvedParentRef => ({ + title, + field: 'parent', + source: 'hierarchy', + selfRef: false, + ...overrides, +}); /** * Creates a HierarchyLevel with defaults matching schema.loadMetadata diff --git a/tests/validate-general.test.ts b/tests/validate-general.test.ts index 82603f4..99cb02f 100644 --- a/tests/validate-general.test.ts +++ b/tests/validate-general.test.ts @@ -1,8 +1,9 @@ import { beforeAll, describe, expect, it } from 'bun:test'; import { join } from 'node:path'; import { readSpaceDirectory, readSpaceOnAPage } from '../src/read/read-space'; -import { resolveHierarchyEdges } from '../src/read/resolve-hierarchy-edges'; -import { bundledSchemasDir, createValidator } from '../src/schema/schema'; +import { resolveGraphEdges } from '../src/read/resolve-graph-edges'; +import { bundledSchemasDir, createValidator, loadMetadata } from '../src/schema/schema'; +import { validateGraph } from '../src/schema/validate-graph'; import type { SpaceNode } from '../src/types'; import { makeLevel } from './test-helpers'; @@ -12,20 +13,7 @@ const INVALID_DIR = join(import.meta.dir, 'fixtures/general/invalid-ost'); const VALID_PAGE = join(import.meta.dir, 'fixtures/general/on-a-page-valid.md'); const validateNode = createValidator(DEFAULT_SCHEMA_PATH); - -/** Inline ref-check helper - mirrors the logic in validate.ts. */ -function checkRefErrors(nodes: SpaceNode[]): Array<{ file: string; parent: string }> { - const index = new Set(nodes.map((n) => n.schemaData.title as string)); - - return nodes - .filter((n) => n.schemaData.parent) - .filter((n) => { - const parentKey = n.resolvedParents[0]; - if (!parentKey) return true; - return !index.has(parentKey); - }) - .map((n) => ({ file: n.label, parent: n.schemaData.parent as string })); -} +const metadata = loadMetadata(DEFAULT_SCHEMA_PATH); describe('Schema validation', () => { describe('valid-ost nodes (readSpaceDirectory)', () => { @@ -43,7 +31,8 @@ describe('Schema validation', () => { }); it('has zero ref errors', () => { - expect(checkRefErrors(nodes)).toHaveLength(0); + const { refErrors } = validateGraph(nodes, metadata); + expect(refErrors).toHaveLength(0); }); }); @@ -88,7 +77,7 @@ describe('Schema validation', () => { }); it('detects dangling parent ref error for Nonexistent Node', () => { - const refErrors = checkRefErrors(nodes); + const { refErrors } = validateGraph(nodes, metadata); expect(refErrors.some((e) => e.parent === '[[Nonexistent Node]]')).toBe(true); }); }); @@ -141,18 +130,15 @@ describe('Schema validation', () => { }, ]; - resolveHierarchyEdges(nodes, [ - makeLevel('vision'), - makeLevel('mission'), - makeLevel('goal'), - makeLevel('solution'), - ]); + resolveGraphEdges(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'); + expect(nodes.find((n) => n.label === 'Another Goal')?.resolvedParents[0]?.title).toBe('Our Mission'); expect(nodes.find((n) => n.label === 'solution_page.md')?.schemaData.parent).toBe('[[anchor_vision#^goal1]]'); - expect(nodes.find((n) => n.label === 'solution_page.md')?.resolvedParents[0]).toBe('Another Goal'); - expect(checkRefErrors(nodes)).toHaveLength(0); + expect(nodes.find((n) => n.label === 'solution_page.md')?.resolvedParents[0]?.title).toBe('Another Goal'); + + const { refErrors } = validateGraph(nodes, metadata); + expect(refErrors).toHaveLength(0); }); it('keeps unresolved parent links untouched when no link target matches', () => { @@ -178,16 +164,11 @@ describe('Schema validation', () => { }, ]; - resolveHierarchyEdges(nodes, [ - makeLevel('vision'), - makeLevel('mission'), - makeLevel('goal'), - makeLevel('solution'), - ]); + resolveGraphEdges(nodes, [makeLevel('vision'), makeLevel('mission'), makeLevel('goal'), makeLevel('solution')]); - const errors = checkRefErrors(nodes); - expect(errors).toHaveLength(1); - expect(errors[0]?.parent).toBe('[[anchor_vision#^noanchor]]'); + const { refErrors } = validateGraph(nodes, metadata); + expect(refErrors).toHaveLength(1); + expect(refErrors[0]?.parent).toBe('[[anchor_vision#^noanchor]]'); }); it('does not resolve bare embedded-node title links when no page exists', () => { @@ -225,17 +206,12 @@ describe('Schema validation', () => { }, ]; - resolveHierarchyEdges(nodes, [ - makeLevel('vision'), - makeLevel('mission'), - makeLevel('goal'), - makeLevel('solution'), - ]); + resolveGraphEdges(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); - expect(errors).toHaveLength(1); - expect(errors[0]?.parent).toBe('[[Embedded Goal]]'); + const { refErrors } = validateGraph(nodes, metadata); + expect(refErrors).toHaveLength(1); + expect(refErrors[0]?.parent).toBe('[[Embedded Goal]]'); }); }); diff --git a/tests/validate-relationships.test.ts b/tests/validate-relationships.test.ts index bfae034..5e450a1 100644 --- a/tests/validate-relationships.test.ts +++ b/tests/validate-relationships.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; -import { buildTargetIndex } from '../src/read/wikilink-utils'; -import { validateRelationships } from '../src/schema/validate-hierarchy'; +import { resolveGraphEdges } from '../src/read/resolve-graph-edges'; +import { validateGraph } from '../src/schema/validate-graph'; import type { SchemaMetadata, SpaceNode } from '../src/types'; function makeNode(title: string, type: string, extra: Record = {}, linkTargets?: string[]): SpaceNode { @@ -13,7 +13,7 @@ function makeNode(title: string, type: string, extra: Record = }; } -describe('validateRelationships', () => { +describe('validateGraph - Relationships', () => { const metadata = { hierarchy: { levels: [{ type: 'opportunity' }] }, relationships: [ @@ -30,9 +30,8 @@ describe('validateRelationships', () => { const assumption = makeNode('Assumption 1', 'assumption', { parent: '[[Opp 1]]' }); const nodes = [opp, assumption]; - const index = buildTargetIndex(nodes); - - const { violations, refErrors } = validateRelationships(nodes, metadata, index); + resolveGraphEdges(nodes, metadata.hierarchy!.levels, metadata.relationships, metadata.typeAliases); + const { violations, refErrors } = validateGraph(nodes, metadata); expect(violations).toBeEmpty(); expect(refErrors).toBeEmpty(); @@ -42,9 +41,8 @@ describe('validateRelationships', () => { const assumption = makeNode('Assumption 1', 'assumption', { parent: '[[Missing Opp]]' }); const nodes = [assumption]; - const index = buildTargetIndex(nodes); - - const { violations, refErrors } = validateRelationships(nodes, metadata, index); + resolveGraphEdges(nodes, metadata.hierarchy!.levels, metadata.relationships, metadata.typeAliases); + const { violations, refErrors } = validateGraph(nodes, metadata); expect(violations).toBeEmpty(); expect(refErrors).toHaveLength(1); @@ -56,10 +54,11 @@ describe('validateRelationships', () => { 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); + // resolveGraphEdges resolves permissively; validateFieldReferences catches the type mismatch + resolveGraphEdges(nodes, metadata.hierarchy!.levels, metadata.relationships, metadata.typeAliases); + const { violations, refErrors } = validateGraph(nodes, metadata); + // Field validation catches it now expect(refErrors).toBeEmpty(); expect(violations).toHaveLength(1); expect(violations[0]?.description).toContain('expected opportunity'); @@ -69,9 +68,8 @@ describe('validateRelationships', () => { const assumption = makeNode('Assumption 1', 'assumption'); const nodes = [assumption]; - const index = buildTargetIndex(nodes); - - const { violations, refErrors } = validateRelationships(nodes, metadata, index); + resolveGraphEdges(nodes, metadata.hierarchy!.levels, metadata.relationships, metadata.typeAliases); + const { violations, refErrors } = validateGraph(nodes, metadata); expect(violations).toBeEmpty(); expect(refErrors).toBeEmpty(); @@ -94,9 +92,13 @@ describe('validateRelationships', () => { 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); + resolveGraphEdges( + nodes, + metaWithCustomField.hierarchy!.levels, + metaWithCustomField.relationships, + metaWithCustomField.typeAliases, + ); + const { violations, refErrors } = validateGraph(nodes, metaWithCustomField); expect(violations).toBeEmpty(); expect(refErrors).toBeEmpty(); @@ -119,9 +121,13 @@ describe('validateRelationships', () => { 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); + resolveGraphEdges( + nodes, + metaWithCustomField.hierarchy!.levels, + metaWithCustomField.relationships, + metaWithCustomField.typeAliases, + ); + const { violations, refErrors } = validateGraph(nodes, metaWithCustomField); expect(refErrors).toBeEmpty(); expect(violations).toHaveLength(1); @@ -129,7 +135,7 @@ describe('validateRelationships', () => { }); }); -describe('validateRelationships — fieldOn: parent', () => { +describe('validateGraph — fieldOn: parent', () => { const metadata = { hierarchy: { levels: [{ type: 'activity' }] }, relationships: [ @@ -138,7 +144,7 @@ describe('validateRelationships — fieldOn: parent', () => { type: 'task', field: 'tasks', fieldOn: 'parent', - multi: true, + multiple: true, }, ], } as SchemaMetadata; @@ -149,9 +155,8 @@ describe('validateRelationships — fieldOn: parent', () => { 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); + resolveGraphEdges(nodes, metadata.hierarchy!.levels, metadata.relationships, metadata.typeAliases); + const { violations, refErrors } = validateGraph(nodes, metadata); expect(violations).toBeEmpty(); expect(refErrors).toBeEmpty(); @@ -161,9 +166,8 @@ describe('validateRelationships — fieldOn: parent', () => { const activity = makeNode('Activity 1', 'activity', { tasks: ['[[Missing Task]]'] }); const nodes = [activity]; - const index = buildTargetIndex(nodes); - - const { violations, refErrors } = validateRelationships(nodes, metadata, index); + resolveGraphEdges(nodes, metadata.hierarchy!.levels, metadata.relationships, metadata.typeAliases); + const { violations, refErrors } = validateGraph(nodes, metadata); expect(violations).toBeEmpty(); expect(refErrors).toHaveLength(1); @@ -175,9 +179,8 @@ describe('validateRelationships — fieldOn: parent', () => { const activity = makeNode('Activity 1', 'activity', { tasks: ['[[Some Solution]]'] }); const nodes = [activity, wrong]; - const index = buildTargetIndex(nodes); - - const { violations, refErrors } = validateRelationships(nodes, metadata, index); + resolveGraphEdges(nodes, metadata.hierarchy!.levels, metadata.relationships, metadata.typeAliases); + const { violations, refErrors } = validateGraph(nodes, metadata); expect(refErrors).toBeEmpty(); expect(violations).toHaveLength(1); @@ -188,9 +191,8 @@ describe('validateRelationships — fieldOn: parent', () => { const activity = makeNode('Activity 1', 'activity'); const nodes = [activity]; - const index = buildTargetIndex(nodes); - - const { violations, refErrors } = validateRelationships(nodes, metadata, index); + resolveGraphEdges(nodes, metadata.hierarchy!.levels, metadata.relationships, metadata.typeAliases); + const { violations, refErrors } = validateGraph(nodes, metadata); expect(violations).toBeEmpty(); expect(refErrors).toBeEmpty(); @@ -200,9 +202,8 @@ describe('validateRelationships — fieldOn: parent', () => { const activity = makeNode('Activity 1', 'activity', { tasks: '[[Task 1]]' }); const nodes = [activity]; - const index = buildTargetIndex(nodes); - - const { violations, refErrors } = validateRelationships(nodes, metadata, index); + resolveGraphEdges(nodes, metadata.hierarchy!.levels, metadata.relationships, metadata.typeAliases); + const { violations, refErrors } = validateGraph(nodes, metadata); expect(violations).toBeEmpty(); expect(refErrors).toHaveLength(1);