diff --git a/.claude/hooks/lint-and-test.sh b/.claude/hooks/lint-and-test.sh index 93e8bc2..304b9f1 100755 --- a/.claude/hooks/lint-and-test.sh +++ b/.claude/hooks/lint-and-test.sh @@ -15,7 +15,7 @@ fi echo "" >&2 echo "→ Running tests..." >&2 -if ! bun test --only-failures; then +if ! bun run test --only-failures; then STATUS=1 fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b3a68c0..b7fcced 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,4 +15,4 @@ jobs: bun-version: latest - run: bun install --frozen-lockfile - run: bun run lint - - run: bun test + - run: bun run test diff --git a/CLAUDE.md b/CLAUDE.md index 590eedb..a2b3433 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,8 @@ Space aliases (e.g. `personal`, `politics`) are resolved via `config.json`. This project validates OST node markdown files against a JSON schema. +Before starting new work, review [docs/concepts.md](docs/concepts.md) for canonical terminology. Use and maintain the definitions there as the source of truth when naming things in code, tests, comments, and documentation. + ## Tooling - `gray-matter` - Parse YAML frontmatter from markdown @@ -24,5 +26,10 @@ This project validates OST node markdown files against a JSON schema. - `config.json` — Space registry (alias → absolute path) - `schema.json` — Entity type definitions and validation rules +## Testing + +- `bun run test` — unit tests (fixtures in `tests/`) +- `bun run test:smoke` — smoke tests that run `validate` against every space in `config.json` (`smoke/`) + ## Hooks -A Stop hook runs linting, autoformatting and tests. If it reports issues related to change you made, address them. \ No newline at end of file +A Stop hook runs linting, autoformatting and unit tests. If it reports issues related to change you made, address them. \ No newline at end of file diff --git a/README.md b/README.md index 3d832ea..2f92478 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ bun install ## Concepts -### Entities in OST +See [docs/concepts.md](docs/concepts.md) for the full terminology reference, including definitions of OST nodes, embedded nodes, spaces, schemas, rules, and more. -- **Vision**: The aspirational outcome at the top of a tree. -- **Mission**: Strategic direction supporting a vision. -- **Goal**: Concrete, measurable targets. -- **Opportunity**: Identified chance to make progress. -- **Solution**: Proposed approach to address an opportunity. -- **Dashboard**: Index node for organizing and displaying tree structure. + +### Schemas + +A JSON-schema file defines the set of entities that a space adheres to. This allows for customisation and extension. + +Currently a single schema is included that combines a basic vision/mission/goals hierarchy with a hierarchy _similar_ to Opportunity Solution Trees. It is designed to be a bit more flexible to allow rapid initial adoption. The plan is to extend the set of schemas available to include some more opinionated and strict examples, and to support composability. ### Spaces @@ -112,8 +112,14 @@ bun run src/index.ts validate personal # Run diagram command bun run src/index.ts diagram personal -# Run tests, using a set of fixtures -bun test +# Run unit tests (fixtures in tests/) +bun run test + +# Run validation smoke tests against all locally configured spaces +bun run test:smoke + +# Run all tests +bun run test:all ``` ## Schema diff --git a/biome.json b/biome.json index 950dc45..75cfbca 100644 --- a/biome.json +++ b/biome.json @@ -19,8 +19,8 @@ "rules": { "recommended": true, "style": { - "noNonNullAssertion": "off" - } + "noNonNullAssertion": "off" + } } }, "javascript": { diff --git a/config.example.json b/config.example.json index 43a5890..31d8ed9 100644 --- a/config.example.json +++ b/config.example.json @@ -8,4 +8,4 @@ } ], "templateDir": "/path/to/ProductX/Templates" -} \ No newline at end of file +} diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..1cfa2bc --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,150 @@ +# OST Tools: Concepts and Terminology + +This document is the canonical reference for concepts and terminology used in this project. It focuses on the meta-concepts the project supports, not the content of specific frameworks modelled in schemas. Before naming things in code, tests, comments, or documentation, check definitions here for consistency, and update them here when the project's "world view" changes, avoiding blurry terms as much as possible. + +--- + +## Space + +A **space** is a named collection of nodes organised according to a schema. Spaces are the primary unit of organisation — a space has a backing format (a `space directory` or an `OST on a page` file) and may be registered in `config.json` with an alias for convenient access. + +```json +{ "alias": "personal", "path": "/path/to/planning directory" } +``` + +A space carries optional configuration alongside its alias: schema path, template directory, and integration settings (e.g. Miro board ID). + +> The term "space" is preferred over "OST" or "tree" because the tooling is not limited to a specific framework, and future schemas may not be strictly tree-shaped. + +### Space directory + +A **space directory** is a directory of markdown files that backs a `space`. Each file may represent an `OST node`, embed child nodes in its body, or be an unrelated file that the tooling ignores. + +Parsing behaviour for a space directory: +- Files declaring an `OST node` type via frontmatter are included as nodes. +- Such files may also contain `embedded nodes` in their body, which are extracted and included. +- Files declaring a `tooling type` (e.g. `ost_on_a_page`, `dashboard`) are excluded from the node set. +- Files without frontmatter, or without a `type` field, are excluded from the node set. +- Non-markdown files are not scanned. + +### OST on a page + +**OST on a page** is a single-file backing format for a `space`. An entire planning tree is represented in one markdown document, using heading hierarchy, bullet point annotations, and `anchor` syntax. No separate per-node files are used. This format is most useful for the early development stages of a space, keeping information together in one file with less "boilerplate". + +A file in this format carries `type: ost_on_a_page` in its frontmatter. It is not itself an `OST node` — it is a container. + +Key properties: +- Heading hierarchy determines node depth and infers `OST node` type (depth-based type inference). +- Heading levels must not skip — each level must be exactly one deeper than its parent. +- A horizontal rule (`---`) terminates parsing; headings below it are ignored. + +> The name "OST on a page" may be revised as the tooling moves toward space-centric terminology — see [GitHub issue #22](https://github.com/mindsocket/ost-tools/issues/22). + +#### Preamble + +**Preamble** is content in an `OST on a page` document that appears before the first heading. It is parsed but discarded — not associated with any node. + +--- + +## OST node + +An **OST node** is a single entity in a `space` — a named, typed item defined in the schema. `OST nodes` are the primary content of a space. + +Node types are defined by the schema in use and may vary across schemas. Examples from the default schema: `vision`, `mission`, `goal`, `opportunity`, `solution`. The tooling is not prescriptive about which types exist — schemas are designed to be extended and replaced. + +> `ost_on_a_page` and `dashboard` are not `OST node` types — they are `tooling types`. + +> The "OST" prefix reflects the project's origins. As the tooling evolves toward broader planning support, this term may be revised — see [GitHub issue #22](https://github.com/mindsocket/ost-tools/issues/22). + +### Embedded node + +An **embedded node** is an `OST node` defined *within* a containing document rather than as its own file. Embedded nodes are declared using markdown heading syntax with inline field annotations (e.g. `[type:: goal]`) or `anchor-implied types`, and are extracted at parse time. + +A `typed page` may contain embedded nodes in its body. Those nodes become full members of the parsed node set, with `parent references` wired to their containing page or enclosing heading. + +### Type alias + +A **type alias** is an alternative name accepted in the `type` field for a given `OST node` type. Aliases allow teams to use their own vocabulary while still receiving schema validation. For example, a schema might accept `outcome` as an alias for `goal`. + +*(Type alias support is planned — see [GitHub issue #14](https://github.com/mindsocket/ost-tools/issues/14).)* + +--- + +## Typed page + +A **typed page** is a markdown file whose frontmatter declares an `OST node` type (e.g. `type: goal`). The file itself represents one node, and its body may additionally contain `embedded nodes`. + +Typed pages are distinct from `OST on a page` files: a typed page *is* an `OST node`; an `ost_on_a_page` file is merely a container. + +--- + +## Schema + +A **schema** defines the valid structure for `OST nodes` in a `space`: the fields, types, constraints, and descriptive `rules` for each entity type. A space uses the default schema unless a custom one is declared in its config. + +The schema handles structural validation. It does not encode qualitative or cross-node checks — those are handled by `rules`, which may be embedded within the schema or applied separately. + +Schemas are designed to be composable: shared building blocks (common field sets, scoring models, constraint overlays) can be referenced across schema files, letting teams tailor a schema without forking its foundations. *(Schema composability is under active development — see [GitHub issues #13](https://github.com/mindsocket/ost-tools/issues/13), [#17](https://github.com/mindsocket/ost-tools/issues/17).)* + +### Rules + +**Rules** are descriptive, and potentially executable, checks applied to `OST nodes` beyond what structural schema validation can express. Rules encode qualitative guidance and best practices alongside the schema, making them available to both tooling and agent skills. + +Rules may be: +- **Descriptive** — human-readable guidance, useful as documentation and as structured input to agent skills +- **Executable** — mechanically evaluable expressions (e.g. "no more than one `active` node of a given type at a time") +- **Quantitative** — numeric thresholds or counts applied to node sets +- **Stage-based** — triggered only when a node's `status` meets a condition +- **Qualitative** — checks on content and framing (e.g. ensuring an opportunity is stated in the user's voice, not as a business goal) +- **Cross-entity** — checks spanning multiple nodes or levels of the tree +- **Coherence** — verifying that statements across related nodes credibly support one another +- **Best-practice** — guidance encoded as checks (e.g. flagging solution-framing in problem descriptions) + +Rules are distinct from schema validation: the schema checks structure; rules check meaning and quality. + +*(Rules support is planned — see [GitHub issue #16](https://github.com/mindsocket/ost-tools/issues/16).)* + +--- + +## Tooling types + +**Tooling types** are `type` values recognised by the schema and tooling but not treated as `OST nodes`. They serve organisational or display purposes: + +- **`ost_on_a_page`** — a container file for an `OST on a page`. Not itself a node. +- **`dashboard`** — a summary view for a `space directory`. Conceptually similar to `OST on a page` in that it presents a high-level, single-document view of a space — but rather than defining the space, it reflects it, querying and assembling information from the space's node files. Useful after a space has "graduated" from a single `OST on a page` file to a `space directory`, as a way to preserve that top-level overview. The dashboard concept may evolve to surface more operational information over time, but there is no concrete design for that yet. + +--- + +## Parent reference + +A **parent reference** is the `parent` field on an `OST node` — a `wikilink` pointing to the node's direct parent in the tree. Root-level node types (such as `vision` in the default schema) carry no parent. Other node types carry one optionally, allowing for orphaned nodes — useful while drafting a tree or when explicitly capturing ideas like "solutions looking for a problem". + +Parent references are validated during ref-checking: each `parent` wikilink must resolve to a known node title in the parsed node set. + +### Wikilink + +A **wikilink** is the `[[Title]]` linking syntax (compatible with Obsidian) used to express `parent references` between `OST nodes`. The `parent` field of a node holds a wikilink to its parent. + +Two forms are supported: + +| Form | Example | Resolves to | +|---|---|---| +| Plain title | `[[My Goal]]` | The `OST node` whose title equals `My Goal` | +| Anchor ref | `[[vision_page#^goal1]]` | The `embedded node` with `anchor` `goal1` inside `vision_page.md` | + +### Anchor + +An **anchor** is a block anchor (e.g. `^goal1`) appended to a heading in a `typed page`, using Obsidian block anchor syntax. Anchors serve two purposes: + +1. **Cross-file references** — other files can reference an `embedded node` by `[[filename#^anchor]]`. +2. **Anchor-implied type** — if the anchor name matches a node type name or a node type name followed by digits (e.g. `^mission`, `^goal1`), the node's type is inferred from the anchor, making an explicit inline annotation unnecessary. + +--- + +## Status + +**Status** is a lifecycle field on `OST nodes` indicating a node's current stage. The valid values and their semantics are defined by the schema in use. Examples from the default schema (in rough progression): + +`identified` → `wondering` → `exploring` → `active` → `paused` → `completed` → `archived` + +Status is required on all `OST node` types at _validation_ time. Note however that currently the `On A Page` parser chooses to apply a default. diff --git a/package.json b/package.json index f92c684..5a84cef 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ "scripts": { "validate": "bun run src/index.ts validate", "diagram": "bun run src/index.ts diagram", - "test": "bun test", - "lint": "biome check src/ tests/", - "lint:fix": "biome check --write src/ tests/" + "test": "bun test tests/", + "test:smoke": "bun test smoke/", + "test:all": "bun test", + "lint": "biome check", + "lint:fix": "biome check --write" }, "devDependencies": { "@biomejs/biome": "^2.4.4", diff --git a/schema.json b/schema.json index de1762d..843b1fa 100644 --- a/schema.json +++ b/schema.json @@ -23,10 +23,7 @@ }, { "type": "object", - "allOf": [ - { "$ref": "#/$defs/baseNodeProps" }, - { "$ref": "#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "#/$defs/baseNodeProps" }, { "$ref": "#/$defs/ostEntityProps" }], "properties": { "type": { "const": "vision" } }, @@ -42,10 +39,7 @@ }, { "type": "object", - "allOf": [ - { "$ref": "#/$defs/baseNodeProps" }, - { "$ref": "#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "#/$defs/baseNodeProps" }, { "$ref": "#/$defs/ostEntityProps" }], "properties": { "type": { "const": "mission" }, "parent": { "$ref": "#/$defs/wikilink" } @@ -62,10 +56,7 @@ }, { "type": "object", - "allOf": [ - { "$ref": "#/$defs/baseNodeProps" }, - { "$ref": "#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "#/$defs/baseNodeProps" }, { "$ref": "#/$defs/ostEntityProps" }], "properties": { "type": { "const": "goal" }, "parent": { "$ref": "#/$defs/wikilink" }, @@ -84,10 +75,7 @@ }, { "type": "object", - "allOf": [ - { "$ref": "#/$defs/baseNodeProps" }, - { "$ref": "#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "#/$defs/baseNodeProps" }, { "$ref": "#/$defs/ostEntityProps" }], "properties": { "type": { "const": "opportunity" }, "parent": { "$ref": "#/$defs/wikilink" }, @@ -117,10 +105,7 @@ }, { "type": "object", - "allOf": [ - { "$ref": "#/$defs/baseNodeProps" }, - { "$ref": "#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "#/$defs/baseNodeProps" }, { "$ref": "#/$defs/ostEntityProps" }], "properties": { "type": { "const": "solution" }, "parent": { @@ -192,15 +177,7 @@ }, "status": { "type": "string", - "enum": [ - "identified", - "wondering", - "exploring", - "active", - "paused", - "completed", - "archived" - ] + "enum": ["identified", "wondering", "exploring", "active", "paused", "completed", "archived"] }, "priority": { "type": "string", diff --git a/smoke/spaces.test.ts b/smoke/spaces.test.ts new file mode 100644 index 0000000..d59a789 --- /dev/null +++ b/smoke/spaces.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'bun:test'; +import { join } from 'node:path'; +import { loadConfig } from '../src/config.js'; + +const ROOT = join(import.meta.dir, '..'); +const config = loadConfig(); + +describe('Smoke: validate all configured spaces', () => { + for (const space of config.spaces) { + it(`${space.alias} passes validation`, () => { + const result = Bun.spawnSync(['bun', 'run', 'src/index.ts', 'validate', space.alias], { + cwd: ROOT, + stdout: 'pipe', + stderr: 'pipe', + }); + + if (result.exitCode !== 0) { + const output = new TextDecoder().decode(result.stdout); + const errors = new TextDecoder().decode(result.stderr); + console.error(`\n--- ${space.alias} stdout ---\n${output}`); + if (errors) console.error(`--- ${space.alias} stderr ---\n${errors}`); + } + + expect(result.exitCode).toBe(0); + }); + } +}); diff --git a/src/diagram.ts b/src/diagram.ts index 4a8b80f..d7005bd 100644 --- a/src/diagram.ts +++ b/src/diagram.ts @@ -1,6 +1,6 @@ import { readFileSync, statSync, writeFileSync } from 'node:fs'; import Ajv from 'ajv'; -import { readOstPage } from './read-ost-page.js'; +import { readOstOnAPage } from './read-ost-on-a-page.js'; import { readSpace } from './read-space.js'; import type { OstNode } from './types.js'; @@ -12,12 +12,6 @@ interface DiagramNode { priority?: string; } -// Parse [[wikilink]] to just the text inside -function parseWikilink(wikilink: string): string { - const match = wikilink.match(/^\[\[(.+)\]\]$/); - return match ? match[1]! : wikilink; -} - export async function diagram(path: string, options: { schema: string; output?: string }): Promise { const schema = JSON.parse(readFileSync(options.schema, 'utf-8')); const ajv = new Ajv(); @@ -28,7 +22,7 @@ export async function diagram(path: string, options: { schema: string; output?: let nonOst: string[] = []; if (statSync(path).isFile()) { - ({ nodes: spaceNodes } = readOstPage(path)); + ({ nodes: spaceNodes } = readOstOnAPage(path)); } else { ({ nodes: spaceNodes, skipped, nonOst } = await readSpace(path)); } @@ -36,20 +30,20 @@ export async function diagram(path: string, options: { schema: string; output?: const invalid: string[] = []; for (const node of spaceNodes) { - const valid = validateFunc(node.data); + const valid = validateFunc(node.schemaData); if (!valid) { invalid.push(node.label); continue; } - const parent = node.data.parent ? parseWikilink(node.data.parent as string) : undefined; + const parent = node.resolvedParent; nodes.push({ - id: node.data.title as string, - type: node.data.type as string, - status: node.data.status as string, + id: node.schemaData.title as string, + type: node.schemaData.type as string, + status: node.schemaData.status as string, parent, - priority: node.data.priority as string | undefined, + priority: node.schemaData.priority as string | undefined, }); } diff --git a/src/dump.ts b/src/dump.ts index 50c2063..c855bc5 100644 --- a/src/dump.ts +++ b/src/dump.ts @@ -1,10 +1,10 @@ import { statSync } from 'node:fs'; -import { readOstPage } from './read-ost-page.js'; +import { readOstOnAPage } from './read-ost-on-a-page.js'; import { readSpace } from './read-space.js'; export async function dump(path: string) { if (statSync(path).isFile()) { - const { nodes, diagnostics } = readOstPage(path); + const { nodes, diagnostics } = readOstOnAPage(path); console.log(JSON.stringify({ nodes, diagnostics }, null, 2)); } else { const { nodes, skipped, nonOst } = await readSpace(path); diff --git a/src/miro/cache.ts b/src/miro/cache.ts index 7c25e06..05329f8 100644 --- a/src/miro/cache.ts +++ b/src/miro/cache.ts @@ -49,12 +49,12 @@ export function saveCache(cache: SyncCache): void { export function computeNodeHash(node: OstNode): string { const relevant = { - title: node.data.title, - type: node.data.type, - status: node.data.status, - summary: node.data.summary, - priority: node.data.priority, - parent: node.data.parent, + title: node.schemaData.title, + type: node.schemaData.type, + status: node.schemaData.status, + summary: node.schemaData.summary, + priority: node.schemaData.priority, + parent: node.resolvedParent ?? node.schemaData.parent, }; return createHash('sha256').update(JSON.stringify(relevant)).digest('hex').slice(0, 16); } diff --git a/src/miro/layout.ts b/src/miro/layout.ts index c069f64..02a6f7b 100644 --- a/src/miro/layout.ts +++ b/src/miro/layout.ts @@ -44,7 +44,7 @@ export function layoutNewCards( // Group new nodes by depth const byDepth = new Map(); for (const node of newNodes) { - const depth = TYPE_DEPTH[node.data.type as string] ?? 4; + const depth = TYPE_DEPTH[node.schemaData.type as string] ?? 4; if (!byDepth.has(depth)) byDepth.set(depth, []); byDepth.get(depth)?.push(node); } @@ -59,7 +59,7 @@ export function layoutNewCards( let x = -totalWidth / 2 + CARD_WIDTH / 2; for (const node of nodes) { - const title = node.data.title as string; + const title = node.schemaData.title as string; positions.set(title, { x, y: rowY }); x += CARD_WIDTH + H_GAP; } diff --git a/src/miro/styles.ts b/src/miro/styles.ts index b540556..5adf926 100644 --- a/src/miro/styles.ts +++ b/src/miro/styles.ts @@ -23,9 +23,9 @@ export function getCardColor(type: string): string { } export function buildCardTitle(node: OstNode): string { - const title = node.data.title as string; - const status = node.data.status as string | undefined; - const priority = node.data.priority as string | undefined; + const title = node.schemaData.title as string; + const status = node.schemaData.status as string | undefined; + const priority = node.schemaData.priority as string | undefined; const icon = status ? (STATUS_ICONS[status] ?? status) : ''; const prefix = icon ? `[${icon}] ` : ''; @@ -37,15 +37,15 @@ export function buildCardTitle(node: OstNode): string { export function buildCardDescription(node: OstNode): string { const parts: string[] = []; - const type = node.data.type as string; - const status = node.data.status as string | undefined; + const type = node.schemaData.type as string; + const status = node.schemaData.status as string | undefined; parts.push(`Type: ${type}`); if (status) parts.push(`Status: ${status}`); - const summary = node.data.summary as string | undefined; + const summary = node.schemaData.summary as string | undefined; if (summary) parts.push(`\n${summary}`); - const content = node.data.content as string | undefined; + const content = node.schemaData.content as string | undefined; if (content) parts.push(`\n${content}`); return parts.join('\n'); diff --git a/src/miro/sync.ts b/src/miro/sync.ts index 03f1fab..c76fd22 100644 --- a/src/miro/sync.ts +++ b/src/miro/sync.ts @@ -1,6 +1,6 @@ import { statSync } from 'node:fs'; import { loadConfig, resolveSpacePath, updateSpaceField } from '../config.js'; -import { readOstPage } from '../read-ost-page.js'; +import { readOstOnAPage } from '../read-ost-on-a-page.js'; import { readSpace } from '../read-space.js'; import type { OstNode } from '../types.js'; import { computeMiroCardHash, computeNodeHash, loadCache, saveCache } from './cache.js'; @@ -14,11 +14,6 @@ interface SyncOptions { verbose?: boolean; } -function parseWikilink(wikilink: string): string { - const match = wikilink.match(/^\[\[(.+)\]\]$/); - return match ? match[1]! : wikilink; -} - export async function miroSync(spaceOrPath: string, options: SyncOptions): Promise { const token = process.env.MIRO_TOKEN; if (!token) { @@ -56,7 +51,7 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi let nodes: OstNode[]; if (statSync(resolvedPath).isFile()) { - ({ nodes } = readOstPage(resolvedPath)); + ({ nodes } = readOstOnAPage(resolvedPath)); } else { ({ nodes } = await readSpace(resolvedPath)); } @@ -163,7 +158,7 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi let skippedCount = 0; for (const node of nodes) { - const title = node.data.title as string; + const title = node.schemaData.title as string; // Compute what we expect to be in Miro (using the same build functions) const expectedTitle = buildCardTitle(node); const expectedDesc = buildCardDescription(node); @@ -206,8 +201,8 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi // 7. Create new cards let createdCount = 0; for (const node of newNodes) { - const title = node.data.title as string; - const type = node.data.type as string; + const title = node.schemaData.title as string; + const type = node.schemaData.type as string; let pos = newPositions.get(title) ?? { x: 0, y: 0 }; // Apply offset if we created a new frame (to center layout in frame) @@ -251,7 +246,7 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi // 8. Update changed cards let updatedCount = 0; for (const { node, cardId } of updatedNodes) { - const title = node.data.title as string; + const title = node.schemaData.title as string; if (options.dryRun) { console.log(`[dry-run] Update card: "${title}"`); @@ -277,7 +272,7 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi if (e instanceof MiroNotFoundError) { // Card was deleted from Miro — recreate it console.log(`Card "${title}" missing from Miro, recreating...`); - const type = node.data.type as string; + const type = node.schemaData.type as string; const card = await client.createCard({ data: { title: buildCardTitle(node), @@ -308,10 +303,9 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi // Only include edges where both endpoints have verified cards on the board const desiredEdges = new Map(); for (const node of nodes) { - const parentRaw = node.data.parent as string | undefined; - if (!parentRaw) continue; - const parentTitle = parseWikilink(parentRaw); - const childTitle = node.data.title as string; + const parentTitle = node.resolvedParent; + if (!parentTitle) continue; + const childTitle = node.schemaData.title as string; // 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/parse-embedded.ts b/src/parse-embedded.ts new file mode 100644 index 0000000..9d828c6 --- /dev/null +++ b/src/parse-embedded.ts @@ -0,0 +1,389 @@ +import { load as yamlLoad } from 'js-yaml'; +import type { Code, Heading, List, ListItem, Paragraph, Root } from 'mdast'; +import { toString as mdastToString } from 'mdast-util-to-string'; +import remarkGfm from 'remark-gfm'; +import remarkParse from 'remark-parse'; +import { unified } from 'unified'; +import type { OstNode, OstPageDiagnostics } from './types.js'; + +export const OST_TYPES = ['vision', 'mission', 'goal', 'opportunity', 'solution'] as const; +export type OstType = (typeof OST_TYPES)[number]; + +export const DEFAULT_STATUS = 'identified'; + +export interface StackEntry { + depth: number; + title: string; + /** Empty string marks an untyped heading placeholder (typed-page mode, i.e. not ost_on_a_page). */ + ostType: string; + /** Preferred wikilink key used when this heading acts as a parent. */ + refTarget: string; +} + +/** Extract [key:: value] bracketed inline fields, return cleaned text and fields. */ +export function extractBracketedFields(text: string): { + cleanText: string; + fields: Record; +} { + const fields: Record = {}; + const cleanText = text + .replace(/\[([^\]]+?):: *([^\]]*)\]/g, (_, key, value) => { + fields[key.trim()] = value.trim(); + return ''; + }) + .trim(); + return { cleanText, fields }; +} + +/** + * Extract unbracketed dataview fields (key:: value on own line). + * Keys must be identifier-style (letters, digits, hyphens, underscores - no spaces). + * Lines matching the pattern are consumed as fields; other lines kept as content. + */ +export function extractUnbracketedFields(text: string): { + remainingText: string; + fields: Record; +} { + const fields: Record = {}; + const remaining: string[] = []; + + for (const line of text.split('\n')) { + const match = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):: *(.*)$/); + if (match) { + fields[match[1]!.trim()] = match[2]!.trim(); + } else { + remaining.push(line); + } + } + + return { remainingText: remaining.join('\n').trim(), fields }; +} + +/** + * Extract a trailing Obsidian block anchor from heading text. + * e.g. "My Title ^anchor-id" -> { cleanText: "My Title", anchor: "anchor-id" } + */ +export function extractAnchor(text: string): { cleanText: string; anchor?: string } { + const match = text.match(/\s+\^([a-zA-Z0-9][a-zA-Z0-9_-]*)$/); + if (match) { + return { + cleanText: text.slice(0, text.length - match[0].length).trim(), + anchor: match[1], + }; + } + return { cleanText: text }; +} + +/** + * If the anchor name exactly matches an OST type (or an OST type followed by digits), + * return that type. Otherwise return undefined. + * Examples: "mission" -> "mission", "goal1" -> "goal", "myanchor" -> undefined + */ +export function anchorToOstType(anchor: string): string | undefined { + for (const type of OST_TYPES) { + if (anchor === type || new RegExp(`^${type}\\d+$`).test(anchor)) { + return type; + } + } + return undefined; +} + +/** + * Turn a full heading string into an Obsidian section-target key component. + * - normalizes observed Obsidian separators (#, ^, :, \) to spaces + * - compresses whitespace runs to single spaces + */ +export function normalizeHeadingSectionTarget(rawHeadingText: string): string { + return rawHeadingText + .replace(/[#^:\\]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Returns the default OST type for a new heading based on its parent's effective type. + * The first heading in a document defaults to 'vision'; each child is the next in sequence. + */ +export function defaultOstType(stack: StackEntry[]): string { + if (stack.length === 0) return OST_TYPES[0]!; + const parentType = stack[stack.length - 1]?.ostType; + const idx = OST_TYPES.indexOf(parentType as OstType); + if (idx === -1 || idx >= OST_TYPES.length - 1) { + throw new Error(`No OST type follows "${parentType}" - cannot determine type for child heading`); + } + return OST_TYPES[idx + 1]!; +} + +function appendContent(node: OstNode, text: string): void { + if (!text) return; + const existing = node.schemaData.content as string | undefined; + node.schemaData.content = existing ? `${existing}\n${text}` : text; +} + +function processListItem( + item: ListItem, + parentRef: string | undefined, + contentTarget: OstNode, + nodes: OstNode[], + makeLabel: (title: string) => string, + buildLinkTargets: (title: string) => string[], +): void { + const firstPara = item.children.find((c) => c.type === 'paragraph') as Paragraph | undefined; + + if (!firstPara) { + appendContent(contentTarget, `- ${mdastToString(item)}`); + return; + } + + const rawText = mdastToString(firstPara); + const { cleanText, fields } = extractBracketedFields(rawText); + + if (fields.type) { + const dashIdx = cleanText.indexOf(' - '); + const title = (dashIdx >= 0 ? cleanText.slice(0, dashIdx) : cleanText).trim(); + const summary = dashIdx >= 0 ? cleanText.slice(dashIdx + 3).trim() : undefined; + + const schemaData: Record = { + title, + type: fields.type, + status: DEFAULT_STATUS, + ...fields, + }; + if (parentRef) schemaData.parent = parentRef; + if (summary) schemaData.summary = summary; + + const linkTargets = buildLinkTargets(title); + const newNode: OstNode = { label: makeLabel(title), schemaData, linkTargets }; + nodes.push(newNode); + + const nestedParentRef = `[[${linkTargets[0] ?? title}]]`; + for (const child of item.children) { + if (child.type === 'list') { + for (const subItem of (child as List).children) { + processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets); + } + } + } + } else { + appendContent(contentTarget, `- ${rawText}`); + } +} + +export interface ExtractEmbeddedOptions { + /** + * Title of the containing page. If provided (and pageType is non-`ost_on_a_page`), + * the page acts as a virtual depth-0 parent for first-level embedded headings. + */ + pageTitle?: string; + /** + * OST type of the containing page. + * - If set to a real OST type (not 'ost_on_a_page'): only headings with an explicit + * `[type:: x]` field or an OST-type anchor become nodes (typed-page mode). + * - If 'ost_on_a_page' or undefined: all headings become nodes with depth-based + * type inference (classic ost_on_a_page behaviour). + */ + pageType?: string; +} + +export interface ExtractEmbeddedResult { + nodes: OstNode[]; + diagnostics: OstPageDiagnostics; +} + +/** + * Extract OST nodes from markdown body text. + * + * Shared by both readOstOnAPage (single ost_on_a_page file) and readSpace + * (directory) to find embedded sub-nodes within a page's content. + */ +export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptions = {}): ExtractEmbeddedResult { + const { pageTitle, pageType } = options; + const isOnAPageMode = pageType === undefined || pageType === 'ost_on_a_page'; + + const nodes: OstNode[] = []; + // Preamble/root content sink - never added to nodes + const rootNode: OstNode = { label: '_root_', schemaData: { type: 'ost_on_a_page' }, linkTargets: [] }; + + const tree = unified().use(remarkParse).use(remarkGfm).parse(body) as Root; + + // In typed-page mode: stack starts with the page's own virtual entry (depth 0). + // In ost_on_a_page mode: stack starts empty (first heading has no parent). + const stack: StackEntry[] = + !isOnAPageMode && pageTitle !== undefined + ? [{ depth: 0, title: pageTitle, ostType: pageType, refTarget: pageTitle }] + : []; + + let currentContextNode: OstNode = rootNode; + + type ParseState = 'preamble' | 'active' | 'done'; + let parseState: ParseState = 'preamble'; + + const diagnostics: OstPageDiagnostics = { + preambleNodeCount: 0, + terminatedHeadings: [], + }; + + function makeLabel(title: string): string { + return title; + } + + /** + * Walk the stack backwards to find the deepest real OST node entry (ostType !== ''). + * Untyped-heading placeholders (ostType === '') are skipped so that typed headings + * beneath an untyped heading correctly inherit the last typed ancestor. + */ + function currentParentRef(): string | undefined { + for (let i = stack.length - 1; i >= 0; i--) { + const entry = stack[i]!; + if (entry.ostType === '') continue; + return `[[${entry.refTarget}]]`; + } + return undefined; + } + + function buildHeadingLinkTargets(rawHeadingText: string, title: string, anchor?: string): string[] { + if (!pageTitle) { + return [title]; + } + + const targets: string[] = []; + + const sectionTarget = normalizeHeadingSectionTarget(rawHeadingText); + if (sectionTarget) { + targets.push(`${pageTitle}#${sectionTarget}`); + } + + if (anchor) { + targets.push(`${pageTitle}#^${anchor}`); + } + + return targets.length > 0 ? targets : [title]; + } + + function buildListItemLinkTargets(title: string): string[] { + if (!pageTitle) return [title]; + const normalized = normalizeHeadingSectionTarget(title); + return normalized ? [`${pageTitle}#${normalized}`] : [title]; + } + + for (const child of tree.children) { + if (parseState === 'done') { + if (child.type === 'heading') { + const rawTitle = mdastToString(child); + const { cleanText: afterBracketed } = extractBracketedFields(rawTitle); + const { cleanText: title } = extractAnchor(afterBracketed); + diagnostics.terminatedHeadings.push(title); + } + continue; + } + + if (child.type === 'thematicBreak') { + if (parseState === 'active') parseState = 'done'; + continue; + } + + if (child.type === 'heading') { + const heading = child as Heading; + const depth = heading.depth; + if (depth > 5) continue; + + parseState = 'active'; + + const rawText = mdastToString(heading); + const { cleanText: afterBracketed, fields: inlineFields } = extractBracketedFields(rawText); + const { cleanText: title, anchor } = extractAnchor(afterBracketed); + + const anchorType = anchor ? anchorToOstType(anchor) : undefined; + const hasExplicitType = !!inlineFields.type; + const hasImpliedType = !!anchorType; + + if (!isOnAPageMode && !hasExplicitType && !hasImpliedType) { + // Untyped heading in typed-page mode: update depth stack but don't create a node. + while (stack.length > 0 && stack[stack.length - 1]!.depth >= depth) { + stack.pop(); + } + stack.push({ depth, title, ostType: '', refTarget: title }); + continue; + } + + // In ost_on_a_page mode, enforce the no-level-skip rule. + if (isOnAPageMode && stack.length > 0) { + const topDepth = stack[stack.length - 1]!.depth; + if (depth > topDepth + 1) { + throw new Error(`Heading level skipped: jumped from H${topDepth} to H${depth} at "${title}"`); + } + } + + while (stack.length > 0 && stack[stack.length - 1]!.depth >= depth) { + stack.pop(); + } + + const type = inlineFields.type ?? anchorType ?? defaultOstType(stack); + const parentRef = currentParentRef(); + + const schemaData: Record = { + title, + type, + status: DEFAULT_STATUS, + ...inlineFields, + }; + if (parentRef) schemaData.parent = parentRef; + + const linkTargets = buildHeadingLinkTargets(rawText, title, anchor); + const headingNode: OstNode = { label: makeLabel(title), schemaData, linkTargets }; + nodes.push(headingNode); + currentContextNode = headingNode; + + const refTarget = linkTargets[0] ?? title; + stack.push({ depth, title, ostType: type, refTarget }); + } else if (parseState !== 'active') { + diagnostics.preambleNodeCount++; + } else if (child.type === 'list') { + const parentRef = currentParentRef(); + for (const item of (child as List).children) { + processListItem(item, parentRef, currentContextNode, nodes, makeLabel, buildListItemLinkTargets); + } + } else if (child.type === 'paragraph') { + const rawText = mdastToString(child); + const { cleanText: afterBracketed, fields: bracketedFields } = extractBracketedFields(rawText); + const { remainingText, fields: unbracketedFields } = extractUnbracketedFields(afterBracketed); + + const allFields = { ...unbracketedFields, ...bracketedFields }; + if ('type' in allFields) { + const title = currentContextNode.schemaData.title as string | undefined; + throw new Error( + `Type override via paragraph field is not supported at "${title ?? currentContextNode.label}". ` + + `Put [type:: ${allFields.type}] directly in the heading text.`, + ); + } + + Object.assign(currentContextNode.schemaData, allFields); + if (remainingText) appendContent(currentContextNode, remainingText); + } else if (child.type === 'code') { + const code = child as Code; + if (code.lang?.trim() === 'yaml') { + const parsed = yamlLoad(code.value); + + if (Array.isArray(parsed)) { + throw new Error( + `YAML block must be an object (key-value properties for the current node), not an array. ` + + `Use typed bullets - e.g. "- [type:: solution] Title" - to define child nodes inline.`, + ); + } + + if (parsed && typeof parsed === 'object') { + Object.assign(currentContextNode.schemaData, parsed as Record); + } else { + appendContent(currentContextNode, code.value); + } + } else { + appendContent(currentContextNode, code.value); + } + } else { + const text = mdastToString(child); + appendContent(currentContextNode, text); + } + } + + return { nodes, diagnostics }; +} diff --git a/src/read-ost-on-a-page.ts b/src/read-ost-on-a-page.ts new file mode 100644 index 0000000..47f0e5f --- /dev/null +++ b/src/read-ost-on-a-page.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { basename } from 'node:path'; +import matter from 'gray-matter'; +import { extractEmbeddedNodes } from './parse-embedded.js'; +import { resolveParentLinks } from './resolve-links.js'; +import type { OstOnAPageReadResult } from './types.js'; + +export function readOstOnAPage(filePath: string): OstOnAPageReadResult { + const raw = readFileSync(filePath, 'utf-8'); + const { data: frontmatter, content: body } = matter(raw); + + const pageType = frontmatter.type as string | undefined; + if (pageType && pageType !== 'ost_on_a_page') { + throw new Error( + `Expected an ost_on_a_page file but got type "${pageType}" in ${filePath}. ` + + `Use a directory path to validate a space containing typed node files.`, + ); + } + + const pageTitle = basename(filePath, '.md'); + const { nodes, diagnostics } = extractEmbeddedNodes(body, { pageTitle, pageType: 'ost_on_a_page' }); + resolveParentLinks(nodes); + return { nodes, diagnostics }; +} diff --git a/src/read-ost-page.ts b/src/read-ost-page.ts deleted file mode 100644 index 917a1db..0000000 --- a/src/read-ost-page.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { readFileSync } from 'node:fs'; -import matter from 'gray-matter'; -import { load as yamlLoad } from 'js-yaml'; -import type { Code, Heading, List, ListItem, Paragraph, Root } from 'mdast'; -import { toString as mdastToString } from 'mdast-util-to-string'; -import remarkGfm from 'remark-gfm'; -import remarkParse from 'remark-parse'; -import { unified } from 'unified'; -import type { OstNode, OstPageReadResult } from './types.js'; - -const OST_TYPES = ['vision', 'mission', 'goal', 'opportunity', 'solution'] as const; - -/** - * Returns the default OST type for a new heading based on its parent's effective type. - * The first heading in a document defaults to 'vision'; each child is the next in sequence. - */ -function defaultOstType(stack: StackEntry[]): string { - if (stack.length === 0) return OST_TYPES[0]!; - const parentType = stack[stack.length - 1]?.ostType; - const idx = OST_TYPES.indexOf(parentType as (typeof OST_TYPES)[number]); - if (idx === -1 || idx >= OST_TYPES.length - 1) { - throw new Error(`No OST type follows "${parentType}" — cannot determine type for child heading`); - } - return OST_TYPES[idx + 1]!; -} - -const DEFAULT_STATUS = 'identified'; - -/** Extract [key:: value] bracketed inline fields, return cleaned text and fields. */ -function extractBracketedFields(text: string): { - cleanText: string; - fields: Record; -} { - const fields: Record = {}; - const cleanText = text - .replace(/\[([^\]]+?):: *([^\]]*)\]/g, (_, key, value) => { - fields[key.trim()] = value.trim(); - return ''; - }) - .trim(); - return { cleanText, fields }; -} - -/** - * Extract unbracketed dataview fields (key:: value on own line). - * Keys must be identifier-style (letters, digits, hyphens, underscores — no spaces). - * Lines matching the pattern are consumed as fields; other lines kept as content. - */ -function extractUnbracketedFields(text: string): { - remainingText: string; - fields: Record; -} { - const fields: Record = {}; - const remaining: string[] = []; - - for (const line of text.split('\n')) { - const match = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):: *(.*)$/); - if (match) { - fields[match[1]!.trim()] = match[2]!.trim(); - } else { - remaining.push(line); - } - } - - return { remainingText: remaining.join('\n').trim(), fields }; -} - -function appendContent(node: OstNode, text: string): void { - if (!text) return; - const existing = node.data.content as string | undefined; - node.data.content = existing ? `${existing}\n${text}` : text; -} - -/** - * Process a list item. Reads only the item's own paragraph (not nested lists) - * to extract the type and text. Typed items become nodes; untyped items append - * to contentTarget. Nested lists are processed recursively. - */ -function processListItem( - item: ListItem, - parentTitle: string | undefined, - contentTarget: OstNode, - nodes: OstNode[], -): void { - const firstPara = item.children.find((c) => c.type === 'paragraph') as Paragraph | undefined; - - if (!firstPara) { - // No paragraph child (e.g. a list that starts directly with a nested list). - // Recover by appending whatever text we can extract. - appendContent(contentTarget, `- ${mdastToString(item)}`); - return; - } - - const rawText = mdastToString(firstPara!); - const { cleanText, fields } = extractBracketedFields(rawText); - - if (fields.type) { - // Typed bullet → child node - const dashIdx = cleanText.indexOf(' - '); - const title = (dashIdx >= 0 ? cleanText.slice(0, dashIdx) : cleanText).trim(); - const summary = dashIdx >= 0 ? cleanText.slice(dashIdx + 3).trim() : undefined; - - const data: Record = { - title, - type: fields.type, - status: DEFAULT_STATUS, - ...fields, - }; - if (parentTitle) data.parent = `[[${parentTitle}]]`; - if (summary) data.summary = summary; - - const newNode: OstNode = { label: title, data }; - nodes.push(newNode); - - // Recursively process nested lists with newNode as both parent and content target - for (const child of item.children) { - if (child.type === 'list') { - for (const subItem of (child as List).children) { - processListItem(subItem, title, newNode, nodes); - } - } - } - } else { - // Untyped bullet → append to content target - appendContent(contentTarget, `- ${rawText}`); - } -} - -interface StackEntry { - depth: number; - title: string; - ostType: string; -} - -export function readOstPage(filePath: string): OstPageReadResult { - const raw = readFileSync(filePath, 'utf-8'); - const { data: frontmatter, content: body } = matter(raw); - - const nodes: OstNode[] = []; - - // Internal preamble content target — not returned as a node. - const rootNode: OstNode = { - label: filePath, - data: { ...frontmatter, type: 'ost_on_a_page' }, - }; - - const tree = unified().use(remarkParse).use(remarkGfm).parse(body) as Root; - - // Heading ancestry stack - const stack: StackEntry[] = []; - - // Tracks the most recent heading node; paragraphs and untyped bullets target this, - // not nodes[last] which may be a typed bullet child. - let currentContextNode: OstNode = rootNode; - - // 'preamble': before the first OST heading — non-heading content is ignored. - // 'active': inside the OST tree — content is parsed normally. - // 'done': after a thematic break (---) during active parsing — everything ignored. - type ParseState = 'preamble' | 'active' | 'done'; - let parseState: ParseState = 'preamble'; - - const diagnostics = { - preambleNodeCount: 0, - terminatedHeadings: [] as string[], - }; - - function currentParentTitle(): string | undefined { - return stack.length > 0 ? stack[stack.length - 1]?.title : undefined; - } - - for (const child of tree.children) { - if (parseState === 'done') { - if (child.type === 'heading') { - const rawTitle = mdastToString(child!); - const { cleanText: title } = extractBracketedFields(rawTitle); - diagnostics.terminatedHeadings.push(title); - } - continue; - } - - if (child.type === 'thematicBreak') { - if (parseState === 'active') parseState = 'done'; - continue; - } - - if (child.type === 'heading') { - const heading = child as Heading; - const depth = heading.depth; - if (depth > 5) continue; - - parseState = 'active'; - - if (stack.length > 0) { - const topDepth = stack[stack.length - 1]!.depth; - if (depth > topDepth + 1) { - const rawTitle = mdastToString(heading); - throw new Error(`Heading level skipped: jumped from H${topDepth} to H${depth} at "${rawTitle}"`); - } - } - - while (stack.length > 0 && stack[stack.length - 1]!.depth >= depth) { - stack.pop(); - } - - const rawText = mdastToString(heading); - const { cleanText: title, fields: inlineFields } = extractBracketedFields(rawText); - const type = inlineFields.type ?? defaultOstType(stack); - const parentTitle = currentParentTitle(); - - const data: Record = { - title, - type, - status: DEFAULT_STATUS, - ...inlineFields, - }; - if (parentTitle) data.parent = `[[${parentTitle}]]`; - - const headingNode: OstNode = { label: title, data }; - nodes.push(headingNode); - currentContextNode = headingNode; - stack.push({ depth, title, ostType: type }); - } else if (parseState !== 'active') { - // Preamble content — ignore - diagnostics.preambleNodeCount++; - } else if (child.type === 'list') { - const parentTitle = currentParentTitle(); - for (const item of (child as List).children) { - processListItem(item, parentTitle, currentContextNode, nodes); - } - } else if (child.type === 'paragraph') { - const rawText = mdastToString(child!); - - // Extract bracketed fields first, then unbracketed fields from remaining text - const { cleanText: afterBracketed, fields: bracketedFields } = extractBracketedFields(rawText); - const { remainingText, fields: unbracketedFields } = extractUnbracketedFields(afterBracketed); - - const allFields = { ...unbracketedFields, ...bracketedFields }; - if ('type' in allFields) { - throw new Error( - `Type override via paragraph field is not supported at "${currentContextNode.data.title}". ` + - `Put [type:: ${allFields.type}] directly in the heading text.`, - ); - } - - Object.assign(currentContextNode.data, allFields); - if (remainingText) appendContent(currentContextNode, remainingText); - } else if (child.type === 'code') { - const code = child as Code; - if (code.lang?.trim() === 'yaml') { - const parsed = yamlLoad(code.value); - - if (Array.isArray(parsed)) { - throw new Error( - `YAML block must be an object (key-value properties for the current node), not an array. ` + - `Use typed bullets — e.g. "- [type:: solution] Title" — to define child nodes inline.`, - ); - } else if (parsed && typeof parsed === 'object') { - // Object dict → merge as properties into current node - Object.assign(currentContextNode.data, parsed as Record); - } else { - // Scalar or null YAML — preserve raw value as content - appendContent(currentContextNode, code.value); - } - } else { - // Non-YAML code block — treat as content - appendContent(currentContextNode, code.value); - } - } else { - // Any other node type (blockquote, table, html, image, etc.) — - // extract whatever text is available and preserve it as content - const text = mdastToString(child); - appendContent(currentContextNode, text); - } - } - - return { nodes, diagnostics }; -} diff --git a/src/read-space.ts b/src/read-space.ts index bdc2c15..03d5b90 100644 --- a/src/read-space.ts +++ b/src/read-space.ts @@ -2,9 +2,14 @@ import { readFileSync } from 'node:fs'; import { basename, join } from 'node:path'; import { glob } from 'glob'; import matter from 'gray-matter'; +import { extractEmbeddedNodes } from './parse-embedded.js'; +import { resolveParentLinks } from './resolve-links.js'; import type { OstNode, SpaceReadResult } from './types.js'; -export async function readSpace(directory: string, options?: { includePageFiles?: boolean }): Promise { +export async function readSpace( + directory: string, + options?: { includeOnAPageFiles?: boolean }, +): Promise { const files = await glob('**/*.md', { cwd: directory, absolute: false }); const nodes: OstNode[] = []; const skipped: string[] = []; @@ -24,15 +29,30 @@ export async function readSpace(directory: string, options?: { includePageFiles? continue; } - if (parsed.data.type === 'ost_on_a_page' && !options?.includePageFiles) { + if (parsed.data.type === 'ost_on_a_page' && !options?.includeOnAPageFiles) { continue; } + const pageType = parsed.data.type as string; + const fileBase = basename(file, '.md'); + nodes.push({ label: file, - data: { title: basename(file, '.md'), ...parsed.data }, + schemaData: { title: fileBase, ...parsed.data }, + linkTargets: [fileBase], }); + + // Extract embedded child nodes from the page body (typed pages with embedded nodes). + // ost_on_a_page files are already excluded above. + if (pageType !== 'ost_on_a_page') { + const { nodes: embedded } = extractEmbeddedNodes(parsed.content, { + pageTitle: fileBase, + pageType, + }); + nodes.push(...embedded); + } } + resolveParentLinks(nodes); return { nodes, skipped, nonOst }; } diff --git a/src/resolve-links.ts b/src/resolve-links.ts new file mode 100644 index 0000000..aca6047 --- /dev/null +++ b/src/resolve-links.ts @@ -0,0 +1,60 @@ +import type { OstNode } from './types.js'; + +function addTarget(index: Map, target: string, node: OstNode): void { + const normalized = target.trim(); + if (!normalized) return; + + const existing = index.get(normalized); + if (existing === undefined) { + index.set(normalized, node); + return; + } + + if (existing !== node) { + index.set(normalized, null); + } +} + +function buildTargetIndex(nodes: OstNode[]): Map { + const index = new Map(); + for (const node of nodes) { + for (const target of node.linkTargets) { + addTarget(index, target, node); + } + } + return index; +} + +/** + * Extract the lookup key from a wikilink string such as: + * [[Personal Vision]] → "Personal Vision" + * [[Personal Vision#Our Mission]] → "Personal Vision#Our Mission" + * [[vision_page#^ourmission]] → "vision_page#^ourmission" + */ +export function wikilinkToTarget(wikilink: string): string { + const cleaned = wikilink.replace(/^"|"$/g, '').trim(); + if (!cleaned.startsWith('[[') || !cleaned.endsWith(']]')) { + return cleaned; + } + return cleaned.slice(2, -2).trim(); +} + +export function resolveParentLinks(nodes: OstNode[]): void { + const targetIndex = buildTargetIndex(nodes); + + for (const node of nodes) { + node.resolvedParent = undefined; + + const rawParent = node.schemaData.parent; + if (typeof rawParent !== 'string') continue; + + const parentTarget = wikilinkToTarget(rawParent); + const parentNode = targetIndex.get(parentTarget); + if (!parentNode) continue; + + const parentTitle = parentNode.schemaData.title; + if (typeof parentTitle !== 'string') continue; + + node.resolvedParent = parentTitle; + } +} diff --git a/src/show.ts b/src/show.ts index b1c36ac..ef1894c 100644 --- a/src/show.ts +++ b/src/show.ts @@ -1,5 +1,5 @@ import { statSync } from 'node:fs'; -import { readOstPage } from './read-ost-page.js'; +import { readOstOnAPage } from './read-ost-on-a-page.js'; import { readSpace } from './read-space.js'; import type { OstNode } from './types.js'; @@ -7,7 +7,7 @@ export async function show(path: string) { let nodes: OstNode[]; if (statSync(path).isFile()) { - ({ nodes } = readOstPage(path)); + ({ nodes } = readOstOnAPage(path)); } else { ({ nodes } = await readSpace(path)); } @@ -15,17 +15,16 @@ export async function show(path: string) { // Build children map (parent title → child nodes in document order) const children = new Map(); for (const node of nodes) { - children.set(node.data.title as string, []); + children.set(node.schemaData.title as string, []); } const roots: OstNode[] = []; for (const node of nodes) { - const parent = node.data.parent as string | undefined; + const parent = node.resolvedParent; if (!parent) { roots.push(node); } else { - const parentTitle = parent.replace(/^"|"$/g, '').slice(2, -2); - const siblings = children.get(parentTitle); + const siblings = children.get(parent); if (siblings) { siblings.push(node); } else { @@ -36,8 +35,8 @@ export async function show(path: string) { function printNode(node: OstNode, depth: number) { const indent = ' '.repeat(depth); - const type = node.data.type as string; - const title = node.data.title as string; + const type = node.schemaData.type as string; + const title = node.schemaData.title as string; console.log(`${indent}- ${type}: ${title}`); for (const child of children.get(title) ?? []) { printNode(child, depth + 1); diff --git a/src/types.ts b/src/types.ts index 103bc9e..897c3a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,12 @@ export interface OstNode { /** Source identifier for error messages (filename or heading title) */ label: string; - /** Schema-ready data: all fields including injected title */ - data: Record; + /** Fields that are validated by schema.json. */ + schemaData: Record; + /** Valid navigation targets this node can be linked to (wikilink key without [[ ]]). */ + linkTargets: string[]; + /** Resolved canonical parent title (derived from schemaData.parent + linkTargets). */ + resolvedParent?: string; } export interface OstPageDiagnostics { @@ -12,7 +16,7 @@ export interface OstPageDiagnostics { terminatedHeadings: string[]; } -export interface OstPageReadResult { +export interface OstOnAPageReadResult { nodes: OstNode[]; diagnostics: OstPageDiagnostics; } diff --git a/src/validate.ts b/src/validate.ts index 39098c8..7e4aecf 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,7 +1,8 @@ import { readFileSync, statSync } from 'node:fs'; import Ajv, { type ErrorObject } from 'ajv'; -import { readOstPage } from './read-ost-page.js'; +import { readOstOnAPage } from './read-ost-on-a-page.js'; import { readSpace } from './read-space.js'; +import { wikilinkToTarget } from './resolve-links.js'; import type { OstNode } from './types.js'; interface ValidationResult { @@ -23,11 +24,9 @@ export async function validate(path: string, options: { schema: string }): Promi let nonOst: string[] = []; if (statSync(path).isFile()) { - ({ nodes } = readOstPage(path)); + ({ nodes } = readOstOnAPage(path)); } else { - ({ nodes, skipped, nonOst } = await readSpace(path, { - includePageFiles: true, - })); + ({ nodes, skipped, nonOst } = await readSpace(path)); } const result: ValidationResult = { @@ -40,7 +39,7 @@ export async function validate(path: string, options: { schema: string }): Promi }; for (const node of nodes) { - const valid = validateFunc(node.data); + const valid = validateFunc(node.schemaData); if (valid) { result.schemaValidCount++; @@ -53,24 +52,31 @@ export async function validate(path: string, options: { schema: string }): Promi } } - // Build index of all node labels (without .md extension) - const nodeIndex = new Map(nodes.map((n) => [n.label.replace(/\.md$/, ''), n])); - - function extractWikilinkFilename(wikilink: string): string { - const cleaned = wikilink.replace(/^"|"$/g, ''); - return cleaned.slice(2, -2); + // Parent refs are resolved to canonical titles on node.resolvedParent in read-* code. + const nodeIndex = new Map(); + for (const n of nodes) { + nodeIndex.set(n.schemaData.title as string, n); } for (const node of nodes) { - const parent = node.data.parent as string | undefined; + const parent = node.schemaData.parent as string | undefined; if (!parent) continue; - const parentFile = extractWikilinkFilename(parent); - if (!nodeIndex.has(parentFile)) { + const parentKey = node.resolvedParent; + if (!parentKey) { + result.refErrors.push({ + file: node.label, + parent: parent, + error: `Parent link target "${wikilinkToTarget(parent)}" not found`, + }); + continue; + } + + if (!nodeIndex.has(parentKey)) { result.refErrors.push({ file: node.label, parent: parent, - error: `Parent node "${parentFile}" not found`, + error: `Parent node "${parentKey}" not found`, }); } } diff --git a/tests/fixtures/valid-ost/anchor_vision.md b/tests/fixtures/valid-ost/anchor_vision.md new file mode 100644 index 0000000..7f09b3e --- /dev/null +++ b/tests/fixtures/valid-ost/anchor_vision.md @@ -0,0 +1,14 @@ +--- +type: vision +status: active +--- + +# Preamble (ignored) + +## Our Mission ^mission + +Mission content. + +## Another Goal ^goal1 + +Goal content with anchor-implied type. diff --git a/tests/fixtures/valid-ost/solution_page.md b/tests/fixtures/valid-ost/solution_page.md new file mode 100644 index 0000000..96b7691 --- /dev/null +++ b/tests/fixtures/valid-ost/solution_page.md @@ -0,0 +1,5 @@ +--- +type: solution +status: identified +parent: "[[vision_page#^embgoal]]" +--- diff --git a/tests/fixtures/valid-ost/vision_page.md b/tests/fixtures/valid-ost/vision_page.md new file mode 100644 index 0000000..f0ad937 --- /dev/null +++ b/tests/fixtures/valid-ost/vision_page.md @@ -0,0 +1,15 @@ +--- +type: vision +status: active +summary: A typed vision page with embedded sub-nodes +--- + +The vision body content. + +## [type:: mission] Embedded Mission ^embmission + +The mission body content. + +### [type:: goal] Embedded Goal ^embgoal + +The goal body content. diff --git a/tests/parse-embedded.test.ts b/tests/parse-embedded.test.ts new file mode 100644 index 0000000..24c02a1 --- /dev/null +++ b/tests/parse-embedded.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'bun:test'; +import { normalizeHeadingSectionTarget } from '../src/parse-embedded.js'; + +describe('normalizeHeadingSectionTarget', () => { + it('matches observed Obsidian bookmark normalization for special separators', () => { + const input = '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~'; + 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); + }); +}); diff --git a/tests/read-ost-page.test.ts b/tests/read-ost-on-a-page.test.ts similarity index 59% rename from tests/read-ost-page.test.ts rename to tests/read-ost-on-a-page.test.ts index 4a2951e..0e36d51 100644 --- a/tests/read-ost-page.test.ts +++ b/tests/read-ost-on-a-page.test.ts @@ -1,77 +1,81 @@ import { beforeAll, describe, expect, it } from 'bun:test'; import { join } from 'node:path'; -import { readOstPage } from '../src/read-ost-page.js'; -import type { OstPageReadResult } from '../src/types.js'; +import { readOstOnAPage } from '../src/read-ost-on-a-page.js'; +import type { OstOnAPageReadResult } from '../src/types.js'; const VALID_PAGE = join(import.meta.dir, 'fixtures/on-a-page-valid.md'); const SKIP_PAGE = join(import.meta.dir, 'fixtures/on-a-page-heading-skip.md'); -describe('readOstPage - on-a-page-valid.md', () => { - let result: OstPageReadResult; +describe('readOstOnAPage - on-a-page-valid.md (ost_on_a_page)', () => { + let result: OstOnAPageReadResult; beforeAll(() => { - result = readOstPage(VALID_PAGE); + result = readOstOnAPage(VALID_PAGE); }); describe('heading type inference', () => { it('infers H1 as vision with no parent', () => { const node = result.nodes.find((n) => n.label === 'Personal Vision'); - expect(node?.data.type).toBe('vision'); - expect(node?.data.parent).toBeUndefined(); + expect(node?.schemaData.type).toBe('vision'); + expect(node?.schemaData.parent).toBeUndefined(); }); it('infers H2 as mission with parent from H1', () => { const node = result.nodes.find((n) => n.label === 'Personal Mission'); - expect(node?.data.type).toBe('mission'); - expect(node?.data.parent).toBe('[[Personal Vision]]'); + expect(node?.schemaData.type).toBe('mission'); + expect(node?.resolvedParent).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?.data.type).toBe('goal'); - expect(node?.data.parent).toBe('[[Personal Mission]]'); + expect(node?.schemaData.type).toBe('goal'); + expect(node?.resolvedParent).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?.data.type).toBe('opportunity'); - expect(node?.data.parent).toBe('[[Career Growth]]'); + expect(node?.schemaData.type).toBe('opportunity'); + expect(node?.resolvedParent).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?.data.type).toBe('solution'); - expect(node?.data.parent).toBe('[[Technical Skills]]'); + expect(node?.schemaData.type).toBe('solution'); + expect(node?.resolvedParent).toBe('Technical Skills'); + expect(node?.schemaData.parent).toContain('[[on-a-page-valid#'); }); }); describe('default status', () => { it('applies DEFAULT_STATUS to heading nodes without explicit status', () => { const node = result.nodes.find((n) => n.label === 'Build a Side Project'); - expect(node?.data.status).toBe('identified'); + expect(node?.schemaData.status).toBe('identified'); }); }); describe('inline bracketed fields', () => { it('extracts [priority:: p2] from Career Growth heading and strips it from title', () => { const node = result.nodes.find((n) => n.label === 'Career Growth'); - expect(node?.data.priority).toBe('p2'); - expect(node?.data.title).toBe('Career Growth'); + expect(node?.schemaData.priority).toBe('p2'); + expect(node?.schemaData.title).toBe('Career Growth'); }); }); describe('unbracketed paragraph fields', () => { it('extracts status:: active on Personal Vision overriding DEFAULT_STATUS', () => { const node = result.nodes.find((n) => n.label === 'Personal Vision'); - expect(node?.data.status).toBe('active'); + expect(node?.schemaData.status).toBe('active'); }); }); describe('YAML code block', () => { it('merges YAML block fields into Personal Mission', () => { const node = result.nodes.find((n) => n.label === 'Personal Mission'); - expect(node?.data.status).toBe('active'); - expect(node?.data.summary).toBe('A mission-level summary set via YAML block'); + expect(node?.schemaData.status).toBe('active'); + expect(node?.schemaData.summary).toBe('A mission-level summary set via YAML block'); }); }); @@ -84,13 +88,14 @@ describe('readOstPage - on-a-page-valid.md', () => { it('sets parent and summary on Learn TypeScript from dash separator', () => { const node = result.nodes.find((n) => n.label === 'Learn TypeScript'); - expect(node?.data.parent).toBe('[[Technical Skills]]'); - expect(node?.data.summary).toBe('Master TypeScript for tool development'); + expect(node?.resolvedParent).toBe('Technical Skills'); + expect(node?.schemaData.parent).toContain('[[on-a-page-valid#'); + expect(node?.schemaData.summary).toBe('Master TypeScript for tool development'); }); it('applies DEFAULT_STATUS to typed bullet without explicit override', () => { const node = result.nodes.find((n) => n.label === 'Read OSTS Book'); - expect(node?.data.status).toBe('identified'); + expect(node?.schemaData.status).toBe('identified'); }); }); @@ -111,7 +116,14 @@ describe('readOstPage - on-a-page-valid.md', () => { describe('heading level skip error', () => { it('throws when heading level is skipped (H1 to H3)', () => { - expect(() => readOstPage(SKIP_PAGE)).toThrow(/Heading level skipped/); + expect(() => readOstOnAPage(SKIP_PAGE)).toThrow(/Heading level skipped/); + }); + }); + + describe('typed file rejection', () => { + it('throws when given a typed node file instead of ost_on_a_page', () => { + const typedFile = join(import.meta.dir, 'fixtures/valid-ost/Personal Vision.md'); + expect(() => readOstOnAPage(typedFile)).toThrow(/Expected an ost_on_a_page file/); }); }); }); diff --git a/tests/read-space.test.ts b/tests/read-space.test.ts index c952f53..96fc684 100644 --- a/tests/read-space.test.ts +++ b/tests/read-space.test.ts @@ -7,20 +7,20 @@ const VALID_DIR = join(import.meta.dir, 'fixtures/valid-ost'); const INVALID_DIR = join(import.meta.dir, 'fixtures/invalid-ost'); describe('readSpace', () => { - describe('valid-ost directory (default options)', () => { + describe('valid-ost directory', () => { let result: SpaceReadResult; beforeAll(async () => { result = await readSpace(VALID_DIR); }); - it('returns 5 OST nodes', () => { - expect(result.nodes).toHaveLength(5); + it('returns 12 OST nodes (5 original + vision_page + 2 embedded + solution_page + anchor_vision + 2 embedded)', () => { + expect(result.nodes).toHaveLength(12); }); - it('injects title from filename', () => { + it('injects title from filename for file-based nodes', () => { const vision = result.nodes.find((n) => n.label === 'Personal Vision.md'); - expect(vision?.data.title).toBe('Personal Vision'); + expect(vision?.schemaData.title).toBe('Personal Vision'); }); it('skips no-frontmatter.md', () => { @@ -41,13 +41,13 @@ describe('readSpace', () => { it('preserves numeric frontmatter fields on Technical Skills', () => { const ts = result.nodes.find((n) => n.label === 'Technical Skills.md'); - expect(ts?.data.impact).toBe(4); - expect(ts?.data.feasibility).toBe(3); - expect(ts?.data.resources).toBe(2); - expect(ts?.data.priority).toBe('p3'); + expect(ts?.schemaData.impact).toBe(4); + expect(ts?.schemaData.feasibility).toBe(3); + expect(ts?.schemaData.resources).toBe(2); + expect(ts?.schemaData.priority).toBe('p3'); }); - it('excludes Community OST.md by default (type: ost_on_a_page)', () => { + it('Community OST.md (ost_on_a_page) is excluded from nodes', () => { expect(result.nodes.every((n) => n.label !== 'Community OST.md')).toBe(true); }); @@ -57,10 +57,98 @@ describe('readSpace', () => { }); }); - it('includes ost_on_a_page nodes when includePageFiles is true', async () => { - const result = await readSpace(VALID_DIR, { includePageFiles: true }); - expect(result.nodes.find((n) => n.label === 'Community OST.md')).toBeDefined(); - expect(result.nodes).toHaveLength(6); + describe('embedded nodes in typed pages', () => { + let result: SpaceReadResult; + + beforeAll(async () => { + result = await readSpace(VALID_DIR); + }); + + it('includes vision_page.md as its own node', () => { + const node = result.nodes.find((n) => n.label === 'vision_page.md'); + expect(node).toBeDefined(); + expect(node?.schemaData.type).toBe('vision'); + expect(node?.schemaData.title).toBe('vision_page'); + }); + + it('extracts embedded mission with plain title', () => { + const node = result.nodes.find((n) => n.label === 'Embedded Mission'); + expect(node).toBeDefined(); + expect(node?.schemaData.type).toBe('mission'); + expect(node?.schemaData.title).toBe('Embedded Mission'); + }); + + 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?.resolvedParent).toBe('vision_page'); + }); + + it('stores navigation targets for embedded mission', () => { + const node = result.nodes.find((n) => n.label === 'Embedded Mission'); + expect(node?.linkTargets).toContain('vision_page#^embmission'); + expect(node?.linkTargets).toContain('vision_page#[type mission] Embedded Mission embmission'); + }); + + it('extracts nested embedded goal with plain title', () => { + const node = result.nodes.find((n) => n.label === 'Embedded Goal'); + expect(node).toBeDefined(); + expect(node?.schemaData.type).toBe('goal'); + }); + + 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?.resolvedParent).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?.resolvedParent).toBe('Embedded Goal'); + }); + }); + + describe('anchor-implied type inference', () => { + let result: SpaceReadResult; + + beforeAll(async () => { + result = await readSpace(VALID_DIR); + }); + + it('infers type "mission" from ^mission anchor', () => { + const node = result.nodes.find((n) => n.label === 'Our Mission'); + expect(node?.schemaData.type).toBe('mission'); + expect(node?.schemaData.title).toBe('Our Mission'); + }); + + it('infers type "goal" from ^goal1 anchor', () => { + const node = result.nodes.find((n) => n.label === 'Another Goal'); + expect(node?.schemaData.type).toBe('goal'); + expect(node?.schemaData.title).toBe('Another Goal'); + }); + + it('stores both section and anchor navigation targets when heading has a block anchor', () => { + const mission = result.nodes.find((n) => n.label === 'Our Mission'); + const goal = result.nodes.find((n) => n.label === 'Another Goal'); + expect(mission?.linkTargets).toContain('anchor_vision#^mission'); + expect(mission?.linkTargets).toContain('anchor_vision#Our Mission mission'); + expect(goal?.linkTargets).toContain('anchor_vision#^goal1'); + expect(goal?.linkTargets).toContain('anchor_vision#Another Goal goal1'); + }); + + it('does not include untyped preamble heading as a node', () => { + expect(result.nodes.map((n) => n.label)).not.toContain('Preamble (ignored)'); + }); + + it('resolves section/anchor parent links to canonical parent titles without mutating source links', () => { + 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?.resolvedParent).toBe('Embedded Mission'); + expect(solutionPage?.schemaData.parent).toBe('[[vision_page#^embgoal]]'); + expect(solutionPage?.resolvedParent).toBe('Embedded Goal'); + }); }); describe('invalid-ost directory', () => { diff --git a/tests/validate.test.ts b/tests/validate.test.ts index 436a79b..1448f2d 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -2,8 +2,9 @@ import { beforeAll, describe, expect, it } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import Ajv from 'ajv'; -import { readOstPage } from '../src/read-ost-page.js'; +import { readOstOnAPage } from '../src/read-ost-on-a-page.js'; import { readSpace } from '../src/read-space.js'; +import { resolveParentLinks } from '../src/resolve-links.js'; import type { OstNode } from '../src/types.js'; const SCHEMA_PATH = join(import.meta.dir, '../schema.json'); @@ -15,16 +16,18 @@ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf-8')); const ajv = new Ajv(); const validateNode = ajv.compile(schema); -// Inline ref-check helper — mirrors the logic in validate.ts +/** Inline ref-check helper - mirrors the logic in validate.ts. */ function checkRefErrors(nodes: OstNode[]): Array<{ file: string; parent: string }> { - const titles = new Set(nodes.map((n) => n.label.replace(/\.md$/, ''))); + const index = new Set(nodes.map((n) => n.schemaData.title as string)); + return nodes - .filter((n) => n.data.parent) + .filter((n) => n.schemaData.parent) .filter((n) => { - const parentTitle = (n.data.parent as string).slice(2, -2); - return !titles.has(parentTitle); + const parentKey = n.resolvedParent; + if (!parentKey) return true; + return !index.has(parentKey); }) - .map((n) => ({ file: n.label, parent: n.data.parent as string })); + .map((n) => ({ file: n.label, parent: n.schemaData.parent as string })); } describe('Schema validation', () => { @@ -35,10 +38,10 @@ describe('Schema validation', () => { ({ nodes } = await readSpace(VALID_DIR)); }); - it('all 5 nodes pass schema validation', () => { - expect(nodes).toHaveLength(5); + it('all 12 nodes pass schema validation', () => { + expect(nodes).toHaveLength(12); for (const node of nodes) { - expect(validateNode(node.data)).toBe(true); + expect(validateNode(node.schemaData)).toBe(true); } }); @@ -47,17 +50,17 @@ describe('Schema validation', () => { }); }); - describe('on-a-page-valid.md nodes (readOstPage)', () => { + describe('on-a-page-valid.md nodes (readOstOnAPage)', () => { let nodes: OstNode[]; beforeAll(() => { - ({ nodes } = readOstPage(VALID_PAGE)); + ({ nodes } = readOstOnAPage(VALID_PAGE)); }); it('all nodes pass schema validation', () => { expect(nodes.length).toBeGreaterThan(0); for (const node of nodes) { - expect(validateNode(node.data)).toBe(true); + expect(validateNode(node.schemaData)).toBe(true); } }); }); @@ -72,19 +75,19 @@ describe('Schema validation', () => { it('missing-status.md fails schema validation (no status field)', () => { const node = nodes.find((n) => n.label === 'missing-status.md'); expect(node).toBeDefined(); - expect(validateNode(node?.data)).toBe(false); + expect(validateNode(node?.schemaData)).toBe(false); }); it('vision-with-parent.md fails schema validation (vision forbids parent)', () => { const node = nodes.find((n) => n.label === 'vision-with-parent.md'); expect(node).toBeDefined(); - expect(validateNode(node?.data)).toBe(false); + expect(validateNode(node?.schemaData)).toBe(false); }); it('dangling-parent.md passes schema validation (ref is a separate check)', () => { const node = nodes.find((n) => n.label === 'dangling-parent.md'); expect(node).toBeDefined(); - expect(validateNode(node?.data)).toBe(true); + expect(validateNode(node?.schemaData)).toBe(true); }); it('detects dangling parent ref error for Nonexistent Node', () => { @@ -93,6 +96,119 @@ describe('Schema validation', () => { }); }); + describe('link-target parent resolution', () => { + it('resolves anchor/section wikilinks to canonical parent titles', () => { + const nodes: OstNode[] = [ + { + label: 'anchor_vision.md', + schemaData: { title: 'anchor_vision', type: 'vision', status: 'active' }, + linkTargets: ['anchor_vision'], + }, + { + label: 'Our Mission', + schemaData: { + title: 'Our Mission', + type: 'mission', + status: 'identified', + parent: '[[anchor_vision]]', + }, + linkTargets: ['anchor_vision#Our Mission mission', 'anchor_vision#^mission'], + }, + { + label: 'Another Goal', + schemaData: { + title: 'Another Goal', + type: 'goal', + status: 'identified', + parent: '[[anchor_vision#^mission]]', + }, + linkTargets: ['anchor_vision#Another Goal goal1', 'anchor_vision#^goal1'], + }, + { + label: 'solution_page.md', + schemaData: { + title: 'solution_page', + type: 'solution', + status: 'identified', + parent: '[[anchor_vision#^goal1]]', + }, + linkTargets: ['solution_page'], + }, + ]; + + resolveParentLinks(nodes); + + expect(nodes.find((n) => n.label === 'Another Goal')?.schemaData.parent).toBe('[[anchor_vision#^mission]]'); + expect(nodes.find((n) => n.label === 'Another Goal')?.resolvedParent).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')?.resolvedParent).toBe('Another Goal'); + expect(checkRefErrors(nodes)).toHaveLength(0); + }); + + it('keeps unresolved parent links untouched when no link target matches', () => { + const nodes: OstNode[] = [ + { + label: 'anchor_vision.md', + schemaData: { title: 'anchor_vision', type: 'vision', status: 'active' }, + linkTargets: ['anchor_vision'], + }, + { + label: 'some-solution.md', + schemaData: { + title: 'some-solution', + type: 'solution', + status: 'identified', + parent: '[[anchor_vision#^noanchor]]', + }, + linkTargets: ['some-solution'], + }, + ]; + + resolveParentLinks(nodes); + + const errors = checkRefErrors(nodes); + expect(errors).toHaveLength(1); + expect(errors[0]?.parent).toBe('[[anchor_vision#^noanchor]]'); + }); + + it('does not resolve bare embedded-node title links when no page exists', () => { + const nodes: OstNode[] = [ + { + label: 'vision_page.md', + schemaData: { title: 'vision_page', type: 'vision', status: 'active' }, + linkTargets: ['vision_page'], + }, + { + label: 'Embedded Goal', + schemaData: { + title: 'Embedded Goal', + type: 'goal', + status: 'identified', + parent: '[[vision_page]]', + }, + linkTargets: ['vision_page#Embedded Goal'], + }, + { + label: 'solution_page.md', + schemaData: { + title: 'solution_page', + type: 'solution', + status: 'identified', + parent: '[[Embedded Goal]]', + }, + linkTargets: ['solution_page'], + }, + ]; + + resolveParentLinks(nodes); + + expect(nodes.find((n) => n.label === 'solution_page.md')?.resolvedParent).toBeUndefined(); + const errors = checkRefErrors(nodes); + expect(errors).toHaveLength(1); + expect(errors[0]?.parent).toBe('[[Embedded Goal]]'); + }); + }); + describe('schema shape assertions (inline data)', () => { it('accepts a valid vision node', () => { expect(validateNode({ title: 'My Vision', type: 'vision', status: 'active' })).toBe(true); @@ -145,5 +261,27 @@ describe('Schema validation', () => { }), ).toBe(false); }); + + it('accepts mission with filename#section wikilink as parent', () => { + expect( + validateNode({ + title: 'M', + type: 'mission', + status: 'active', + parent: '[[vision_page#Our Mission]]', + }), + ).toBe(true); + }); + + it('accepts goal with anchor-based wikilink as parent', () => { + expect( + validateNode({ + title: 'G', + type: 'goal', + status: 'active', + parent: '[[vision_page#^mission]]', + }), + ).toBe(true); + }); }); });