From 478bf8b59ad03e93768a34263fcaf056f4157c12 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Wed, 25 Mar 2026 09:20:47 +1100 Subject: [PATCH 1/4] feat: implement render plugin hook (closes #63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `render` plugin hook to the ost-tools plugin contract, providing a centralised pipeline for rendering SpaceNode[] into output formats: Pipeline: parse → validate (AJV) → filter → classify → render hook Key changes: - New `RenderFormat`, `RenderInput`, `RenderHook` types in plugins/util.ts - `buildFormatRegistry()` in src/render/registry.ts collects formats from all loaded plugins (additive, unlike the first-responder parse/templateSync hooks) - `executeRender()` in src/render/render.ts drives the full pipeline - `ost-tools render ` command with `--filter` and `-o` options - `ost-tools render list [space]` lists available formats Markdown plugin provides two formats: - `markdown.bullets` — indented bullet list (replaces show command logic) - `markdown.mermaid` — Mermaid graph TD diagram (replaces diagram command logic) `show` and `diagram` kept as convenience aliases that delegate to the render pipeline. `diagram` gains `--filter` support as a free side-effect. `show` gains schema validation filtering it previously lacked. Follow-up issues: #64 (extract mermaid plugin), #65 (migrate miro-sync) --- src/commands/diagram.ts | 141 ++----------------------- src/commands/render.ts | 43 ++++++++ src/commands/show.ts | 64 +---------- src/index.ts | 23 +++- src/plugin-api.ts | 4 + src/plugins/markdown/index.ts | 13 +++ src/plugins/markdown/render-bullets.ts | 51 +++++++++ src/plugins/markdown/render-mermaid.ts | 76 +++++++++++++ src/plugins/util.ts | 36 ++++++- src/render/registry.ts | 30 ++++++ src/render/render.ts | 43 ++++++++ tests/render/registry.test.ts | 57 ++++++++++ tests/render/render-bullets.test.ts | 129 ++++++++++++++++++++++ tests/render/render-mermaid.test.ts | 114 ++++++++++++++++++++ 14 files changed, 627 insertions(+), 197 deletions(-) create mode 100644 src/commands/render.ts create mode 100644 src/plugins/markdown/render-bullets.ts create mode 100644 src/plugins/markdown/render-mermaid.ts create mode 100644 src/render/registry.ts create mode 100644 src/render/render.ts create mode 100644 tests/render/registry.test.ts create mode 100644 tests/render/render-bullets.test.ts create mode 100644 tests/render/render-mermaid.test.ts diff --git a/src/commands/diagram.ts b/src/commands/diagram.ts index fe57b3a..1c4d983 100644 --- a/src/commands/diagram.ts +++ b/src/commands/diagram.ts @@ -1,140 +1,13 @@ import { writeFileSync } from 'node:fs'; -import { readSpace } from '../read/read-space'; -import type { SpaceContext, SpaceNode } from '../types'; -import { buildHierarchyNodeSet, classifyNodes } from '../util/graph-helpers'; +import type { SpaceContext } from '../types'; +import { executeRender } from '../render/render'; -/** - * Escape strings for Mermaid diagram labels. - * Replaces quotes with " to prevent parsing errors. - */ -function escapeMermaidString(str: string): string { - return str.replace(/"/g, '"'); -} - -/** - * Create a safe node ID for Mermaid diagrams. - * Replaces special characters with underscores to prevent parsing errors. - */ -function safeNodeId(id: string): string { - return id.replace(/[^a-zA-Z0-9_-]/g, '_'); -} - -export async function diagram(context: SpaceContext, options: { output?: string }): Promise { - const { schema, schemaValidator } = context; - const hierarchyLevels = schema.metadata.hierarchy?.levels ?? []; - - const readResult = await readSpace(context); - const spaceNodes: SpaceNode[] = readResult.nodes; - const skipped = (readResult.diagnostics?.skipped as string[]) ?? []; - const nonSpace = (readResult.diagnostics?.nonSpace as string[]) ?? []; - - // Validate nodes - const validNodes: SpaceNode[] = []; - const invalid: string[] = []; - - for (const node of spaceNodes) { - const valid = schemaValidator(node.schemaData); - if (!valid) { - invalid.push(node.label); - continue; - } - validNodes.push(node); - } - - // Classify nodes using the new graph-helpers function - const classification = classifyNodes(validNodes, hierarchyLevels); - const { hierarchyRoots, orphans, nonHierarchy, children } = classification; - - // Build lookup for all hierarchy nodes (roots + orphans + descendants) - const hierarchyNodeSet = buildHierarchyNodeSet(classification); - - // Generate mermaid diagram - let mmd = 'graph TD\n'; - - // Add styling - mmd += ' classDef vision fill:#ff9999,stroke:#ff0000,stroke-width:2px\n'; - mmd += ' classDef mission fill:#99ccff,stroke:#0066cc,stroke-width:2px\n'; - mmd += ' classDef goal fill:#99ff99,stroke:#00cc00,stroke-width:2px\n'; - mmd += ' classDef opportunity fill:#ffcc99,stroke:#cc9900,stroke-width:2px\n'; - mmd += ' classDef solution fill:#cc99ff,stroke:#6600cc,stroke-width:2px\n'; - - // Define styles for each status - mmd += ' classDef identified fill:#f0f0f0,stroke:#999999,stroke-dasharray: 5 5\n'; - mmd += ' classDef wondering fill:#fff0cc,stroke:#cccc00,stroke-dasharray: 5 5\n'; - mmd += ' classDef exploring fill:#ffcc99,stroke:#cc9900,stroke-dasharray: 5 5\n'; - mmd += ' classDef active fill:#99ff99,stroke:#00cc00,stroke-width:2px\n'; - mmd += ' classDef paused fill:#ffcc99,stroke:#cc9900,stroke-width:2px\n'; - mmd += ' classDef completed fill:#ccccff,stroke:#6666cc,stroke-width:2px\n'; - mmd += ' classDef archived fill:#e0e0e0,stroke:#999999,stroke-width:2px\n'; - - // Add all hierarchy nodes (roots, orphans, and their children) - const addedNodes = new Set(); - - function addNodeAndChildren(node: SpaceNode) { - const nodeId = node.schemaData.title as string; - if (addedNodes.has(nodeId)) return; - addedNodes.add(nodeId); - - const type = node.schemaData.type as string; - const status = node.schemaData.status as string; - const priority = node.schemaData.priority as string | undefined; - const label = priority ? `${nodeId} (${priority})` : nodeId; - const className = `${type}_${status}`; - - // Use safe node ID for Mermaid syntax (left side - no quotes) - const safeId = safeNodeId(nodeId); - // Escape special characters in label (right side - with quotes) - const escapedLabel = escapeMermaidString(label); - - mmd += ` ${safeId}["${escapedLabel}"]:::${className}\n`; - - // Add edges to children using the children map - const nodeChildren = children.get(nodeId) ?? []; - for (const child of nodeChildren) { - // Only add edges to hierarchy nodes - if (hierarchyNodeSet.has(child.schemaData.title as string)) { - const childId = child.schemaData.title as string; - const safeChildId = safeNodeId(childId); - mmd += ` ${safeId} --> ${safeChildId}\n`; - addNodeAndChildren(child); - } - } - } - - // Add hierarchy roots - for (const root of hierarchyRoots) { - addNodeAndChildren(root); - } - - // Add orphans as a subgraph - if (orphans.length > 0) { - mmd += '\n subgraph Orphans\n'; - for (const orphan of orphans) { - addNodeAndChildren(orphan); - } - mmd += ' end\n'; - } - - // Output +export async function diagram(context: SpaceContext, options: { output?: string; filter?: string }): Promise { + const result = await executeRender('markdown.mermaid', context, { filter: options.filter }); if (options.output) { - writeFileSync(options.output, mmd); - console.log(`✅ Mermaid diagram written to ${options.output}`); + writeFileSync(options.output, result); + console.log(`Mermaid diagram written to ${options.output}`); } else { - console.log(mmd); - } - - // Report stats - console.error(`\n📊 Diagram Stats:`); - console.error(` Total hierarchy nodes: ${hierarchyRoots.length + orphans.length}`); - console.error(` Hierarchy roots: ${hierarchyRoots.length}`); - console.error(` Orphan nodes: ${orphans.length}`); - console.error(` Non-hierarchy nodes (not rendered): ${nonHierarchy.length}`); - console.error(` Skipped: ${skipped.length}`); - if (nonSpace.length > 0) { - console.error(` Non-space (no type field): ${nonSpace.length}`); - } - if (invalid.length > 0) { - console.error(` Invalid (skipped): ${invalid.length}`); - for (const f of invalid) console.error(` ${f}`); + console.log(result); } } diff --git a/src/commands/render.ts b/src/commands/render.ts new file mode 100644 index 0000000..83e4021 --- /dev/null +++ b/src/commands/render.ts @@ -0,0 +1,43 @@ +import { writeFileSync } from 'node:fs'; +import { loadPlugins } from '../plugins/loader'; +import { discoverPlugins } from '../plugins/loader'; +import type { SpaceContext } from '../types'; +import { buildFormatRegistry } from '../render/registry'; +import { executeRender } from '../render/render'; + +export async function render( + context: SpaceContext, + format: string, + options: { filter?: string; output?: string }, +): Promise { + const result = await executeRender(format, context, { filter: options.filter }); + if (options.output) { + writeFileSync(options.output, result); + console.error(`Written to ${options.output}`); + } else { + process.stdout.write(result); + if (!result.endsWith('\n')) process.stdout.write('\n'); + } +} + +export async function renderList(context?: SpaceContext): Promise { + let loaded; + if (context) { + const pluginMap: Record> = context.space?.plugins ?? {}; + loaded = await loadPlugins(pluginMap, context.configDir); + } else { + const discovered = await discoverPlugins(); + loaded = discovered.map((plugin) => ({ plugin, pluginConfig: {} })); + } + + const registry = buildFormatRegistry(loaded); + + if (registry.length === 0) { + console.log('No render formats available.'); + return; + } + + for (const entry of registry) { + console.log(` ${entry.qualifiedName.padEnd(24)} ${entry.format.description}`); + } +} diff --git a/src/commands/show.ts b/src/commands/show.ts index ab3d639..b003dba 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -1,62 +1,8 @@ -import { filterNodes } from '../filter/filter-nodes'; -import { readSpace } from '../read/read-space'; -import type { SpaceContext, SpaceNode } from '../types'; -import { classifyNodes } from '../util/graph-helpers'; +import type { SpaceContext } from '../types'; +import { executeRender } from '../render/render'; export async function show(context: SpaceContext, options?: { filter?: string }) { - const levels = context.schema.metadata.hierarchy?.levels ?? []; - - let { nodes } = await readSpace(context); - - if (options?.filter) { - const expression = context.space.views?.[options.filter]?.expression ?? options.filter; - nodes = await filterNodes(expression, nodes); - } - - const { hierarchyRoots, orphans, nonHierarchy, children } = classifyNodes(nodes, levels); - - const seen = new Set(); - - function printNode(node: SpaceNode, depth: number) { - const indent = ' '.repeat(depth); - const type = node.schemaData.type as string; - const title = node.schemaData.title as string; - const nodeChildren = children.get(title) ?? []; - - if (seen.has(title)) { - // Only mark (*) when there's a subtree being skipped — no marker if no children - if (nodeChildren.length > 0) { - console.log(`${indent}- ${type}: ${title} (*)`); - } - return; - } - seen.add(title); - console.log(`${indent}- ${type}: ${title}`); - for (const child of nodeChildren) { - printNode(child, depth + 1); - } - } - - // Main hierarchy tree - for (const root of hierarchyRoots) { - printNode(root, 0); - } - - // Orphans: in hierarchy but no parent - if (orphans.length > 0) { - console.log('\nOrphans (missing parent):'); - for (const node of orphans) { - printNode(node, 0); - } - } - - // Non-hierarchy types: flat list at the end - if (nonHierarchy.length > 0) { - console.log('\nOther (not in hierarchy):'); - for (const node of nonHierarchy) { - const type = node.schemaData.type as string; - const title = node.schemaData.title as string; - console.log(` - ${type}: ${title}`); - } - } + const result = await executeRender('markdown.bullets', context, { filter: options?.filter }); + process.stdout.write(result); + if (!result.endsWith('\n')) process.stdout.write('\n'); } diff --git a/src/index.ts b/src/index.ts index 959f786..259c4cd 100755 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { Command } from 'commander'; import { diagram } from './commands/diagram'; import { dump } from './commands/dump'; import { listPlugins } from './commands/plugins'; +import { render, renderList } from './commands/render'; import { readme } from './commands/readme'; import { listSchemas, showSchema } from './commands/schemas'; import { show } from './commands/show'; @@ -70,12 +71,13 @@ program .command('diagram') .description('Generate mermaid diagram from space') .argument('', 'Space name') + .option('--filter ', 'Filter view name (from config) or inline filter expression') .option('-o, --output ', 'Output file path (default: stdout)') .action((spaceName, options) => diagram(buildSpaceContext(spaceName), options)); program .command('show') - .description('Print space tree as an indented list') + .description('Print space graph as a bullet list') .argument('', 'Space name') .option('--filter ', 'Filter view name (from config) or inline filter expression') .action((spaceName, options) => show(buildSpaceContext(spaceName), options)); @@ -144,4 +146,23 @@ program .description('Show full README documentation') .action(() => readme()); +const renderCmd = new Command('render').description('Render a space in a given format'); +renderCmd + .command('list', { isDefault: false }) + .description('List available render formats') + .argument('[space-name]', 'Space name (optional, to show space-specific formats)') + .action(async (spaceName?: string) => { + const context = spaceName ? buildSpaceContext(spaceName) : undefined; + await renderList(context); + }); +renderCmd + .argument('', 'Space name') + .argument('', 'Render format (e.g. markdown.bullets)') + .option('--filter ', 'Filter view name (from config) or inline filter expression') + .option('-o, --output ', 'Output file path (default: stdout)') + .action(async (spaceName: string, format: string, options) => { + await render(buildSpaceContext(spaceName), format, options); + }); +program.addCommand(renderCmd); + program.parse(); diff --git a/src/plugin-api.ts b/src/plugin-api.ts index 4e250f6..cf3579a 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -12,9 +12,13 @@ export type { ParseHook, ParseResult, PluginContext, + RenderFormat, + RenderHook, + RenderInput, TemplateSyncHook, TemplateSyncOptions, } from './plugins/util'; +export type { NodeClassification } from './util/graph-helpers'; export type { SharedEmbeddingFields } from './schema/metadata-contract'; export type { BaseNode, diff --git a/src/plugins/markdown/index.ts b/src/plugins/markdown/index.ts index 1bb1eab..22114a2 100644 --- a/src/plugins/markdown/index.ts +++ b/src/plugins/markdown/index.ts @@ -2,6 +2,8 @@ import { statSync } from 'node:fs'; import type { OstToolsPlugin, ParseResult, PluginContext } from '../util'; import { PLUGIN_PREFIX } from '../util'; import { readSpaceDirectory, readSpaceOnAPage } from './read-space'; +import { renderBullets } from './render-bullets'; +import { renderMermaid } from './render-mermaid'; import { templateSync } from './template-sync'; export type MarkdownPluginConfig = { @@ -36,4 +38,15 @@ export const markdownPlugin: OstToolsPlugin = { configSchema: MARKDOWN_CONFIG_SCHEMA, parse, templateSync, + render: { + formats: [ + { name: 'bullets', description: 'Indented bullet list' }, + { name: 'mermaid', description: 'Mermaid graph TD diagram' }, + ], + render(format, input) { + if (format === 'bullets') return renderBullets(input); + if (format === 'mermaid') return renderMermaid(input); + throw new Error(`Unknown markdown render format: "${format}"`); + }, + }, }; diff --git a/src/plugins/markdown/render-bullets.ts b/src/plugins/markdown/render-bullets.ts new file mode 100644 index 0000000..d0a4eb3 --- /dev/null +++ b/src/plugins/markdown/render-bullets.ts @@ -0,0 +1,51 @@ +import type { RenderInput } from '../util'; +import type { SpaceNode } from '../../types'; + +export function renderBullets({ classification }: RenderInput): string { + const { hierarchyRoots, orphans, nonHierarchy, children } = classification; + const lines: string[] = []; + const seen = new Set(); + + function renderNode(node: SpaceNode, depth: number) { + const indent = ' '.repeat(depth); + const type = node.schemaData.type as string; + const title = node.schemaData.title as string; + const nodeChildren = children.get(title) ?? []; + + if (seen.has(title)) { + if (nodeChildren.length > 0) { + lines.push(`${indent}- ${type}: ${title} (*)`); + } + return; + } + seen.add(title); + lines.push(`${indent}- ${type}: ${title}`); + for (const child of nodeChildren) { + renderNode(child, depth + 1); + } + } + + for (const root of hierarchyRoots) { + renderNode(root, 0); + } + + if (orphans.length > 0) { + lines.push(''); + lines.push('Orphans (missing parent):'); + for (const node of orphans) { + renderNode(node, 0); + } + } + + if (nonHierarchy.length > 0) { + lines.push(''); + lines.push('Other (not in hierarchy):'); + for (const node of nonHierarchy) { + const type = node.schemaData.type as string; + const title = node.schemaData.title as string; + lines.push(` - ${type}: ${title}`); + } + } + + return lines.join('\n'); +} diff --git a/src/plugins/markdown/render-mermaid.ts b/src/plugins/markdown/render-mermaid.ts new file mode 100644 index 0000000..2bb7c45 --- /dev/null +++ b/src/plugins/markdown/render-mermaid.ts @@ -0,0 +1,76 @@ +import type { RenderInput } from '../util'; +import type { SpaceNode } from '../../types'; +import { buildHierarchyNodeSet } from '../../util/graph-helpers'; + +function escapeMermaidString(str: string): string { + return str.replace(/"/g, '"'); +} + +function safeNodeId(id: string): string { + return id.replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +export function renderMermaid({ classification }: RenderInput): string { + const { hierarchyRoots, orphans, children } = classification; + + const hierarchyNodeSet = buildHierarchyNodeSet(classification); + + let mmd = 'graph TD\n'; + + mmd += ' classDef vision fill:#ff9999,stroke:#ff0000,stroke-width:2px\n'; + mmd += ' classDef mission fill:#99ccff,stroke:#0066cc,stroke-width:2px\n'; + mmd += ' classDef goal fill:#99ff99,stroke:#00cc00,stroke-width:2px\n'; + mmd += ' classDef opportunity fill:#ffcc99,stroke:#cc9900,stroke-width:2px\n'; + mmd += ' classDef solution fill:#cc99ff,stroke:#6600cc,stroke-width:2px\n'; + + mmd += ' classDef identified fill:#f0f0f0,stroke:#999999,stroke-dasharray: 5 5\n'; + mmd += ' classDef wondering fill:#fff0cc,stroke:#cccc00,stroke-dasharray: 5 5\n'; + mmd += ' classDef exploring fill:#ffcc99,stroke:#cc9900,stroke-dasharray: 5 5\n'; + mmd += ' classDef active fill:#99ff99,stroke:#00cc00,stroke-width:2px\n'; + mmd += ' classDef paused fill:#ffcc99,stroke:#cc9900,stroke-width:2px\n'; + mmd += ' classDef completed fill:#ccccff,stroke:#6666cc,stroke-width:2px\n'; + mmd += ' classDef archived fill:#e0e0e0,stroke:#999999,stroke-width:2px\n'; + + const addedNodes = new Set(); + + function addNodeAndChildren(node: SpaceNode) { + const nodeId = node.schemaData.title as string; + if (addedNodes.has(nodeId)) return; + addedNodes.add(nodeId); + + const type = node.schemaData.type as string; + const status = node.schemaData.status as string; + const priority = node.schemaData.priority as string | undefined; + const label = priority ? `${nodeId} (${priority})` : nodeId; + const className = `${type}_${status}`; + + const safeId = safeNodeId(nodeId); + const escapedLabel = escapeMermaidString(label); + + mmd += ` ${safeId}["${escapedLabel}"]:::${className}\n`; + + const nodeChildren = children.get(nodeId) ?? []; + for (const child of nodeChildren) { + const childTitle = child.schemaData.title as string; + if (hierarchyNodeSet.has(childTitle)) { + const safeChildId = safeNodeId(childTitle); + mmd += ` ${safeId} --> ${safeChildId}\n`; + addNodeAndChildren(child); + } + } + } + + for (const root of hierarchyRoots) { + addNodeAndChildren(root); + } + + if (orphans.length > 0) { + mmd += '\n subgraph Orphans\n'; + for (const orphan of orphans) { + addNodeAndChildren(orphan); + } + mmd += ' end\n'; + } + + return mmd; +} diff --git a/src/plugins/util.ts b/src/plugins/util.ts index ca360d6..55b514d 100644 --- a/src/plugins/util.ts +++ b/src/plugins/util.ts @@ -1,5 +1,6 @@ import type { AnySchemaObject } from 'ajv'; -import type { BaseNode, SpaceContext } from '../types'; +import type { NodeClassification } from '../util/graph-helpers'; +import type { BaseNode, SpaceContext, SpaceNode } from '../types'; export const PLUGIN_PREFIX = 'ost-tools-'; export const CONFIG_PLUGINS_DIR = 'plugins'; @@ -31,11 +32,39 @@ export type TemplateSyncOptions = { export type TemplateSyncHook = (context: PluginContext, options: TemplateSyncOptions) => Promise; +/** A single render output format provided by a plugin. */ +export type RenderFormat = { + /** Short name, unique within the plugin (e.g. 'bullets', 'mermaid'). */ + name: string; + /** Human-readable description shown in `render list`. */ + description: string; +}; + +/** Input provided to a render function. */ +export type RenderInput = { + /** All valid nodes after filtering (or all valid nodes if no filter applied). */ + nodes: SpaceNode[]; + /** Pre-computed classification of the nodes. */ + classification: NodeClassification; + /** The full space context for access to schema, hierarchy levels, etc. */ + context: SpaceContext; +}; + +/** + * The render hook on a plugin: declares available formats and handles rendering. + * Unlike parse/templateSync (first-responder), render hooks are additive — + * all formats from all plugins are available simultaneously. + */ +export type RenderHook = { + formats: RenderFormat[]; + render: (format: string, input: RenderInput) => Promise | string; +}; + /** * Plugin contract: * - A hook not implemented on the plugin → that plugin is skipped for that operation. - * - A hook returns `T | null` → null means "didn't handle, try next plugin". - * - The orchestrator accepts the first non-null result; if no plugin handles, it throws. + * - parse/templateSync return `T | null` → null means "didn't handle, try next plugin". + * - render is additive: all plugins' formats are registered and dispatched by name. */ export type OstToolsPlugin = { name: string; @@ -44,4 +73,5 @@ export type OstToolsPlugin = { configSchema: AnySchemaObject; parse?: ParseHook; templateSync?: TemplateSyncHook; + render?: RenderHook; }; diff --git a/src/render/registry.ts b/src/render/registry.ts new file mode 100644 index 0000000..c28f4b4 --- /dev/null +++ b/src/render/registry.ts @@ -0,0 +1,30 @@ +import type { LoadedPlugin } from '../plugins/loader'; +import type { RenderFormat } from '../plugins/util'; +import { PLUGIN_PREFIX } from '../plugins/util'; + +export type ResolvedFormat = { + qualifiedName: string; + format: RenderFormat; + plugin: LoadedPlugin; +}; + +/** + * Build a registry of all render formats from loaded plugins. + * Formats are namespaced as `{shortPluginName}.{formatName}` where + * shortPluginName strips the `ost-tools-` prefix. + */ +export function buildFormatRegistry(loaded: LoadedPlugin[]): ResolvedFormat[] { + const registry: ResolvedFormat[] = []; + for (const lp of loaded) { + if (!lp.plugin.render) continue; + const shortName = lp.plugin.name.replace(PLUGIN_PREFIX, ''); + for (const format of lp.plugin.render.formats) { + registry.push({ + qualifiedName: `${shortName}.${format.name}`, + format, + plugin: lp, + }); + } + } + return registry; +} diff --git a/src/render/render.ts b/src/render/render.ts new file mode 100644 index 0000000..ea45a78 --- /dev/null +++ b/src/render/render.ts @@ -0,0 +1,43 @@ +import { loadPlugins } from '../plugins/loader'; +import { filterNodes } from '../filter/filter-nodes'; +import { readSpace } from '../read/read-space'; +import type { SpaceContext, SpaceNode } from '../types'; +import { classifyNodes } from '../util/graph-helpers'; +import { buildFormatRegistry } from './registry'; + +export async function executeRender( + formatName: string, + context: SpaceContext, + options: { filter?: string }, +): Promise { + const pluginMap: Record> = context.space?.plugins ?? {}; + const loaded = await loadPlugins(pluginMap, context.configDir); + const registry = buildFormatRegistry(loaded); + + const entry = registry.find((r) => r.qualifiedName === formatName); + if (!entry) { + const available = registry.map((r) => r.qualifiedName).join(', '); + throw new Error( + `Unknown render format: "${formatName}".${available ? ` Available: ${available}` : ' No formats registered.'}`, + ); + } + + const { nodes: allNodes } = await readSpace(context); + + // Validate: drop nodes that fail schema validation + const { schemaValidator } = context; + const validNodes: SpaceNode[] = allNodes.filter((node) => schemaValidator(node.schemaData)); + + // Filter: apply filter expression if provided + let nodes = validNodes; + if (options.filter) { + const expression = context.space.views?.[options.filter]?.expression ?? options.filter; + nodes = await filterNodes(expression, nodes); + } + + // Classify + const levels = context.schema.metadata.hierarchy?.levels ?? []; + const classification = classifyNodes(nodes, levels); + + return entry.plugin.plugin.render!.render(entry.format.name, { nodes, classification, context }); +} diff --git a/tests/render/registry.test.ts b/tests/render/registry.test.ts new file mode 100644 index 0000000..19c0a25 --- /dev/null +++ b/tests/render/registry.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'bun:test'; +import type { LoadedPlugin } from '../../src/plugins/loader'; +import type { OstToolsPlugin } from '../../src/plugins/util'; +import { buildFormatRegistry } from '../../src/render/registry'; + +function makePlugin(name: string, formats: { name: string; description: string }[]): LoadedPlugin { + const plugin: OstToolsPlugin = { + name, + configSchema: { type: 'object' }, + render: { + formats, + render: () => '', + }, + }; + return { plugin, pluginConfig: {} }; +} + +describe('buildFormatRegistry', () => { + it('returns empty registry for plugins with no render hook', () => { + const loaded: LoadedPlugin[] = [ + { plugin: { name: 'ost-tools-foo', configSchema: { type: 'object' } }, pluginConfig: {} }, + ]; + expect(buildFormatRegistry(loaded)).toEqual([]); + }); + + it('builds qualified names stripping the ost-tools- prefix', () => { + const loaded = [makePlugin('ost-tools-markdown', [{ name: 'bullets', description: 'Bullet list' }])]; + const registry = buildFormatRegistry(loaded); + expect(registry).toHaveLength(1); + expect(registry[0].qualifiedName).toBe('markdown.bullets'); + expect(registry[0].format.name).toBe('bullets'); + expect(registry[0].format.description).toBe('Bullet list'); + }); + + it('collects formats from multiple plugins', () => { + const loaded = [ + makePlugin('ost-tools-markdown', [ + { name: 'bullets', description: 'Bullet list' }, + { name: 'mermaid', description: 'Mermaid diagram' }, + ]), + makePlugin('ost-tools-slides', [{ name: 'reveal', description: 'Reveal.js slides' }]), + ]; + const registry = buildFormatRegistry(loaded); + expect(registry).toHaveLength(3); + expect(registry.map((r) => r.qualifiedName)).toEqual(['markdown.bullets', 'markdown.mermaid', 'slides.reveal']); + }); + + it('skips plugins without a render hook and includes those with one', () => { + const loaded: LoadedPlugin[] = [ + { plugin: { name: 'ost-tools-norender', configSchema: { type: 'object' } }, pluginConfig: {} }, + ...([makePlugin('ost-tools-markdown', [{ name: 'bullets', description: 'Bullets' }])]), + ]; + const registry = buildFormatRegistry(loaded); + expect(registry).toHaveLength(1); + expect(registry[0].qualifiedName).toBe('markdown.bullets'); + }); +}); diff --git a/tests/render/render-bullets.test.ts b/tests/render/render-bullets.test.ts new file mode 100644 index 0000000..2702921 --- /dev/null +++ b/tests/render/render-bullets.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'bun:test'; +import { renderBullets } from '../../src/plugins/markdown/render-bullets'; +import type { RenderInput } from '../../src/plugins/util'; +import type { NodeClassification } from '../../src/util/graph-helpers'; +import type { SpaceContext, SpaceNode } from '../../src/types'; +import { makeParentRef } from '../test-helpers'; + +function makeNode(title: string, type: string): SpaceNode { + return { + label: `${title}.md`, + schemaData: { title, type }, + linkTargets: [title], + type, + resolvedType: type, + resolvedParents: [], + }; +} + +function makeInput(classification: NodeClassification): RenderInput { + const allNodes = [ + ...classification.hierarchyRoots, + ...classification.orphans, + ...classification.nonHierarchy, + ...[...classification.children.values()].flat(), + ]; + return { + nodes: [...new Map(allNodes.map((n) => [n.schemaData.title, n])).values()], + classification, + context: {} as SpaceContext, + }; +} + +describe('renderBullets', () => { + it('renders a single root with no children', () => { + const root = makeNode('My Goal', 'goal'); + const classification: NodeClassification = { + hierarchyRoots: [root], + orphans: [], + nonHierarchy: [], + children: new Map([['My Goal', []]]), + }; + const output = renderBullets(makeInput(classification)); + expect(output).toBe('- goal: My Goal'); + }); + + it('renders a tree with parent-child hierarchy', () => { + const goal = makeNode('My Goal', 'goal'); + const opp = makeNode('An Opportunity', 'opportunity'); + opp.resolvedParents = [makeParentRef('My Goal')]; + const classification: NodeClassification = { + hierarchyRoots: [goal], + orphans: [], + nonHierarchy: [], + children: new Map([ + ['My Goal', [opp]], + ['An Opportunity', []], + ]), + }; + const output = renderBullets(makeInput(classification)); + expect(output).toBe('- goal: My Goal\n - opportunity: An Opportunity'); + }); + + it('renders orphans in a separate section', () => { + const orphan = makeNode('Orphaned Opp', 'opportunity'); + const classification: NodeClassification = { + hierarchyRoots: [], + orphans: [orphan], + nonHierarchy: [], + children: new Map([['Orphaned Opp', []]]), + }; + const output = renderBullets(makeInput(classification)); + expect(output).toContain('Orphans (missing parent):'); + expect(output).toContain('- opportunity: Orphaned Opp'); + }); + + it('renders non-hierarchy nodes in a separate section', () => { + const dashboard = makeNode('My Dashboard', 'dashboard'); + const classification: NodeClassification = { + hierarchyRoots: [], + orphans: [], + nonHierarchy: [dashboard], + children: new Map(), + }; + const output = renderBullets(makeInput(classification)); + expect(output).toContain('Other (not in hierarchy):'); + expect(output).toContain('- dashboard: My Dashboard'); + }); + + it('marks repeated subtrees with (*) when the repeated node has children', () => { + const goal = makeNode('My Goal', 'goal'); + const goal2 = makeNode('Another Goal', 'goal'); + const opp = makeNode('Shared Opp', 'opportunity'); + const solution = makeNode('A Solution', 'solution'); + // opp is referenced under both goals and has a child + const classification: NodeClassification = { + hierarchyRoots: [goal, goal2], + orphans: [], + nonHierarchy: [], + children: new Map([ + ['My Goal', [opp]], + ['Another Goal', [opp]], + ['Shared Opp', [solution]], + ['A Solution', []], + ]), + }; + const output = renderBullets(makeInput(classification)); + expect(output).toContain('- opportunity: Shared Opp\n - solution: A Solution'); + expect(output).toContain('- opportunity: Shared Opp (*)'); + }); + + it('silently skips leaf nodes seen twice (no (*) marker)', () => { + const goal = makeNode('My Goal', 'goal'); + const goal2 = makeNode('Another Goal', 'goal'); + const opp = makeNode('Shared Opp', 'opportunity'); + const classification: NodeClassification = { + hierarchyRoots: [goal, goal2], + orphans: [], + nonHierarchy: [], + children: new Map([ + ['My Goal', [opp]], + ['Another Goal', [opp]], + ['Shared Opp', []], + ]), + }; + const output = renderBullets(makeInput(classification)); + expect(output).toContain('- opportunity: Shared Opp'); + expect(output).not.toContain('(*)'); + }); +}); diff --git a/tests/render/render-mermaid.test.ts b/tests/render/render-mermaid.test.ts new file mode 100644 index 0000000..bcda638 --- /dev/null +++ b/tests/render/render-mermaid.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'bun:test'; +import { renderMermaid } from '../../src/plugins/markdown/render-mermaid'; +import type { RenderInput } from '../../src/plugins/util'; +import type { NodeClassification } from '../../src/util/graph-helpers'; +import type { SpaceContext, SpaceNode } from '../../src/types'; +import { makeParentRef } from '../test-helpers'; + +function makeNode(title: string, type: string, status = 'active'): SpaceNode { + return { + label: `${title}.md`, + schemaData: { title, type, status }, + linkTargets: [title], + type, + resolvedType: type, + resolvedParents: [], + }; +} + +function makeInput(classification: NodeClassification, nodes?: SpaceNode[]): RenderInput { + const allNodes = + nodes ?? + [...new Map( + [ + ...classification.hierarchyRoots, + ...classification.orphans, + ...classification.nonHierarchy, + ...[...classification.children.values()].flat(), + ].map((n) => [n.schemaData.title, n]), + ).values()]; + return { + nodes: allNodes, + classification, + context: {} as SpaceContext, + }; +} + +describe('renderMermaid', () => { + it('starts with graph TD', () => { + const classification: NodeClassification = { + hierarchyRoots: [], + orphans: [], + nonHierarchy: [], + children: new Map(), + }; + const output = renderMermaid(makeInput(classification)); + expect(output.startsWith('graph TD\n')).toBe(true); + }); + + it('renders a root node with type_status class', () => { + const goal = makeNode('My Goal', 'goal', 'active'); + const classification: NodeClassification = { + hierarchyRoots: [goal], + orphans: [], + nonHierarchy: [], + children: new Map([['My Goal', []]]), + }; + const output = renderMermaid(makeInput(classification)); + expect(output).toContain('My_Goal["My Goal"]:::goal_active'); + }); + + it('renders edges between parent and child', () => { + const goal = makeNode('My Goal', 'goal', 'active'); + const opp = makeNode('An Opportunity', 'opportunity', 'active'); + opp.resolvedParents = [makeParentRef('My Goal')]; + const classification: NodeClassification = { + hierarchyRoots: [goal], + orphans: [], + nonHierarchy: [], + children: new Map([ + ['My Goal', [opp]], + ['An Opportunity', []], + ]), + }; + const output = renderMermaid(makeInput(classification)); + expect(output).toContain('My_Goal --> An_Opportunity'); + }); + + it('wraps orphans in a subgraph', () => { + const orphan = makeNode('Orphaned Opp', 'opportunity', 'active'); + const classification: NodeClassification = { + hierarchyRoots: [], + orphans: [orphan], + nonHierarchy: [], + children: new Map([['Orphaned Opp', []]]), + }; + const output = renderMermaid(makeInput(classification)); + expect(output).toContain('subgraph Orphans'); + expect(output).toContain('Orphaned_Opp["Orphaned Opp"]:::opportunity_active'); + }); + + it('escapes double quotes in node labels', () => { + const node = makeNode('Say "Hello"', 'goal', 'active'); + const classification: NodeClassification = { + hierarchyRoots: [node], + orphans: [], + nonHierarchy: [], + children: new Map([['Say "Hello"', []]]), + }; + const output = renderMermaid(makeInput(classification)); + expect(output).toContain('"Hello"'); + }); + + it('does not render non-hierarchy nodes', () => { + const dashboard = makeNode('My Dashboard', 'dashboard', 'active'); + const classification: NodeClassification = { + hierarchyRoots: [], + orphans: [], + nonHierarchy: [dashboard], + children: new Map(), + }; + const output = renderMermaid(makeInput(classification)); + expect(output).not.toContain('My_Dashboard'); + }); +}); From b2fe5c18b6c40025928c2e8e4f5183b887225f78 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Wed, 25 Mar 2026 09:21:42 +1100 Subject: [PATCH 2/4] fix: resolve lint errors from render plugin hook implementation --- src/commands/diagram.ts | 2 +- src/commands/render.ts | 7 +++---- src/commands/show.ts | 2 +- src/index.ts | 2 +- src/plugin-api.ts | 2 +- src/plugins/markdown/render-bullets.ts | 2 +- src/plugins/markdown/render-mermaid.ts | 2 +- src/plugins/util.ts | 2 +- src/render/render.ts | 2 +- tests/filter/filter-nodes.test.ts | 2 +- tests/render/registry.test.ts | 2 +- tests/render/render-bullets.test.ts | 2 +- tests/render/render-mermaid.test.ts | 10 +++++----- 13 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/commands/diagram.ts b/src/commands/diagram.ts index 1c4d983..0885017 100644 --- a/src/commands/diagram.ts +++ b/src/commands/diagram.ts @@ -1,6 +1,6 @@ import { writeFileSync } from 'node:fs'; -import type { SpaceContext } from '../types'; import { executeRender } from '../render/render'; +import type { SpaceContext } from '../types'; export async function diagram(context: SpaceContext, options: { output?: string; filter?: string }): Promise { const result = await executeRender('markdown.mermaid', context, { filter: options.filter }); diff --git a/src/commands/render.ts b/src/commands/render.ts index 83e4021..06f2321 100644 --- a/src/commands/render.ts +++ b/src/commands/render.ts @@ -1,9 +1,8 @@ import { writeFileSync } from 'node:fs'; -import { loadPlugins } from '../plugins/loader'; -import { discoverPlugins } from '../plugins/loader'; -import type { SpaceContext } from '../types'; +import { discoverPlugins, type LoadedPlugin, loadPlugins } from '../plugins/loader'; import { buildFormatRegistry } from '../render/registry'; import { executeRender } from '../render/render'; +import type { SpaceContext } from '../types'; export async function render( context: SpaceContext, @@ -21,7 +20,7 @@ export async function render( } export async function renderList(context?: SpaceContext): Promise { - let loaded; + let loaded: LoadedPlugin[]; if (context) { const pluginMap: Record> = context.space?.plugins ?? {}; loaded = await loadPlugins(pluginMap, context.configDir); diff --git a/src/commands/show.ts b/src/commands/show.ts index b003dba..e86611f 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -1,5 +1,5 @@ -import type { SpaceContext } from '../types'; import { executeRender } from '../render/render'; +import type { SpaceContext } from '../types'; export async function show(context: SpaceContext, options?: { filter?: string }) { const result = await executeRender('markdown.bullets', context, { filter: options?.filter }); diff --git a/src/index.ts b/src/index.ts index 259c4cd..a959d09 100755 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,8 @@ import { Command } from 'commander'; import { diagram } from './commands/diagram'; import { dump } from './commands/dump'; import { listPlugins } from './commands/plugins'; -import { render, renderList } from './commands/render'; import { readme } from './commands/readme'; +import { render, renderList } from './commands/render'; import { listSchemas, showSchema } from './commands/schemas'; import { show } from './commands/show'; import { listSpaces } from './commands/spaces'; diff --git a/src/plugin-api.ts b/src/plugin-api.ts index cf3579a..17d415c 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -18,7 +18,6 @@ export type { TemplateSyncHook, TemplateSyncOptions, } from './plugins/util'; -export type { NodeClassification } from './util/graph-helpers'; export type { SharedEmbeddingFields } from './schema/metadata-contract'; export type { BaseNode, @@ -31,3 +30,4 @@ export type { SpaceNode, UnresolvedRef, } from './types'; +export type { NodeClassification } from './util/graph-helpers'; diff --git a/src/plugins/markdown/render-bullets.ts b/src/plugins/markdown/render-bullets.ts index d0a4eb3..5258de1 100644 --- a/src/plugins/markdown/render-bullets.ts +++ b/src/plugins/markdown/render-bullets.ts @@ -1,5 +1,5 @@ -import type { RenderInput } from '../util'; import type { SpaceNode } from '../../types'; +import type { RenderInput } from '../util'; export function renderBullets({ classification }: RenderInput): string { const { hierarchyRoots, orphans, nonHierarchy, children } = classification; diff --git a/src/plugins/markdown/render-mermaid.ts b/src/plugins/markdown/render-mermaid.ts index 2bb7c45..6f2717a 100644 --- a/src/plugins/markdown/render-mermaid.ts +++ b/src/plugins/markdown/render-mermaid.ts @@ -1,6 +1,6 @@ -import type { RenderInput } from '../util'; import type { SpaceNode } from '../../types'; import { buildHierarchyNodeSet } from '../../util/graph-helpers'; +import type { RenderInput } from '../util'; function escapeMermaidString(str: string): string { return str.replace(/"/g, '"'); diff --git a/src/plugins/util.ts b/src/plugins/util.ts index 55b514d..12d289c 100644 --- a/src/plugins/util.ts +++ b/src/plugins/util.ts @@ -1,6 +1,6 @@ import type { AnySchemaObject } from 'ajv'; -import type { NodeClassification } from '../util/graph-helpers'; import type { BaseNode, SpaceContext, SpaceNode } from '../types'; +import type { NodeClassification } from '../util/graph-helpers'; export const PLUGIN_PREFIX = 'ost-tools-'; export const CONFIG_PLUGINS_DIR = 'plugins'; diff --git a/src/render/render.ts b/src/render/render.ts index ea45a78..4672040 100644 --- a/src/render/render.ts +++ b/src/render/render.ts @@ -1,5 +1,5 @@ -import { loadPlugins } from '../plugins/loader'; import { filterNodes } from '../filter/filter-nodes'; +import { loadPlugins } from '../plugins/loader'; import { readSpace } from '../read/read-space'; import type { SpaceContext, SpaceNode } from '../types'; import { classifyNodes } from '../util/graph-helpers'; diff --git a/tests/filter/filter-nodes.test.ts b/tests/filter/filter-nodes.test.ts index 02a1e77..ad13114 100644 --- a/tests/filter/filter-nodes.test.ts +++ b/tests/filter/filter-nodes.test.ts @@ -99,7 +99,7 @@ describe('filterNodes', () => { for (const node of result) { // Original SpaceNode has resolvedParents; augmented representation would have ancestors[] expect(node.resolvedParents).toBeDefined(); - expect(((node as Record).ancestors)).toBeUndefined(); + expect((node as Record).ancestors).toBeUndefined(); } }); }); diff --git a/tests/render/registry.test.ts b/tests/render/registry.test.ts index 19c0a25..cb53ac1 100644 --- a/tests/render/registry.test.ts +++ b/tests/render/registry.test.ts @@ -48,7 +48,7 @@ describe('buildFormatRegistry', () => { it('skips plugins without a render hook and includes those with one', () => { const loaded: LoadedPlugin[] = [ { plugin: { name: 'ost-tools-norender', configSchema: { type: 'object' } }, pluginConfig: {} }, - ...([makePlugin('ost-tools-markdown', [{ name: 'bullets', description: 'Bullets' }])]), + ...[makePlugin('ost-tools-markdown', [{ name: 'bullets', description: 'Bullets' }])], ]; const registry = buildFormatRegistry(loaded); expect(registry).toHaveLength(1); diff --git a/tests/render/render-bullets.test.ts b/tests/render/render-bullets.test.ts index 2702921..4894493 100644 --- a/tests/render/render-bullets.test.ts +++ b/tests/render/render-bullets.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'bun:test'; import { renderBullets } from '../../src/plugins/markdown/render-bullets'; import type { RenderInput } from '../../src/plugins/util'; -import type { NodeClassification } from '../../src/util/graph-helpers'; import type { SpaceContext, SpaceNode } from '../../src/types'; +import type { NodeClassification } from '../../src/util/graph-helpers'; import { makeParentRef } from '../test-helpers'; function makeNode(title: string, type: string): SpaceNode { diff --git a/tests/render/render-mermaid.test.ts b/tests/render/render-mermaid.test.ts index bcda638..30616da 100644 --- a/tests/render/render-mermaid.test.ts +++ b/tests/render/render-mermaid.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'bun:test'; import { renderMermaid } from '../../src/plugins/markdown/render-mermaid'; import type { RenderInput } from '../../src/plugins/util'; -import type { NodeClassification } from '../../src/util/graph-helpers'; import type { SpaceContext, SpaceNode } from '../../src/types'; +import type { NodeClassification } from '../../src/util/graph-helpers'; import { makeParentRef } from '../test-helpers'; function makeNode(title: string, type: string, status = 'active'): SpaceNode { @@ -17,16 +17,16 @@ function makeNode(title: string, type: string, status = 'active'): SpaceNode { } function makeInput(classification: NodeClassification, nodes?: SpaceNode[]): RenderInput { - const allNodes = - nodes ?? - [...new Map( + const allNodes = nodes ?? [ + ...new Map( [ ...classification.hierarchyRoots, ...classification.orphans, ...classification.nonHierarchy, ...[...classification.children.values()].flat(), ].map((n) => [n.schemaData.title, n]), - ).values()]; + ).values(), + ]; return { nodes: allNodes, classification, From 2c504ff98ae9cedad61a90e9f70f99e4903dfa60 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Wed, 25 Mar 2026 14:48:03 +1100 Subject: [PATCH 3/4] Introduce lefthook for lint fixes pre-commit --- bun.lock | 23 +++++++++++++++++++++++ lefthook.yml | 6 ++++++ package.json | 3 ++- 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 lefthook.yml diff --git a/bun.lock b/bun.lock index ebfc6f9..893165d 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "@types/bun": "latest", "@types/js-yaml": "^4.0.9", "json-schema-to-ts": "^3.1.1", + "lefthook": "^2.1.4", }, "peerDependencies": { "typescript": "^5", @@ -115,6 +116,28 @@ "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + "lefthook": ["lefthook@2.1.4", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.4", "lefthook-darwin-x64": "2.1.4", "lefthook-freebsd-arm64": "2.1.4", "lefthook-freebsd-x64": "2.1.4", "lefthook-linux-arm64": "2.1.4", "lefthook-linux-x64": "2.1.4", "lefthook-openbsd-arm64": "2.1.4", "lefthook-openbsd-x64": "2.1.4", "lefthook-windows-arm64": "2.1.4", "lefthook-windows-x64": "2.1.4" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-JNfJ5gAn0KADvJ1I6/xMcx70+/6TL6U9gqGkKvPw5RNMfatC7jIg0Evl97HN846xmfz959BV70l8r3QsBJk30w=="], + + "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BUAAE9+rUrjr39a+wH/1zHmGrDdwUQ2Yq/z6BQbM/yUb9qtXBRcQ5eOXxApqWW177VhGBpX31aqIlfAZ5Q7wzw=="], + + "lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-K1ncIMEe84fe+ss1hQNO7rIvqiKy2TJvTFpkypvqFodT7mJXZn7GLKYTIXdIuyPAYthRa9DwFnx5uMoHwD2F1Q=="], + + "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-PVUhjOhVN71YaYsVdQyNbFZ4a2jFB2Tg5hKrrn9kaWpx64aLz/XivLjwr8sEuTaP1GRlEWBpW6Bhrcsyo39qFw=="], + + "lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZWV9o/LeyWNEBoVO+BhLqxH3rGTba05nkm5NvMjEFSj7LbUNUDbQmupZwtHl1OMGJO66eZP0CalzRfUH6GhBxQ=="], + + "lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iWN0pGnTjrIvNIcSI1vQBJXUbybTqJ5CLMniPA0olabMXQfPDrdMKVQe+mgdwHK+E3/Y0H0ZNL3lnOj6Sk6szA=="], + + "lefthook-linux-x64": ["lefthook-linux-x64@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-96bTBE/JdYgqWYAJDh+/e/0MaxJ25XTOAk7iy/fKoZ1ugf6S0W9bEFbnCFNooXOcxNVTan5xWKfcjJmPIKtsJA=="], + + "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-oYUoK6AIJNEr9lUSpIMj6g7sWzotvtc3ryw7yoOyQM6uqmEduw73URV/qGoUcm4nqqmR93ZalZwR2r3Gd61zvw=="], + + "lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/Dv9Jcm68y9cggr1PhyUhOabBGP9+hzQPoiyOhKks7y9qrJl79A8XfG6LHekSuYc2VpiSu5wdnnrE1cj2nfTg=="], + + "lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-hSww7z+QX4YMnw2lK7DMrs3+w7NtxksuMKOkCKGyxUAC/0m1LAICo0ZbtdDtZ7agxRQQQ/SEbzFRhU5ysNcbjA=="], + + "lefthook-windows-x64": ["lefthook-windows-x64@2.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-eE68LwnogxwcPgGsbVGPGxmghyMGmU9SdGwcc+uhGnUxPz1jL89oECMWJNc36zjVK24umNeDAzB5KA3lw1MuWw=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..1623112 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,6 @@ +pre-commit: + commands: + biome: + glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}" + run: bunx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files} + stage_fixed: true diff --git a/package.json b/package.json index a3b0f7b..4654a88 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "@biomejs/biome": "^2.4.7", "@types/bun": "latest", "@types/js-yaml": "^4.0.9", - "json-schema-to-ts": "^3.1.1" + "json-schema-to-ts": "^3.1.1", + "lefthook": "^2.1.4" }, "peerDependencies": { "typescript": "^5" From 89305ed213da22346499858f0a05e9960fe39ee7 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Wed, 25 Mar 2026 16:42:04 +1100 Subject: [PATCH 4/4] refactor: clean up SpaceGraph architecture and standardise render hook convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flatten src/graph/space-graph.ts → src/space-graph.ts; remove graph/ dir - Remove unused `categories` map from SpaceGraph - Align render hook signature with parse/templateSync convention: render(context: PluginContext, graph: SpaceGraph, options: RenderOptions) - Replace RenderInput with RenderOptions; plugin-api exports RenderOptions - Internal render implementations (renderBullets, renderMermaid) take only what they use — context is not threaded into them - Update all call sites, tests, and plugin-api exports accordingly --- src/commands/validate.ts | 7 +- src/filter/augment-nodes.ts | 34 ++---- src/filter/expand-include.ts | 14 +-- src/filter/filter-nodes.ts | 39 +++---- src/integrations/miro/layout.ts | 16 ++- src/integrations/miro/styles.ts | 2 +- src/integrations/miro/sync.ts | 17 ++- src/plugin-api.ts | 4 +- src/plugins/markdown/index.ts | 6 +- src/plugins/markdown/parse-embedded.ts | 4 + src/plugins/markdown/read-space.ts | 1 + src/plugins/markdown/render-bullets.ts | 14 +-- src/plugins/markdown/render-mermaid.ts | 15 +-- src/plugins/util.ts | 18 ++- src/render/render.ts | 15 ++- src/schema/validate-graph.ts | 4 +- src/schema/validate-rules.ts | 2 +- src/space-graph.ts | 123 ++++++++++++++++++++ src/types.ts | 2 + src/util/graph-helpers.ts | 111 ------------------ tests/filter/augment-nodes.test.ts | 82 +++++-------- tests/filter/expand-include.test.ts | 12 +- tests/filter/filter-nodes.test.ts | 65 +++++++---- tests/render/render-bullets.test.ts | 87 +++----------- tests/render/render-mermaid.test.ts | 85 +++----------- tests/schema/evaluate-rule.test.ts | 12 ++ tests/schema/validate-graph.test.ts | 2 + tests/schema/validate-rules.test.ts | 46 ++++++++ tests/space-graph.test.ts | 154 +++++++++++++++++++++++++ tests/test-helpers.ts | 1 + tests/validate/general.test.ts | 13 ++- 31 files changed, 557 insertions(+), 450 deletions(-) create mode 100644 src/space-graph.ts delete mode 100644 src/util/graph-helpers.ts create mode 100644 tests/space-graph.test.ts diff --git a/src/commands/validate.ts b/src/commands/validate.ts index df05c65..f9684e6 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -6,8 +6,8 @@ import { readSpace } from '../read/read-space'; import { bundledSchemasDir } from '../schema/schema'; import { validateGraph } from '../schema/validate-graph'; import { validateRules } from '../schema/validate-rules'; +import { buildSpaceGraph } from '../space-graph'; import type { GraphViolation, RuleViolation, SchemaWithMetadata, SpaceContext } from '../types'; -import { classifyNodes } from '../util/graph-helpers'; import { extractEntityInfo } from './schemas'; interface FormattedError { @@ -169,7 +169,7 @@ export async function validate(context: SpaceContext): Promise { // Detect duplicate node keys (titles) const titleToFiles = new Map(); for (const node of nodes) { - const title = node.schemaData.title as string; + const title = node.title; if (!titleToFiles.has(title)) { titleToFiles.set(title, []); } @@ -190,8 +190,7 @@ export async function validate(context: SpaceContext): Promise { // Calculate orphan count (informational, not a validation error) if (metadata.hierarchy) { - const classification = classifyNodes(nodes, metadata.hierarchy.levels); - result.orphanCount = classification.orphans.length; + result.orphanCount = buildSpaceGraph(nodes, metadata.hierarchy.levels).orphans.length; } // Load and execute rules validation if schema defines rules diff --git a/src/filter/augment-nodes.ts b/src/filter/augment-nodes.ts index 12664a6..f82f70c 100644 --- a/src/filter/augment-nodes.ts +++ b/src/filter/augment-nodes.ts @@ -15,24 +15,6 @@ export type AugmentedFlatNode = Record & { descendants: Array & EdgeMetadata>; }; -/** - * Build an index from parent title → direct children, using all edges in resolvedParents. - * Used by augmentNode for descendant traversal. - */ -export function buildChildrenIndex(nodes: SpaceNode[]): Map { - const index = new Map(); - for (const node of nodes) { - const title = node.schemaData.title as string; - if (!index.has(title)) index.set(title, []); - - for (const parentRef of node.resolvedParents) { - if (!index.has(parentRef.title)) index.set(parentRef.title, []); - index.get(parentRef.title)!.push(node); - } - } - return index; -} - /** Flatten a SpaceNode's data fields for use in an augmented representation. */ function flattenData(node: SpaceNode): Record { return { @@ -51,8 +33,8 @@ function flattenData(node: SpaceNode): Record { */ export function augmentNode( node: SpaceNode, - nodeIndex: Map, - childrenIndex: Map, + nodeIndex: ReadonlyMap, + childrenIndex: ReadonlyMap, ): AugmentedFlatNode { const ancestors = buildAncestors(node, nodeIndex); const descendants = buildDescendants(node, childrenIndex); @@ -68,7 +50,7 @@ export function augmentNode( function buildAncestors( node: SpaceNode, - nodeIndex: Map, + nodeIndex: ReadonlyMap, ): Array & EdgeMetadata> { const visited = new Set(); const result: Array & EdgeMetadata> = []; @@ -83,7 +65,7 @@ function buildAncestors( while (queue.length > 0) { const item = queue.shift()!; - const title = item.node.schemaData.title as string; + const title = item.node.title; if (visited.has(title)) continue; visited.add(title); @@ -108,9 +90,9 @@ function buildAncestors( function buildDescendants( node: SpaceNode, - childrenIndex: Map, + childrenIndex: ReadonlyMap, ): Array & EdgeMetadata> { - const nodeTitle = node.schemaData.title as string; + const nodeTitle = node.title; const visited = new Set(); const result: Array & EdgeMetadata> = []; @@ -125,7 +107,7 @@ function buildDescendants( while (queue.length > 0) { const item = queue.shift()!; - const title = item.childNode.schemaData.title as string; + const title = item.childNode.title; if (visited.has(title)) continue; visited.add(title); @@ -138,7 +120,7 @@ function buildDescendants( const grandchildren = childrenIndex.get(title) ?? []; for (const grandchild of grandchildren) { - if (!visited.has(grandchild.schemaData.title as string)) { + if (!visited.has(grandchild.title)) { const ref = grandchild.resolvedParents.find((r) => r.title === title); if (ref) queue.push({ childNode: grandchild, ref }); } diff --git a/src/filter/expand-include.ts b/src/filter/expand-include.ts index da05a55..892cad5 100644 --- a/src/filter/expand-include.ts +++ b/src/filter/expand-include.ts @@ -126,13 +126,13 @@ function parseDirective(item: string): IncludeDirective { export function expandInclude( matchedNodes: SpaceNode[], directives: IncludeDirective[], - nodeIndex: Map, - childrenIndex: Map, + nodeIndex: ReadonlyMap, + childrenIndex: ReadonlyMap, augmented: Map, ): SpaceNode[] { if (directives.length === 0) return matchedNodes; - const seen = new Set(matchedNodes.map((n) => n.schemaData.title as string)); + const seen = new Set(matchedNodes.map((n) => n.title)); const result: SpaceNode[] = [...matchedNodes]; function addByTitle(title: string) { @@ -145,7 +145,7 @@ export function expandInclude( } for (const node of matchedNodes) { - const title = node.schemaData.title as string; + const title = node.title; const aug = augmented.get(title); if (!aug) continue; @@ -161,7 +161,7 @@ function applyDirective( node: SpaceNode, aug: AugmentedFlatNode, directive: IncludeDirective, - childrenIndex: Map, + childrenIndex: ReadonlyMap, addByTitle: (title: string) => void, ): void { switch (directive.kind) { @@ -186,8 +186,8 @@ function applyDirective( for (const parentRef of node.resolvedParents) { const siblings = childrenIndex.get(parentRef.title) ?? []; for (const sibling of siblings) { - const siblingTitle = sibling.schemaData.title as string; - if (siblingTitle !== (node.schemaData.title as string)) { + const siblingTitle = sibling.title; + if (siblingTitle !== node.title) { addByTitle(siblingTitle); } } diff --git a/src/filter/filter-nodes.ts b/src/filter/filter-nodes.ts index b638237..008b782 100644 --- a/src/filter/filter-nodes.ts +++ b/src/filter/filter-nodes.ts @@ -1,13 +1,14 @@ import jsonata from 'jsonata'; +import { buildSpaceGraph, type SpaceGraph } from '../space-graph'; import type { SpaceNode } from '../types'; -import { type AugmentedFlatNode, augmentNode, buildChildrenIndex } from './augment-nodes'; +import { type AugmentedFlatNode, augmentNode } from './augment-nodes'; import { expandInclude, parseIncludeSpec } from './expand-include'; import { parseFilterExpression } from './parse-expression'; const expressionCache = new Map>(); /** - * Filter a set of nodes using a filter expression. + * Filter a SpaceGraph using a filter expression, returning a new SpaceGraph. * * The expression follows the SELECT...WHERE DSL: * WHERE {jsonata} — return nodes where the JSONata predicate is truthy @@ -22,32 +23,26 @@ const expressionCache = new Map>(); * relationships[(childType | parentType:childType | parentType:field:childType)] * * @param expression - Filter DSL expression or view expression string - * @param nodes - All nodes in the space - * @returns Filtered+expanded SpaceNode[] (original node objects) + * @param graph - The full space graph + * @returns A new SpaceGraph containing only the filtered+expanded nodes */ -export async function filterNodes(expression: string, nodes: SpaceNode[]): Promise { +export async function filterNodes(expression: string, graph: SpaceGraph): Promise { const { where, include } = parseFilterExpression(expression); - // Build lookup structures (always needed for SELECT expansion or WHERE evaluation) - const nodeIndex = new Map(); - for (const node of nodes) { - const title = node.schemaData.title as string; - if (title) nodeIndex.set(title, node); - } - const childrenIndex = buildChildrenIndex(nodes); + const nodeIndex = graph.nodes; + const childrenIndex = graph.children; // Pre-augment all nodes once (ancestors/descendants needed for WHERE predicates and SELECT expansion) const augmented = new Map(); - for (const node of nodes) { - const title = node.schemaData.title as string; - augmented.set(title, augmentNode(node, nodeIndex, childrenIndex)); + for (const node of nodeIndex.values()) { + augmented.set(node.title, augmentNode(node, nodeIndex, childrenIndex)); } // Step 1: apply WHERE clause to get the matched set let matched: SpaceNode[]; if (where === undefined) { // SELECT-only: start from all nodes - matched = nodes; + matched = [...nodeIndex.values()]; } else { const allAugmented = Array.from(augmented.values()); @@ -59,9 +54,8 @@ export async function filterNodes(expression: string, nodes: SpaceNode[]): Promi } matched = []; - for (const node of nodes) { - const title = node.schemaData.title as string; - const current = augmented.get(title); + for (const node of nodeIndex.values()) { + const current = augmented.get(node.title); if (!current) continue; // Spread current node fields at root level so bare field names work in expressions @@ -74,10 +68,13 @@ export async function filterNodes(expression: string, nodes: SpaceNode[]): Promi } // Step 2: apply SELECT clause to expand the result set + let matchedNodes: SpaceNode[]; if (include !== undefined) { const directives = parseIncludeSpec(include); - return expandInclude(matched, directives, nodeIndex, childrenIndex, augmented); + matchedNodes = expandInclude(matched, directives, nodeIndex, childrenIndex, augmented); + } else { + matchedNodes = matched; } - return matched; + return buildSpaceGraph(matchedNodes, graph.levels); } diff --git a/src/integrations/miro/layout.ts b/src/integrations/miro/layout.ts index b51280b..e3a9641 100644 --- a/src/integrations/miro/layout.ts +++ b/src/integrations/miro/layout.ts @@ -1,5 +1,4 @@ import type { HierarchyLevel, SpaceNode } from '../../types'; -import { buildDepthMap } from '../../util/graph-helpers'; export const CARD_WIDTH = 320; const CARD_HEIGHT = 160; @@ -13,6 +12,19 @@ export interface LayoutResult { bounds: { minX: number; minY: number; maxX: number; maxY: number }; } +/** + * DEPRECATED - likely not needed after migration to render plugin and SpaceGraph + * Build a depth map from hierarchy levels. + * The position in the hierarchy array determines the depth. + */ +function buildDepthMap(hierarchyLevels: HierarchyLevel[]): Map { + const depthMap = new Map(); + for (const [i, level] of hierarchyLevels.entries()) { + depthMap.set(level.type, i); + } + return depthMap; +} + /** * Compute positions for new cards only. Existing cards keep their Miro positions. * New cards are laid out in rows grouped by OST type depth, starting below @@ -58,7 +70,7 @@ export function layoutNewCards( let x = -totalWidth / 2 + CARD_WIDTH / 2; for (const node of nodes) { - const title = node.schemaData.title as string; + const title = node.title; positions.set(title, { x, y: rowY }); x += CARD_WIDTH + H_GAP; } diff --git a/src/integrations/miro/styles.ts b/src/integrations/miro/styles.ts index 880394f..872c217 100644 --- a/src/integrations/miro/styles.ts +++ b/src/integrations/miro/styles.ts @@ -45,7 +45,7 @@ export function getCardColor(type: string, hierarchyLevels: HierarchyLevel[]): s } export function buildCardTitle(node: SpaceNode): string { - const title = node.schemaData.title as string; + const title = node.title; const status = node.schemaData.status as string | undefined; const priority = node.schemaData.priority as string | undefined; diff --git a/src/integrations/miro/sync.ts b/src/integrations/miro/sync.ts index 5e391e4..93dcaea 100644 --- a/src/integrations/miro/sync.ts +++ b/src/integrations/miro/sync.ts @@ -1,7 +1,7 @@ import { updateSpaceField } from '../../config'; import { readSpace } from '../../read/read-space'; +import { buildSpaceGraph } from '../../space-graph'; import type { SpaceContext, SpaceNode } from '../../types'; -import { buildHierarchyNodeSet, classifyNodes } from '../../util/graph-helpers'; import { computeMiroCardHash, computeNodeHash, loadCache, saveCache } from './cache'; import { MiroClient, MiroNotFoundError } from './client'; import { CARD_WIDTH, layoutNewCards } from './layout'; @@ -53,13 +53,10 @@ export async function miroSync(context: SpaceContext, options: SyncOptions): Pro // Filter to hierarchy nodes only const levels = metadata.hierarchy?.levels ?? []; - const { hierarchyRoots, orphans, nonHierarchy, children } = classifyNodes(nodes, levels); - - // Build set of all hierarchy node titles (roots + orphans + all descendants) - const hierarchyNodeTitles = buildHierarchyNodeSet({ hierarchyRoots, orphans, nonHierarchy, children }); + const { nonHierarchy, hierarchyTitles: hierarchyNodeTitles } = buildSpaceGraph(nodes, levels); // Filter nodes to only hierarchy nodes - nodes = nodes.filter((n) => hierarchyNodeTitles.has(n.schemaData.title as string)); + nodes = nodes.filter((n) => hierarchyNodeTitles.has(n.title)); if (options.verbose && nonHierarchy.length > 0) { console.log(`Excluded ${nonHierarchy.length} non-hierarchy nodes from sync`); @@ -170,7 +167,7 @@ export async function miroSync(context: SpaceContext, options: SyncOptions): Pro let skippedCount = 0; for (const node of nodes) { - const title = node.schemaData.title as string; + const title = node.title; // Compute what we expect to be in Miro (using the same build functions) const expectedTitle = buildCardTitle(node); const expectedDesc = buildCardDescription(node); @@ -213,7 +210,7 @@ export async function miroSync(context: SpaceContext, options: SyncOptions): Pro // 7. Create new cards let createdCount = 0; for (const node of newNodes) { - const title = node.schemaData.title as string; + const title = node.title; const type = node.schemaData.type as string; let pos = newPositions.get(title) ?? { x: 0, y: 0 }; @@ -258,7 +255,7 @@ export async function miroSync(context: SpaceContext, options: SyncOptions): Pro // 8. Update changed cards let updatedCount = 0; for (const { node, cardId } of updatedNodes) { - const title = node.schemaData.title as string; + const title = node.title; if (options.dryRun) { console.log(`[dry-run] Update card: "${title}"`); @@ -315,7 +312,7 @@ export async function miroSync(context: SpaceContext, options: SyncOptions): Pro // Only include edges where both endpoints have verified cards on the board const desiredEdges = new Map(); for (const node of nodes) { - const childTitle = node.schemaData.title as string; + const childTitle = node.title; for (const { title: parentTitle, source } of node.resolvedParents) { if (source !== 'hierarchy') continue; // Both endpoints must have verified cards on the board diff --git a/src/plugin-api.ts b/src/plugin-api.ts index 17d415c..73b5467 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -14,11 +14,12 @@ export type { PluginContext, RenderFormat, RenderHook, - RenderInput, + RenderOptions, TemplateSyncHook, TemplateSyncOptions, } from './plugins/util'; export type { SharedEmbeddingFields } from './schema/metadata-contract'; +export type { SpaceGraph } from './space-graph'; export type { BaseNode, EdgeDefinition, @@ -30,4 +31,3 @@ export type { SpaceNode, UnresolvedRef, } from './types'; -export type { NodeClassification } from './util/graph-helpers'; diff --git a/src/plugins/markdown/index.ts b/src/plugins/markdown/index.ts index 22114a2..6002c97 100644 --- a/src/plugins/markdown/index.ts +++ b/src/plugins/markdown/index.ts @@ -43,9 +43,9 @@ export const markdownPlugin: OstToolsPlugin = { { name: 'bullets', description: 'Indented bullet list' }, { name: 'mermaid', description: 'Mermaid graph TD diagram' }, ], - render(format, input) { - if (format === 'bullets') return renderBullets(input); - if (format === 'mermaid') return renderMermaid(input); + render(_context, graph, { format }) { + if (format === 'bullets') return renderBullets(graph); + if (format === 'mermaid') return renderMermaid(graph); throw new Error(`Unknown markdown render format: "${format}"`); }, }, diff --git a/src/plugins/markdown/parse-embedded.ts b/src/plugins/markdown/parse-embedded.ts index 68a709e..a324296 100644 --- a/src/plugins/markdown/parse-embedded.ts +++ b/src/plugins/markdown/parse-embedded.ts @@ -262,6 +262,7 @@ function processListItem( const linkTargets = buildLinkTargets(title); const newNode: BaseNode = { label: makeLabel(title), + title, schemaData, linkTargets, type, @@ -342,6 +343,7 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio // Preamble/root content sink - never added to nodes const rootNode: BaseNode = { label: '_root_', + title: '_root_', schemaData: { type: 'space_on_a_page' }, linkTargets: [], type: 'space_on_a_page', @@ -612,6 +614,7 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio const linkTargets = buildHeadingLinkTargets(rawText, title, anchor); const headingNode: BaseNode = { label: makeLabel(title), + title, schemaData, linkTargets, type, @@ -781,6 +784,7 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio const linkTargets = buildListItemLinkTargets(title); const rowNode: BaseNode = { label: makeLabel(title), + title, schemaData, linkTargets, type: rowTypeStr, diff --git a/src/plugins/markdown/read-space.ts b/src/plugins/markdown/read-space.ts index 5d17571..b85cc79 100644 --- a/src/plugins/markdown/read-space.ts +++ b/src/plugins/markdown/read-space.ts @@ -100,6 +100,7 @@ export async function readSpaceDirectory( nodes.push({ label: file, + title, schemaData: { title, ...data }, linkTargets: [title, fileBase], type: pageType, diff --git a/src/plugins/markdown/render-bullets.ts b/src/plugins/markdown/render-bullets.ts index 5258de1..94f7831 100644 --- a/src/plugins/markdown/render-bullets.ts +++ b/src/plugins/markdown/render-bullets.ts @@ -1,15 +1,15 @@ +import type { SpaceGraph } from '../../space-graph'; import type { SpaceNode } from '../../types'; -import type { RenderInput } from '../util'; -export function renderBullets({ classification }: RenderInput): string { - const { hierarchyRoots, orphans, nonHierarchy, children } = classification; +export function renderBullets(graph: SpaceGraph): string { + const { hierarchyRoots, orphans, nonHierarchy, hierarchyChildren: children } = graph; const lines: string[] = []; const seen = new Set(); function renderNode(node: SpaceNode, depth: number) { const indent = ' '.repeat(depth); - const type = node.schemaData.type as string; - const title = node.schemaData.title as string; + const type = node.resolvedType; + const title = node.title; const nodeChildren = children.get(title) ?? []; if (seen.has(title)) { @@ -41,8 +41,8 @@ export function renderBullets({ classification }: RenderInput): string { lines.push(''); lines.push('Other (not in hierarchy):'); for (const node of nonHierarchy) { - const type = node.schemaData.type as string; - const title = node.schemaData.title as string; + const type = node.resolvedType; + const title = node.title; lines.push(` - ${type}: ${title}`); } } diff --git a/src/plugins/markdown/render-mermaid.ts b/src/plugins/markdown/render-mermaid.ts index 6f2717a..6a81e08 100644 --- a/src/plugins/markdown/render-mermaid.ts +++ b/src/plugins/markdown/render-mermaid.ts @@ -1,6 +1,5 @@ +import type { SpaceGraph } from '../../space-graph'; import type { SpaceNode } from '../../types'; -import { buildHierarchyNodeSet } from '../../util/graph-helpers'; -import type { RenderInput } from '../util'; function escapeMermaidString(str: string): string { return str.replace(/"/g, '"'); @@ -10,10 +9,8 @@ function safeNodeId(id: string): string { return id.replace(/[^a-zA-Z0-9_-]/g, '_'); } -export function renderMermaid({ classification }: RenderInput): string { - const { hierarchyRoots, orphans, children } = classification; - - const hierarchyNodeSet = buildHierarchyNodeSet(classification); +export function renderMermaid(graph: SpaceGraph): string { + const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyTitles: hierarchyNodeSet } = graph; let mmd = 'graph TD\n'; @@ -34,11 +31,11 @@ export function renderMermaid({ classification }: RenderInput): string { const addedNodes = new Set(); function addNodeAndChildren(node: SpaceNode) { - const nodeId = node.schemaData.title as string; + const nodeId = node.title; if (addedNodes.has(nodeId)) return; addedNodes.add(nodeId); - const type = node.schemaData.type as string; + const type = node.resolvedType; const status = node.schemaData.status as string; const priority = node.schemaData.priority as string | undefined; const label = priority ? `${nodeId} (${priority})` : nodeId; @@ -51,7 +48,7 @@ export function renderMermaid({ classification }: RenderInput): string { const nodeChildren = children.get(nodeId) ?? []; for (const child of nodeChildren) { - const childTitle = child.schemaData.title as string; + const childTitle = child.title; if (hierarchyNodeSet.has(childTitle)) { const safeChildId = safeNodeId(childTitle); mmd += ` ${safeId} --> ${safeChildId}\n`; diff --git a/src/plugins/util.ts b/src/plugins/util.ts index 12d289c..3d30e21 100644 --- a/src/plugins/util.ts +++ b/src/plugins/util.ts @@ -1,6 +1,6 @@ import type { AnySchemaObject } from 'ajv'; -import type { BaseNode, SpaceContext, SpaceNode } from '../types'; -import type { NodeClassification } from '../util/graph-helpers'; +import type { SpaceGraph } from '../space-graph'; +import type { BaseNode, SpaceContext } from '../types'; export const PLUGIN_PREFIX = 'ost-tools-'; export const CONFIG_PLUGINS_DIR = 'plugins'; @@ -40,14 +40,10 @@ export type RenderFormat = { description: string; }; -/** Input provided to a render function. */ -export type RenderInput = { - /** All valid nodes after filtering (or all valid nodes if no filter applied). */ - nodes: SpaceNode[]; - /** Pre-computed classification of the nodes. */ - classification: NodeClassification; - /** The full space context for access to schema, hierarchy levels, etc. */ - context: SpaceContext; +/** Options passed to a render function. */ +export type RenderOptions = { + /** The format name being rendered (e.g. 'bullets', 'mermaid'). */ + format: string; }; /** @@ -57,7 +53,7 @@ export type RenderInput = { */ export type RenderHook = { formats: RenderFormat[]; - render: (format: string, input: RenderInput) => Promise | string; + render: (context: PluginContext, graph: SpaceGraph, options: RenderOptions) => Promise | string; }; /** diff --git a/src/render/render.ts b/src/render/render.ts index 4672040..f7924a7 100644 --- a/src/render/render.ts +++ b/src/render/render.ts @@ -1,8 +1,8 @@ import { filterNodes } from '../filter/filter-nodes'; import { loadPlugins } from '../plugins/loader'; import { readSpace } from '../read/read-space'; +import { buildSpaceGraph } from '../space-graph'; import type { SpaceContext, SpaceNode } from '../types'; -import { classifyNodes } from '../util/graph-helpers'; import { buildFormatRegistry } from './registry'; export async function executeRender( @@ -28,16 +28,15 @@ export async function executeRender( const { schemaValidator } = context; const validNodes: SpaceNode[] = allNodes.filter((node) => schemaValidator(node.schemaData)); + const levels = context.schema.metadata.hierarchy?.levels ?? []; + let graph = buildSpaceGraph(validNodes, levels); + // Filter: apply filter expression if provided - let nodes = validNodes; if (options.filter) { const expression = context.space.views?.[options.filter]?.expression ?? options.filter; - nodes = await filterNodes(expression, nodes); + graph = await filterNodes(expression, graph); } - // Classify - const levels = context.schema.metadata.hierarchy?.levels ?? []; - const classification = classifyNodes(nodes, levels); - - return entry.plugin.plugin.render!.render(entry.format.name, { nodes, classification, context }); + const pluginContext = { ...context, pluginConfig: entry.plugin.pluginConfig }; + return entry.plugin.plugin.render!.render(pluginContext, graph, { format: entry.format.name }); } diff --git a/src/schema/validate-graph.ts b/src/schema/validate-graph.ts index 5cd0fed..3883ab8 100644 --- a/src/schema/validate-graph.ts +++ b/src/schema/validate-graph.ts @@ -74,13 +74,13 @@ export function validateHierarchyStructure(nodes: SpaceNode[], metadata: SchemaM const nodeIndex = new Map(); for (const node of nodes) { - const title = node.schemaData.title as string; + const title = node.title; if (title) nodeIndex.set(title, node); } for (const node of nodes) { const nodeType = node.resolvedType; - const nodeTitle = node.schemaData.title as string; + const nodeTitle = node.title; for (const parentRef of node.resolvedParents) { const parentNode = nodeIndex.get(parentRef.title); diff --git a/src/schema/validate-rules.ts b/src/schema/validate-rules.ts index 4eaf175..a118c6c 100644 --- a/src/schema/validate-rules.ts +++ b/src/schema/validate-rules.ts @@ -15,7 +15,7 @@ export async function validateRules(nodes: SpaceNode[], rules: Rule[]): Promise< // Build node index for efficient lookups const nodeIndex = new Map(); for (const node of nodes) { - const title = node.schemaData.title as string; + const title = node.title; if (title) { nodeIndex.set(title, node); } diff --git a/src/space-graph.ts b/src/space-graph.ts new file mode 100644 index 0000000..38ec9da --- /dev/null +++ b/src/space-graph.ts @@ -0,0 +1,123 @@ +import type { HierarchyLevel, ResolvedParentRef, SpaceNode } from './types'; + +/** + * A navigable graph over a set of SpaceNodes. + * + * Built once from SpaceNode[] + hierarchy levels via buildSpaceGraph(). + * Provides typed access to nodes, edges, traversal, and classification + * so consumers don't need to build their own indexes. + */ +export type SpaceGraph = { + /** All nodes, keyed by title. Preserves insertion order. */ + readonly nodes: ReadonlyMap; + + /** Hierarchy roots: nodes of the root type with no valid hierarchy parents. */ + readonly hierarchyRoots: readonly SpaceNode[]; + + /** Orphans: hierarchy-typed nodes with no valid hierarchy parents in the node set. */ + readonly orphans: readonly SpaceNode[]; + + /** Non-hierarchy nodes: type not in the hierarchy levels definition. */ + readonly nonHierarchy: readonly SpaceNode[]; + + /** Hierarchy children map: parent title → direct children connected via hierarchy edges only. */ + readonly hierarchyChildren: ReadonlyMap; + + /** All-edges children map: parent title → direct children connected via any edge (hierarchy + relationship). */ + readonly children: ReadonlyMap; + + /** Set of all node titles that are part of the hierarchy (roots + their descendants + orphans). */ + readonly hierarchyTitles: ReadonlySet; + + /** Hierarchy levels used to build this graph. */ + readonly levels: readonly HierarchyLevel[]; +}; + +/** Build a SpaceGraph from a flat list of SpaceNodes and hierarchy level definitions. */ +export function buildSpaceGraph(nodes: SpaceNode[], levels: readonly HierarchyLevel[]): SpaceGraph { + const hierarchyTypes = new Set(levels.map((l) => l.type)); + const rootType = levels[0]?.type; + + const nodesMap = new Map(); + const hierarchyChildrenMap = new Map(); + const childrenMap = new Map(); + + const hierarchyRoots: SpaceNode[] = []; + const orphans: SpaceNode[] = []; + const nonHierarchy: SpaceNode[] = []; + + // First pass: register all nodes and init adjacency lists + for (const node of nodes) { + nodesMap.set(node.title, node); + hierarchyChildrenMap.set(node.title, []); + childrenMap.set(node.title, []); + } + + // Second pass: build children maps (inverted from resolvedParents) + for (const node of nodes) { + for (const parentRef of node.resolvedParents) { + // All-edges map + if (!childrenMap.has(parentRef.title)) childrenMap.set(parentRef.title, []); + childrenMap.get(parentRef.title)!.push(node); + + // Hierarchy-only map + if (parentRef.source === 'hierarchy') { + if (!hierarchyChildrenMap.has(parentRef.title)) hierarchyChildrenMap.set(parentRef.title, []); + hierarchyChildrenMap.get(parentRef.title)!.push(node); + } + } + } + + // Third pass: classify each node + for (const node of nodes) { + const nodeType = node.resolvedType; + + if (!hierarchyTypes.has(nodeType)) { + nonHierarchy.push(node); + continue; + } + + // Only hierarchy-sourced parents determine structural position in the DAG + const hierarchyParents = node.resolvedParents.filter((r: ResolvedParentRef) => r.source === 'hierarchy'); + + if (hierarchyParents.length === 0) { + if (nodeType === rootType) { + hierarchyRoots.push(node); + } else { + orphans.push(node); + } + } else { + // Check if at least one hierarchy parent is actually in the node set + const hasValidParent = hierarchyParents.some((r: ResolvedParentRef) => nodesMap.has(r.title)); + if (!hasValidParent) { + // All hierarchy parents are dangling — treat as orphan + orphans.push(node); + } + } + } + + // Build hierarchyTitles: BFS from roots + orphans through hierarchyChildren + const hierarchyTitles = new Set(); + + function addHierarchySubtree(node: SpaceNode) { + if (hierarchyTitles.has(node.title)) return; + hierarchyTitles.add(node.title); + for (const child of hierarchyChildrenMap.get(node.title) ?? []) { + addHierarchySubtree(child); + } + } + + for (const root of hierarchyRoots) addHierarchySubtree(root); + for (const orphan of orphans) addHierarchySubtree(orphan); + + return { + nodes: nodesMap, + hierarchyRoots, + orphans, + nonHierarchy, + hierarchyChildren: hierarchyChildrenMap, + children: childrenMap, + hierarchyTitles, + levels, + }; +} diff --git a/src/types.ts b/src/types.ts index b57a05d..4c2ef2a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,6 +74,8 @@ export type UnresolvedRef = { export type BaseNode = { /** Source identifier for error messages (filename or heading title) */ label: string; + /** Canonical title of the node (from schemaData.title). First-class accessor. */ + title: string; /** Fields validated against the active schema. */ schemaData: Record; /** Valid navigation targets this node can be linked to (wikilink key without [[ ]]). */ diff --git a/src/util/graph-helpers.ts b/src/util/graph-helpers.ts deleted file mode 100644 index 5287929..0000000 --- a/src/util/graph-helpers.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { HierarchyLevel, SpaceNode } from '../types'; - -export interface NodeClassification { - hierarchyRoots: SpaceNode[]; - orphans: SpaceNode[]; - nonHierarchy: SpaceNode[]; - children: Map; -} - -/** - * Classify nodes into hierarchy categories. - * - * - **hierarchyRoots**: Nodes of the hierarchy's root type with no valid parents - * - **orphans**: Nodes of other hierarchy types with no valid parents (or all parents dangling) - * - **nonHierarchy**: Nodes whose type is not in the hierarchy definition - * - **children**: Map of parent title → children (only includes hierarchy nodes) - * - * A "valid parent" is one whose title exists in the nodes list. - */ -export function classifyNodes(nodes: SpaceNode[], hierarchyLevels: HierarchyLevel[]): NodeClassification { - const hierarchyTypes = new Set(hierarchyLevels.map((level) => level.type)); - const rootType = hierarchyLevels[0]?.type; - - // Build children map and title lookup - const children = new Map(); - const nodeTitles = new Set(); - for (const node of nodes) { - const title = node.schemaData.title as string; - nodeTitles.add(title); - children.set(title, []); - } - - // Categorize nodes - const hierarchyRoots: SpaceNode[] = []; - const orphans: SpaceNode[] = []; - const nonHierarchy: SpaceNode[] = []; - - for (const node of nodes) { - const nodeType = node.resolvedType; - - if (!hierarchyTypes.has(nodeType)) { - nonHierarchy.push(node); - continue; // non-hierarchy nodes don't participate in the DAG - } - - // 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 { - orphans.push(node); - } - } else { - let addedToAParent = false; - for (const parentRef of hierarchyParents) { - if (nodeTitles.has(parentRef.title)) { - const siblings = children.get(parentRef.title); - if (siblings) { - siblings.push(node); - addedToAParent = true; - } - } - } - if (!addedToAParent) { - // All hierarchy parents dangling — treat as orphan - orphans.push(node); - } - } - } - - return { hierarchyRoots, orphans, nonHierarchy, children }; -} - -/** - * Build a Set containing all hierarchy node titles. - * Includes roots, orphans, and all their descendants. - */ -export function buildHierarchyNodeSet(classification: NodeClassification): Set { - const nodeTitles = new Set(); - - function addNodeAndDescendants(node: SpaceNode) { - nodeTitles.add(node.schemaData.title as string); - const nodeChildren = classification.children.get(node.schemaData.title as string) ?? []; - for (const child of nodeChildren) { - addNodeAndDescendants(child); - } - } - - for (const root of classification.hierarchyRoots) { - addNodeAndDescendants(root); - } - for (const orphan of classification.orphans) { - addNodeAndDescendants(orphan); - } - - return nodeTitles; -} - -/** - * Build a depth map from hierarchy levels. - * The position in the hierarchy array determines the depth. - */ -export function buildDepthMap(hierarchyLevels: HierarchyLevel[]): Map { - const depthMap = new Map(); - for (const [i, level] of hierarchyLevels.entries()) { - depthMap.set(level.type, i); - } - return depthMap; -} diff --git a/tests/filter/augment-nodes.test.ts b/tests/filter/augment-nodes.test.ts index b920617..6b34a0e 100644 --- a/tests/filter/augment-nodes.test.ts +++ b/tests/filter/augment-nodes.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'bun:test'; -import { augmentNode, buildChildrenIndex } from '../../src/filter/augment-nodes'; +import { augmentNode } from '../../src/filter/augment-nodes'; +import { buildSpaceGraph } from '../../src/space-graph'; import type { SpaceNode } from '../../src/types'; import { makeParentRef } from '../test-helpers'; function makeNode(title: string, type: string, parentRefs: ReturnType[] = []): SpaceNode { return { label: `${title}.md`, + title, schemaData: { title, type }, linkTargets: [title], type, @@ -14,37 +16,16 @@ function makeNode(title: string, type: string, parentRefs: ReturnType { - it('builds empty index for nodes with no parents', () => { - const root = makeNode('Root', 'goal'); - const index = buildChildrenIndex([root]); - expect(index.get('Root')).toEqual([]); - }); - - it('maps parent titles to their children', () => { - const root = makeNode('Root', 'goal'); - const child = makeNode('Child', 'opportunity', [makeParentRef('Root')]); - const index = buildChildrenIndex([root, child]); - expect(index.get('Root')).toEqual([child]); - expect(index.get('Child')).toEqual([]); - }); - - it('supports multiple children per parent', () => { - const root = makeNode('Root', 'goal'); - const child1 = makeNode('Child 1', 'opportunity', [makeParentRef('Root')]); - const child2 = makeNode('Child 2', 'opportunity', [makeParentRef('Root')]); - const index = buildChildrenIndex([root, child1, child2]); - expect(index.get('Root')).toEqual([child1, child2]); - }); -}); +function childrenOf(nodes: SpaceNode[]) { + return buildSpaceGraph(nodes, []).children; +} describe('augmentNode', () => { describe('ancestors', () => { it('returns empty ancestors for a root node', () => { const root = makeNode('Root', 'goal'); const nodeIndex = new Map([['Root', root]]); - const childrenIndex = buildChildrenIndex([root]); - const result = augmentNode(root, nodeIndex, childrenIndex); + const result = augmentNode(root, nodeIndex, childrenOf([root])); expect(result.ancestors).toEqual([]); }); @@ -55,8 +36,7 @@ describe('augmentNode', () => { ['Root', root], ['Child', child], ]); - const childrenIndex = buildChildrenIndex([root, child]); - const result = augmentNode(child, nodeIndex, childrenIndex); + const result = augmentNode(child, nodeIndex, childrenOf([root, child])); expect(result.ancestors).toHaveLength(1); expect(result.ancestors[0]).toMatchObject({ @@ -73,10 +53,9 @@ describe('augmentNode', () => { const parent = makeNode('Parent', 'opportunity', [makeParentRef('Grandparent')]); const child = makeNode('Child', 'solution', [makeParentRef('Parent')]); const nodes = [grandparent, parent, child]; - const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); - const childrenIndex = buildChildrenIndex(nodes); + const nodeIndex = new Map(nodes.map((n) => [n.title, n])); - const result = augmentNode(child, nodeIndex, childrenIndex); + const result = augmentNode(child, nodeIndex, childrenOf(nodes)); expect(result.ancestors).toHaveLength(2); expect(result.ancestors[0]).toMatchObject({ title: 'Parent' }); @@ -90,10 +69,9 @@ describe('augmentNode', () => { const parent2 = makeNode('Parent 2', 'opportunity', [makeParentRef('Grandparent')]); const child = makeNode('Child', 'solution', [makeParentRef('Parent 1'), makeParentRef('Parent 2')]); const nodes = [grandparent, parent1, parent2, child]; - const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); - const childrenIndex = buildChildrenIndex(nodes); + const nodeIndex = new Map(nodes.map((n) => [n.title, n])); - const result = augmentNode(child, nodeIndex, childrenIndex); + const result = augmentNode(child, nodeIndex, childrenOf(nodes)); const ancestorTitles = result.ancestors.map((a) => a.title); // Grandparent appears only once despite two paths @@ -107,11 +85,10 @@ describe('augmentNode', () => { // Make Solution A also point to Solution B to create a cycle solutionA.resolvedParents = [makeParentRef('Solution B', { selfRef: true })]; const nodes = [solutionA, solutionB]; - const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); - const childrenIndex = buildChildrenIndex(nodes); + const nodeIndex = new Map(nodes.map((n) => [n.title, n])); // Should not throw or infinite loop - const result = augmentNode(solutionB, nodeIndex, childrenIndex); + const result = augmentNode(solutionB, nodeIndex, childrenOf(nodes)); expect(result.ancestors.length).toBeGreaterThan(0); // Each title appears at most once const titles = result.ancestors.map((a) => a.title); @@ -124,10 +101,9 @@ describe('augmentNode', () => { makeParentRef('Opportunity', { source: 'relationship', field: 'assumptions', selfRef: false }), ]); const nodes = [parent, child]; - const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); - const childrenIndex = buildChildrenIndex(nodes); + const nodeIndex = new Map(nodes.map((n) => [n.title, n])); - const result = augmentNode(child, nodeIndex, childrenIndex); + const result = augmentNode(child, nodeIndex, childrenOf(nodes)); expect(result.ancestors[0]).toMatchObject({ _field: 'assumptions', @@ -141,8 +117,7 @@ describe('augmentNode', () => { it('returns empty descendants for a leaf node', () => { const leaf = makeNode('Leaf', 'solution'); const nodeIndex = new Map([['Leaf', leaf]]); - const childrenIndex = buildChildrenIndex([leaf]); - const result = augmentNode(leaf, nodeIndex, childrenIndex); + const result = augmentNode(leaf, nodeIndex, childrenOf([leaf])); expect(result.descendants).toEqual([]); }); @@ -150,10 +125,9 @@ describe('augmentNode', () => { const root = makeNode('Root', 'goal'); const child = makeNode('Child', 'opportunity', [makeParentRef('Root')]); const nodes = [root, child]; - const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); - const childrenIndex = buildChildrenIndex(nodes); + const nodeIndex = new Map(nodes.map((n) => [n.title, n])); - const result = augmentNode(root, nodeIndex, childrenIndex); + const result = augmentNode(root, nodeIndex, childrenOf(nodes)); expect(result.descendants).toHaveLength(1); expect(result.descendants[0]).toMatchObject({ @@ -169,10 +143,9 @@ describe('augmentNode', () => { const mid = makeNode('Mid', 'opportunity', [makeParentRef('Root')]); const leaf = makeNode('Leaf', 'solution', [makeParentRef('Mid')]); const nodes = [root, mid, leaf]; - const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); - const childrenIndex = buildChildrenIndex(nodes); + const nodeIndex = new Map(nodes.map((n) => [n.title, n])); - const result = augmentNode(root, nodeIndex, childrenIndex); + const result = augmentNode(root, nodeIndex, childrenOf(nodes)); expect(result.descendants).toHaveLength(2); expect(result.descendants[0]).toMatchObject({ title: 'Mid' }); @@ -185,10 +158,9 @@ describe('augmentNode', () => { const child2 = makeNode('Child 2', 'opportunity', [makeParentRef('Root')]); const grandchild = makeNode('Grandchild', 'solution', [makeParentRef('Child 1'), makeParentRef('Child 2')]); const nodes = [root, child1, child2, grandchild]; - const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); - const childrenIndex = buildChildrenIndex(nodes); + const nodeIndex = new Map(nodes.map((n) => [n.title, n])); - const result = augmentNode(root, nodeIndex, childrenIndex); + const result = augmentNode(root, nodeIndex, childrenOf(nodes)); const descTitles = result.descendants.map((d) => d.title); expect(descTitles.filter((t) => t === 'Grandchild')).toHaveLength(1); @@ -200,9 +172,8 @@ describe('augmentNode', () => { const node = makeNode('My Node', 'solution'); (node.schemaData as Record).status = 'active'; const nodeIndex = new Map([['My Node', node]]); - const childrenIndex = buildChildrenIndex([node]); - const result = augmentNode(node, nodeIndex, childrenIndex); + const result = augmentNode(node, nodeIndex, childrenOf([node])); expect(result.status).toBe('active'); expect(result.resolvedType).toBe('solution'); @@ -212,10 +183,9 @@ describe('augmentNode', () => { const parent = makeNode('Parent', 'goal'); const child = makeNode('Child', 'opportunity', [makeParentRef('Parent')]); const nodes = [parent, child]; - const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); - const childrenIndex = buildChildrenIndex(nodes); + const nodeIndex = new Map(nodes.map((n) => [n.title, n])); - const result = augmentNode(child, nodeIndex, childrenIndex); + const result = augmentNode(child, nodeIndex, childrenOf(nodes)); expect(result.resolvedParentTitles).toEqual(['Parent']); }); diff --git a/tests/filter/expand-include.test.ts b/tests/filter/expand-include.test.ts index bcbc4f3..db5617a 100644 --- a/tests/filter/expand-include.test.ts +++ b/tests/filter/expand-include.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; -import { augmentNode, buildChildrenIndex } from '../../src/filter/augment-nodes'; +import { augmentNode } from '../../src/filter/augment-nodes'; import { expandInclude, parseIncludeSpec } from '../../src/filter/expand-include'; +import { buildSpaceGraph } from '../../src/space-graph'; import type { SpaceNode } from '../../src/types'; import { makeParentRef } from '../test-helpers'; @@ -11,6 +12,7 @@ import { makeParentRef } from '../test-helpers'; function makeNode(title: string, type: string, extra: Record = {}): SpaceNode { return { label: `${title}.md`, + title, schemaData: { title, type, ...extra }, linkTargets: [title], type, @@ -20,9 +22,11 @@ function makeNode(title: string, type: string, extra: Record = } function buildContext(nodes: SpaceNode[]) { - const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); - const childrenIndex = buildChildrenIndex(nodes); - const augmented = new Map(nodes.map((n) => [n.schemaData.title as string, augmentNode(n, nodeIndex, childrenIndex)])); + // Use an empty levels array — expand-include tests don't care about hierarchy classification + const graph = buildSpaceGraph(nodes, []); + const nodeIndex = graph.nodes; + const childrenIndex = graph.children; + const augmented = new Map(nodes.map((n) => [n.title, augmentNode(n, nodeIndex, childrenIndex)])); return { nodeIndex, childrenIndex, augmented }; } diff --git a/tests/filter/filter-nodes.test.ts b/tests/filter/filter-nodes.test.ts index ad13114..a5778cd 100644 --- a/tests/filter/filter-nodes.test.ts +++ b/tests/filter/filter-nodes.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from 'bun:test'; import { filterNodes } from '../../src/filter/filter-nodes'; +import { buildSpaceGraph } from '../../src/space-graph'; import type { SpaceNode } from '../../src/types'; -import { makeParentRef } from '../test-helpers'; +import { makeLevel, makeParentRef } from '../test-helpers'; + +const levels = [makeLevel('goal'), makeLevel('opportunity'), makeLevel('solution')]; function makeNode(title: string, type: string, extra: Record = {}): SpaceNode { return { label: `${title}.md`, + title, schemaData: { title, type, ...extra }, linkTargets: [title], type, @@ -27,80 +31,93 @@ activeOpportunity.resolvedParents = [makeParentRef('My Goal')]; pausedOpportunity.resolvedParents = [makeParentRef('My Goal')]; const allNodes = [goal, activeOpportunity, pausedOpportunity, solution1, solution2]; +const allGraph = buildSpaceGraph(allNodes, levels); + +function nodes(graph: Awaited>): SpaceNode[] { + return [...graph.nodes.values()]; +} describe('filterNodes', () => { describe('WHERE clause matching', () => { it('filters by resolvedType', async () => { - const result = await filterNodes("WHERE resolvedType='solution'", allNodes); + const result = nodes(await filterNodes("WHERE resolvedType='solution'", allGraph)); expect(result).toHaveLength(2); - expect(result.map((n) => n.schemaData.title)).toContain('Solution 1'); - expect(result.map((n) => n.schemaData.title)).toContain('Solution 2'); + expect(result.map((n) => n.title)).toContain('Solution 1'); + expect(result.map((n) => n.title)).toContain('Solution 2'); }); it('filters by a schemaData field (status)', async () => { - const result = await filterNodes("WHERE status='active'", allNodes); - expect(result.map((n) => n.schemaData.title)).toEqual(['My Goal', 'Active Opportunity', 'Solution 1']); + const result = nodes(await filterNodes("WHERE status='active'", allGraph)); + expect(result.map((n) => n.title)).toEqual(['My Goal', 'Active Opportunity', 'Solution 1']); }); it('filters by combined conditions', async () => { - const result = await filterNodes("WHERE resolvedType='solution' and status='active'", allNodes); + const result = nodes(await filterNodes("WHERE resolvedType='solution' and status='active'", allGraph)); expect(result).toHaveLength(1); - expect(result[0]!.schemaData.title).toBe('Solution 1'); + expect(result[0]!.title).toBe('Solution 1'); }); - it('returns empty array when nothing matches', async () => { - const result = await filterNodes("WHERE resolvedType='nonexistent'", allNodes); - expect(result).toEqual([]); + it('returns empty graph when nothing matches', async () => { + const result = await filterNodes("WHERE resolvedType='nonexistent'", allGraph); + expect(result.nodes.size).toBe(0); }); it('filters by ancestor attribute using ancestors[] array', async () => { // Solutions whose parent opportunity has status='active' - const result = await filterNodes( - "WHERE resolvedType='solution' and $exists(ancestors[resolvedType='opportunity' and status='active'])", - allNodes, + const result = nodes( + await filterNodes( + "WHERE resolvedType='solution' and $exists(ancestors[resolvedType='opportunity' and status='active'])", + allGraph, + ), ); expect(result).toHaveLength(1); - expect(result[0]!.schemaData.title).toBe('Solution 1'); + expect(result[0]!.title).toBe('Solution 1'); }); it('supports bare JSONata without WHERE keyword', async () => { - const result = await filterNodes("resolvedType='solution'", allNodes); + const result = nodes(await filterNodes("resolvedType='solution'", allGraph)); expect(result).toHaveLength(2); }); }); describe('no filter predicate', () => { it('returns all nodes when no WHERE clause', async () => { - const result = await filterNodes('SELECT ancestors(opportunity)', allNodes); + const result = await filterNodes('SELECT ancestors(opportunity)', allGraph); // SELECT-only: returns all nodes (no WHERE filter) - expect(result).toHaveLength(allNodes.length); + expect(result.nodes.size).toBe(allNodes.length); }); }); describe('SELECT clause expansion', () => { it('expands result with SELECT ancestors when present', async () => { // Matched: solutions. Expanded: + their opportunity ancestor. - const result = await filterNodes("SELECT ancestors(opportunity) WHERE resolvedType='solution'", allNodes); - const titles = result.map((n) => n.schemaData.title); + const result = nodes(await filterNodes("SELECT ancestors(opportunity) WHERE resolvedType='solution'", allGraph)); + const titles = result.map((n) => n.title); expect(titles).toContain('Solution 1'); expect(titles).toContain('Active Opportunity'); // ancestor of solution 1 }); it('SELECT-only returns all nodes expanded', async () => { - const result = await filterNodes('SELECT ancestors(opportunity)', allNodes); + const result = await filterNodes('SELECT ancestors(opportunity)', allGraph); // All nodes returned (SELECT-only = no WHERE filter) - expect(result.length).toBeGreaterThanOrEqual(allNodes.length); + expect(result.nodes.size).toBeGreaterThanOrEqual(allNodes.length); }); }); describe('return type', () => { - it('returns the original SpaceNode objects, not augmented representations', async () => { - const result = await filterNodes("WHERE resolvedType='solution'", allNodes); + it('returns original SpaceNode objects, not augmented representations', async () => { + const result = nodes(await filterNodes("WHERE resolvedType='solution'", allGraph)); for (const node of result) { // Original SpaceNode has resolvedParents; augmented representation would have ancestors[] expect(node.resolvedParents).toBeDefined(); expect((node as Record).ancestors).toBeUndefined(); } }); + + it('returns a SpaceGraph with correct classification', async () => { + const result = await filterNodes("WHERE resolvedType='solution'", allGraph); + // Solutions without their parents are orphans in the filtered graph + expect(result.orphans.length).toBeGreaterThan(0); + }); }); }); diff --git a/tests/render/render-bullets.test.ts b/tests/render/render-bullets.test.ts index 4894493..6d32fe9 100644 --- a/tests/render/render-bullets.test.ts +++ b/tests/render/render-bullets.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from 'bun:test'; import { renderBullets } from '../../src/plugins/markdown/render-bullets'; -import type { RenderInput } from '../../src/plugins/util'; -import type { SpaceContext, SpaceNode } from '../../src/types'; -import type { NodeClassification } from '../../src/util/graph-helpers'; -import { makeParentRef } from '../test-helpers'; +import { buildSpaceGraph } from '../../src/space-graph'; +import type { SpaceNode } from '../../src/types'; +import { makeLevel, makeParentRef } from '../test-helpers'; + +const levels = [makeLevel('goal'), makeLevel('opportunity'), makeLevel('solution')]; function makeNode(title: string, type: string): SpaceNode { return { label: `${title}.md`, + title, schemaData: { title, type }, linkTargets: [title], type, @@ -16,30 +18,10 @@ function makeNode(title: string, type: string): SpaceNode { }; } -function makeInput(classification: NodeClassification): RenderInput { - const allNodes = [ - ...classification.hierarchyRoots, - ...classification.orphans, - ...classification.nonHierarchy, - ...[...classification.children.values()].flat(), - ]; - return { - nodes: [...new Map(allNodes.map((n) => [n.schemaData.title, n])).values()], - classification, - context: {} as SpaceContext, - }; -} - describe('renderBullets', () => { it('renders a single root with no children', () => { const root = makeNode('My Goal', 'goal'); - const classification: NodeClassification = { - hierarchyRoots: [root], - orphans: [], - nonHierarchy: [], - children: new Map([['My Goal', []]]), - }; - const output = renderBullets(makeInput(classification)); + const output = renderBullets(buildSpaceGraph([root], levels)); expect(output).toBe('- goal: My Goal'); }); @@ -47,41 +29,20 @@ describe('renderBullets', () => { const goal = makeNode('My Goal', 'goal'); const opp = makeNode('An Opportunity', 'opportunity'); opp.resolvedParents = [makeParentRef('My Goal')]; - const classification: NodeClassification = { - hierarchyRoots: [goal], - orphans: [], - nonHierarchy: [], - children: new Map([ - ['My Goal', [opp]], - ['An Opportunity', []], - ]), - }; - const output = renderBullets(makeInput(classification)); + const output = renderBullets(buildSpaceGraph([goal, opp], levels)); expect(output).toBe('- goal: My Goal\n - opportunity: An Opportunity'); }); it('renders orphans in a separate section', () => { const orphan = makeNode('Orphaned Opp', 'opportunity'); - const classification: NodeClassification = { - hierarchyRoots: [], - orphans: [orphan], - nonHierarchy: [], - children: new Map([['Orphaned Opp', []]]), - }; - const output = renderBullets(makeInput(classification)); + const output = renderBullets(buildSpaceGraph([orphan], levels)); expect(output).toContain('Orphans (missing parent):'); expect(output).toContain('- opportunity: Orphaned Opp'); }); it('renders non-hierarchy nodes in a separate section', () => { const dashboard = makeNode('My Dashboard', 'dashboard'); - const classification: NodeClassification = { - hierarchyRoots: [], - orphans: [], - nonHierarchy: [dashboard], - children: new Map(), - }; - const output = renderBullets(makeInput(classification)); + const output = renderBullets(buildSpaceGraph([dashboard], levels)); expect(output).toContain('Other (not in hierarchy):'); expect(output).toContain('- dashboard: My Dashboard'); }); @@ -92,18 +53,9 @@ describe('renderBullets', () => { const opp = makeNode('Shared Opp', 'opportunity'); const solution = makeNode('A Solution', 'solution'); // opp is referenced under both goals and has a child - const classification: NodeClassification = { - hierarchyRoots: [goal, goal2], - orphans: [], - nonHierarchy: [], - children: new Map([ - ['My Goal', [opp]], - ['Another Goal', [opp]], - ['Shared Opp', [solution]], - ['A Solution', []], - ]), - }; - const output = renderBullets(makeInput(classification)); + opp.resolvedParents = [makeParentRef('My Goal'), makeParentRef('Another Goal')]; + solution.resolvedParents = [makeParentRef('Shared Opp')]; + const output = renderBullets(buildSpaceGraph([goal, goal2, opp, solution], levels)); expect(output).toContain('- opportunity: Shared Opp\n - solution: A Solution'); expect(output).toContain('- opportunity: Shared Opp (*)'); }); @@ -112,17 +64,8 @@ describe('renderBullets', () => { const goal = makeNode('My Goal', 'goal'); const goal2 = makeNode('Another Goal', 'goal'); const opp = makeNode('Shared Opp', 'opportunity'); - const classification: NodeClassification = { - hierarchyRoots: [goal, goal2], - orphans: [], - nonHierarchy: [], - children: new Map([ - ['My Goal', [opp]], - ['Another Goal', [opp]], - ['Shared Opp', []], - ]), - }; - const output = renderBullets(makeInput(classification)); + opp.resolvedParents = [makeParentRef('My Goal'), makeParentRef('Another Goal')]; + const output = renderBullets(buildSpaceGraph([goal, goal2, opp], levels)); expect(output).toContain('- opportunity: Shared Opp'); expect(output).not.toContain('(*)'); }); diff --git a/tests/render/render-mermaid.test.ts b/tests/render/render-mermaid.test.ts index 30616da..22c7a47 100644 --- a/tests/render/render-mermaid.test.ts +++ b/tests/render/render-mermaid.test.ts @@ -1,13 +1,21 @@ import { describe, expect, it } from 'bun:test'; import { renderMermaid } from '../../src/plugins/markdown/render-mermaid'; -import type { RenderInput } from '../../src/plugins/util'; -import type { SpaceContext, SpaceNode } from '../../src/types'; -import type { NodeClassification } from '../../src/util/graph-helpers'; -import { makeParentRef } from '../test-helpers'; +import { buildSpaceGraph } from '../../src/space-graph'; +import type { SpaceNode } from '../../src/types'; +import { makeLevel, makeParentRef } from '../test-helpers'; + +const levels = [ + makeLevel('vision'), + makeLevel('mission'), + makeLevel('goal'), + makeLevel('opportunity'), + makeLevel('solution'), +]; function makeNode(title: string, type: string, status = 'active'): SpaceNode { return { label: `${title}.md`, + title, schemaData: { title, type, status }, linkTargets: [title], type, @@ -16,45 +24,15 @@ function makeNode(title: string, type: string, status = 'active'): SpaceNode { }; } -function makeInput(classification: NodeClassification, nodes?: SpaceNode[]): RenderInput { - const allNodes = nodes ?? [ - ...new Map( - [ - ...classification.hierarchyRoots, - ...classification.orphans, - ...classification.nonHierarchy, - ...[...classification.children.values()].flat(), - ].map((n) => [n.schemaData.title, n]), - ).values(), - ]; - return { - nodes: allNodes, - classification, - context: {} as SpaceContext, - }; -} - describe('renderMermaid', () => { it('starts with graph TD', () => { - const classification: NodeClassification = { - hierarchyRoots: [], - orphans: [], - nonHierarchy: [], - children: new Map(), - }; - const output = renderMermaid(makeInput(classification)); + const output = renderMermaid(buildSpaceGraph([], levels)); expect(output.startsWith('graph TD\n')).toBe(true); }); it('renders a root node with type_status class', () => { const goal = makeNode('My Goal', 'goal', 'active'); - const classification: NodeClassification = { - hierarchyRoots: [goal], - orphans: [], - nonHierarchy: [], - children: new Map([['My Goal', []]]), - }; - const output = renderMermaid(makeInput(classification)); + const output = renderMermaid(buildSpaceGraph([goal], levels)); expect(output).toContain('My_Goal["My Goal"]:::goal_active'); }); @@ -62,53 +40,26 @@ describe('renderMermaid', () => { const goal = makeNode('My Goal', 'goal', 'active'); const opp = makeNode('An Opportunity', 'opportunity', 'active'); opp.resolvedParents = [makeParentRef('My Goal')]; - const classification: NodeClassification = { - hierarchyRoots: [goal], - orphans: [], - nonHierarchy: [], - children: new Map([ - ['My Goal', [opp]], - ['An Opportunity', []], - ]), - }; - const output = renderMermaid(makeInput(classification)); + const output = renderMermaid(buildSpaceGraph([goal, opp], levels)); expect(output).toContain('My_Goal --> An_Opportunity'); }); it('wraps orphans in a subgraph', () => { const orphan = makeNode('Orphaned Opp', 'opportunity', 'active'); - const classification: NodeClassification = { - hierarchyRoots: [], - orphans: [orphan], - nonHierarchy: [], - children: new Map([['Orphaned Opp', []]]), - }; - const output = renderMermaid(makeInput(classification)); + const output = renderMermaid(buildSpaceGraph([orphan], levels)); expect(output).toContain('subgraph Orphans'); expect(output).toContain('Orphaned_Opp["Orphaned Opp"]:::opportunity_active'); }); it('escapes double quotes in node labels', () => { const node = makeNode('Say "Hello"', 'goal', 'active'); - const classification: NodeClassification = { - hierarchyRoots: [node], - orphans: [], - nonHierarchy: [], - children: new Map([['Say "Hello"', []]]), - }; - const output = renderMermaid(makeInput(classification)); + const output = renderMermaid(buildSpaceGraph([node], levels)); expect(output).toContain('"Hello"'); }); it('does not render non-hierarchy nodes', () => { const dashboard = makeNode('My Dashboard', 'dashboard', 'active'); - const classification: NodeClassification = { - hierarchyRoots: [], - orphans: [], - nonHierarchy: [dashboard], - children: new Map(), - }; - const output = renderMermaid(makeInput(classification)); + const output = renderMermaid(buildSpaceGraph([dashboard], levels)); expect(output).not.toContain('My_Dashboard'); }); }); diff --git a/tests/schema/evaluate-rule.test.ts b/tests/schema/evaluate-rule.test.ts index da8aa08..71a5101 100644 --- a/tests/schema/evaluate-rule.test.ts +++ b/tests/schema/evaluate-rule.test.ts @@ -7,6 +7,8 @@ describe('evaluate-rule', () => { describe('evaluateExpression', () => { const mockNode: SpaceNode = { label: 'test.md', + title: 'Test Node', + type: 'solution', schemaData: { title: 'Test Node', type: 'solution', @@ -20,6 +22,8 @@ describe('evaluate-rule', () => { const mockParent: SpaceNode = { label: 'parent.md', + title: 'Parent Opportunity', + type: 'opportunity', schemaData: { title: 'Parent Opportunity', type: 'opportunity', @@ -82,6 +86,8 @@ describe('evaluate-rule', () => { it('evaluates expression with $exists check for missing parent', async () => { const nodeWithoutParent: SpaceNode = { label: 'orphan.md', + title: 'Orphan Node', + type: 'outcome', schemaData: { title: 'Orphan Node', type: 'outcome', @@ -120,6 +126,8 @@ describe('evaluate-rule', () => { describe('buildEvalContext', () => { const childNode: SpaceNode = { label: 'child.md', + title: 'Child', + type: 'solution', schemaData: { title: 'Child', type: 'solution', parent: '[[Parent]]' }, linkTargets: ['Child'], resolvedParents: [makeParentRef('Parent')], @@ -128,6 +136,8 @@ describe('evaluate-rule', () => { const parentNode: SpaceNode = { label: 'parent.md', + title: 'Parent', + type: 'opportunity', schemaData: { title: 'Parent', type: 'opportunity' }, linkTargets: ['Parent'], resolvedParents: [], @@ -166,6 +176,8 @@ describe('evaluate-rule', () => { it('sets parent to undefined when node has no resolved parent', () => { const orphanNode: SpaceNode = { label: 'orphan.md', + title: 'Orphan', + type: 'outcome', schemaData: { title: 'Orphan', type: 'outcome' }, linkTargets: ['Orphan'], resolvedParents: [], diff --git a/tests/schema/validate-graph.test.ts b/tests/schema/validate-graph.test.ts index d68a47f..e398f83 100644 --- a/tests/schema/validate-graph.test.ts +++ b/tests/schema/validate-graph.test.ts @@ -81,6 +81,8 @@ describe('validate-graph', () => { const buildNode = (title: string, type: string, parentTitle?: string): SpaceNode => ({ label: `${title}.md`, + title, + type, schemaData: { title, type, status: 'active' }, linkTargets: [title], resolvedParents: parentTitle ? [makeParentRef(parentTitle)] : [], diff --git a/tests/schema/validate-rules.test.ts b/tests/schema/validate-rules.test.ts index 99e5e6b..745cd1c 100644 --- a/tests/schema/validate-rules.test.ts +++ b/tests/schema/validate-rules.test.ts @@ -8,6 +8,8 @@ describe('validate-rules', () => { const mockNodes: SpaceNode[] = [ { label: 'outcome.md', + title: 'Outcome', + type: 'outcome', schemaData: { title: 'Outcome', type: 'outcome', status: 'active', metric: 'Increase X' }, linkTargets: ['Outcome'], resolvedParents: [], @@ -15,6 +17,8 @@ describe('validate-rules', () => { }, { label: 'opportunity.md', + title: 'Opportunity', + type: 'opportunity', schemaData: { title: 'Opportunity', type: 'opportunity', @@ -28,6 +32,8 @@ describe('validate-rules', () => { }, { label: 'solution.md', + title: 'Solution', + type: 'solution', schemaData: { title: 'Solution', type: 'solution', status: 'exploring', parent: '[[Opportunity]]' }, linkTargets: ['Solution'], resolvedParents: [makeParentRef('Opportunity')], @@ -35,6 +41,8 @@ describe('validate-rules', () => { }, { label: 'bad-solution.md', + title: 'Bad Solution', + type: 'solution', schemaData: { title: 'Bad Solution', type: 'solution', status: 'exploring', parent: '[[Solution]]' }, linkTargets: ['Bad Solution'], resolvedParents: [makeParentRef('Solution')], @@ -42,6 +50,8 @@ describe('validate-rules', () => { }, { label: 'experiment.md', + title: 'Experiment', + type: 'experiment', schemaData: { title: 'Experiment', type: 'experiment', @@ -55,6 +65,8 @@ describe('validate-rules', () => { }, { label: 'bad-experiment.md', + title: 'Bad Experiment', + type: 'experiment', schemaData: { title: 'Bad Experiment', type: 'experiment', @@ -123,6 +135,8 @@ describe('validate-rules', () => { it('detects outcome with parent', async () => { const outcomeWithParent: SpaceNode = { label: 'bad-outcome.md', + title: 'Bad Outcome', + type: 'outcome', schemaData: { title: 'Bad Outcome', type: 'outcome', status: 'active', parent: '[[Vision]]' }, linkTargets: ['Bad Outcome'], resolvedParents: [makeParentRef('Vision')], @@ -130,6 +144,8 @@ describe('validate-rules', () => { }; const visionNode: SpaceNode = { label: 'vision.md', + title: 'Vision', + type: 'vision', schemaData: { title: 'Vision', type: 'vision', status: 'active' }, linkTargets: ['Vision'], resolvedParents: [], @@ -156,6 +172,8 @@ describe('validate-rules', () => { it('passes when opportunity has enough solutions', async () => { const opportunityNode: SpaceNode = { label: 'opportunity.md', + title: 'Opportunity', + type: 'opportunity', schemaData: { title: 'Opportunity', type: 'opportunity', @@ -170,6 +188,8 @@ describe('validate-rules', () => { const solutions: SpaceNode[] = Array.from({ length: 3 }, (_, i) => ({ label: `solution${i}.md`, + title: `Solution ${i}`, + type: 'solution', schemaData: { title: `Solution ${i}`, type: 'solution', @@ -189,6 +209,8 @@ describe('validate-rules', () => { it('detects opportunity with too few solutions', async () => { const opportunityNode: SpaceNode = { label: 'opportunity.md', + title: 'Opportunity', + type: 'opportunity', schemaData: { title: 'Opportunity', type: 'opportunity', @@ -203,6 +225,8 @@ describe('validate-rules', () => { const singleSolution: SpaceNode = { label: 'solution.md', + title: 'Solution', + type: 'solution', schemaData: { title: 'Solution', type: 'solution', status: 'exploring', parent: '[[Opportunity]]' }, linkTargets: ['Solution'], resolvedParents: [makeParentRef('Opportunity')], @@ -244,6 +268,8 @@ describe('validate-rules', () => { const multipleActiveOutcomes: SpaceNode[] = [ { label: 'outcome1.md', + title: 'Outcome 1', + type: 'outcome', schemaData: { title: 'Outcome 1', type: 'outcome', status: 'active', metric: 'X' }, linkTargets: ['Outcome 1'], resolvedParents: [], @@ -251,6 +277,8 @@ describe('validate-rules', () => { }, { label: 'outcome2.md', + title: 'Outcome 2', + type: 'outcome', schemaData: { title: 'Outcome 2', type: 'outcome', status: 'active', metric: 'Y' }, linkTargets: ['Outcome 2'], resolvedParents: [], @@ -258,6 +286,8 @@ describe('validate-rules', () => { }, { label: 'unrelated.md', + title: 'Unrelated', + type: 'solution', schemaData: { title: 'Unrelated', type: 'solution', status: 'exploring' }, linkTargets: ['Unrelated'], resolvedParents: [], @@ -274,6 +304,8 @@ describe('validate-rules', () => { it('detects active node with non-active parent', async () => { const parentNode: SpaceNode = { label: 'outcome.md', + title: 'Outcome', + type: 'outcome', schemaData: { title: 'Outcome', type: 'outcome', status: 'inactive', metric: 'X' }, linkTargets: ['Outcome'], resolvedParents: [], @@ -281,6 +313,8 @@ describe('validate-rules', () => { }; const childNode: SpaceNode = { label: 'opportunity.md', + title: 'Opportunity', + type: 'opportunity', schemaData: { title: 'Opportunity', type: 'opportunity', @@ -302,6 +336,8 @@ describe('validate-rules', () => { it('passes active node when parent is also active', async () => { const parentNode: SpaceNode = { label: 'outcome.md', + title: 'Outcome', + type: 'outcome', schemaData: { title: 'Outcome', type: 'outcome', status: 'active', metric: 'X' }, linkTargets: ['Outcome'], resolvedParents: [], @@ -309,6 +345,8 @@ describe('validate-rules', () => { }; const childNode: SpaceNode = { label: 'opportunity.md', + title: 'Opportunity', + type: 'opportunity', schemaData: { title: 'Opportunity', type: 'opportunity', @@ -347,6 +385,8 @@ describe('validate-rules', () => { const nodes: SpaceNode[] = [ { label: 'outcome1.md', + title: 'Outcome 1', + type: 'outcome', schemaData: { title: 'Outcome 1', type: 'outcome', status: 'active', metric: 'X' }, linkTargets: ['Outcome 1'], resolvedParents: [], @@ -354,6 +394,8 @@ describe('validate-rules', () => { }, { label: 'outcome2.md', + title: 'Outcome 2', + type: 'outcome', schemaData: { title: 'Outcome 2', type: 'outcome', status: 'active', metric: 'Y' }, linkTargets: ['Outcome 2'], resolvedParents: [], @@ -361,6 +403,8 @@ describe('validate-rules', () => { }, { label: 'solution.md', + title: 'Solution', + type: 'solution', schemaData: { title: 'Solution', type: 'solution', status: 'exploring', parent: '[[Opportunity]]' }, linkTargets: ['Solution'], resolvedParents: [makeParentRef('Opportunity')], @@ -368,6 +412,8 @@ describe('validate-rules', () => { }, { label: 'bad-solution.md', + title: 'Bad Solution', + type: 'solution', schemaData: { title: 'Bad Solution', type: 'solution', status: 'exploring', parent: '[[Solution]]' }, linkTargets: ['Bad Solution'], resolvedParents: [makeParentRef('Solution')], diff --git a/tests/space-graph.test.ts b/tests/space-graph.test.ts new file mode 100644 index 0000000..e6d1864 --- /dev/null +++ b/tests/space-graph.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'bun:test'; +import { buildSpaceGraph } from '../src/space-graph'; +import type { SpaceNode } from '../src/types'; +import { makeLevel, makeParentRef } from './test-helpers'; + +const levels = [makeLevel('goal'), makeLevel('opportunity'), makeLevel('solution')]; + +function makeNode(title: string, type: string): SpaceNode { + return { + label: `${title}.md`, + title, + type, + schemaData: { title, type }, + linkTargets: [title], + resolvedType: type, + resolvedParents: [], + }; +} + +describe('buildSpaceGraph', () => { + describe('nodes map', () => { + it('builds a map of all nodes keyed by title', () => { + const goal = makeNode('My Goal', 'goal'); + const graph = buildSpaceGraph([goal], levels); + expect(graph.nodes.get('My Goal')).toBe(goal); + expect(graph.nodes.size).toBe(1); + }); + + it('preserves insertion order', () => { + const goal = makeNode('My Goal', 'goal'); + const opp = makeNode('An Opportunity', 'opportunity'); + const graph = buildSpaceGraph([goal, opp], levels); + expect([...graph.nodes.keys()]).toEqual(['My Goal', 'An Opportunity']); + }); + }); + + describe('classification', () => { + it('classifies root-type nodes with no parents as hierarchyRoot', () => { + const goal = makeNode('My Goal', 'goal'); + const graph = buildSpaceGraph([goal], levels); + expect(graph.hierarchyRoots).toContain(goal); + }); + + it('classifies non-root hierarchy nodes with no parents as orphan', () => { + const opp = makeNode('Orphaned Opp', 'opportunity'); + const graph = buildSpaceGraph([opp], levels); + expect(graph.orphans).toContain(opp); + }); + + it('classifies non-root hierarchy nodes with valid parents as hierarchyChild', () => { + const goal = makeNode('My Goal', 'goal'); + const opp = makeNode('An Opportunity', 'opportunity'); + opp.resolvedParents = [makeParentRef('My Goal')]; + const graph = buildSpaceGraph([goal, opp], levels); + expect(graph.hierarchyChildren.get('My Goal')).toContain(opp); + }); + + it('classifies nodes with dangling hierarchy parents as orphan', () => { + const opp = makeNode('Orphaned Opp', 'opportunity'); + opp.resolvedParents = [makeParentRef('Missing Goal')]; + const graph = buildSpaceGraph([opp], levels); + expect(graph.orphans).toContain(opp); + }); + + it('classifies nodes not in hierarchy levels as nonHierarchy', () => { + const dashboard = makeNode('My Dashboard', 'dashboard'); + const graph = buildSpaceGraph([dashboard], levels); + expect(graph.nonHierarchy).toContain(dashboard); + }); + + it('handles empty node list', () => { + const graph = buildSpaceGraph([], levels); + expect(graph.hierarchyRoots).toHaveLength(0); + expect(graph.orphans).toHaveLength(0); + expect(graph.nonHierarchy).toHaveLength(0); + expect(graph.nodes.size).toBe(0); + }); + }); + + describe('hierarchyChildren', () => { + it('maps parent to hierarchy children only', () => { + const goal = makeNode('My Goal', 'goal'); + const opp = makeNode('An Opportunity', 'opportunity'); + opp.resolvedParents = [makeParentRef('My Goal', { source: 'hierarchy' })]; + const graph = buildSpaceGraph([goal, opp], levels); + expect(graph.hierarchyChildren.get('My Goal')).toContain(opp); + }); + + it('excludes relationship edges from hierarchyChildren', () => { + const goal = makeNode('My Goal', 'goal'); + const opp = makeNode('An Opportunity', 'opportunity'); + opp.resolvedParents = [makeParentRef('My Goal', { source: 'relationship' })]; + const graph = buildSpaceGraph([goal, opp], levels); + expect(graph.hierarchyChildren.get('My Goal')).toHaveLength(0); + }); + + it('initialises empty array for leaf nodes', () => { + const leaf = makeNode('Leaf', 'solution'); + const graph = buildSpaceGraph([leaf], levels); + expect(graph.hierarchyChildren.get('Leaf')).toEqual([]); + }); + }); + + describe('children (all edges)', () => { + it('includes both hierarchy and relationship children', () => { + const goal = makeNode('My Goal', 'goal'); + const opp = makeNode('An Opportunity', 'opportunity'); + const assumption = makeNode('An Assumption', 'assumption'); + opp.resolvedParents = [makeParentRef('My Goal', { source: 'hierarchy' })]; + assumption.resolvedParents = [makeParentRef('My Goal', { source: 'relationship', field: 'assumptions' })]; + const graph = buildSpaceGraph([goal, opp, assumption], levels); + const children = graph.children.get('My Goal') ?? []; + expect(children).toContain(opp); + expect(children).toContain(assumption); + }); + + it('populates children map for parents not in the node set', () => { + const opp = makeNode('An Opportunity', 'opportunity'); + opp.resolvedParents = [makeParentRef('Missing Goal')]; + const graph = buildSpaceGraph([opp], levels); + expect(graph.children.get('Missing Goal')).toContain(opp); + }); + }); + + describe('hierarchyTitles', () => { + it('includes hierarchy roots and their descendants', () => { + const goal = makeNode('My Goal', 'goal'); + const opp = makeNode('An Opportunity', 'opportunity'); + opp.resolvedParents = [makeParentRef('My Goal')]; + const graph = buildSpaceGraph([goal, opp], levels); + expect(graph.hierarchyTitles.has('My Goal')).toBe(true); + expect(graph.hierarchyTitles.has('An Opportunity')).toBe(true); + }); + + it('includes orphans', () => { + const opp = makeNode('Orphaned Opp', 'opportunity'); + const graph = buildSpaceGraph([opp], levels); + expect(graph.hierarchyTitles.has('Orphaned Opp')).toBe(true); + }); + + it('excludes non-hierarchy nodes', () => { + const dashboard = makeNode('My Dashboard', 'dashboard'); + const graph = buildSpaceGraph([dashboard], levels); + expect(graph.hierarchyTitles.has('My Dashboard')).toBe(false); + }); + }); + + describe('levels', () => { + it('stores the hierarchy levels used to build the graph', () => { + const graph = buildSpaceGraph([], levels); + expect(graph.levels).toBe(levels); + }); + }); +}); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index c8f82b4..ed2a18c 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -51,6 +51,7 @@ export const makeNode = ( linkTargets?: string[], ): BaseNode => ({ label: `${title}.md`, + title, schemaData: { title, type, ...extra }, linkTargets: linkTargets ?? [title], type, diff --git a/tests/validate/general.test.ts b/tests/validate/general.test.ts index 30654a5..8462d85 100644 --- a/tests/validate/general.test.ts +++ b/tests/validate/general.test.ts @@ -92,12 +92,14 @@ describe('Schema validation', () => { const baseNodes: BaseNode[] = [ { label: 'anchor_vision.md', + title: 'anchor_vision', schemaData: { title: 'anchor_vision', type: 'vision', status: 'active' }, linkTargets: ['anchor_vision'], type: 'vision', }, { label: 'Our Mission', + title: 'Our Mission', schemaData: { title: 'Our Mission', type: 'mission', @@ -109,6 +111,7 @@ describe('Schema validation', () => { }, { label: 'Another Goal', + title: 'Another Goal', schemaData: { title: 'Another Goal', type: 'goal', @@ -120,6 +123,7 @@ describe('Schema validation', () => { }, { label: 'solution_page.md', + title: 'solution_page', schemaData: { title: 'solution_page', type: 'solution', @@ -148,12 +152,14 @@ describe('Schema validation', () => { const baseNodes: BaseNode[] = [ { label: 'anchor_vision.md', + title: 'anchor_vision', schemaData: { title: 'anchor_vision', type: 'vision', status: 'active' }, linkTargets: ['anchor_vision'], type: 'vision', }, { label: 'some-solution.md', + title: 'some-solution', schemaData: { title: 'some-solution', type: 'solution', @@ -178,12 +184,14 @@ describe('Schema validation', () => { const baseNodes: BaseNode[] = [ { label: 'vision_page.md', + title: 'vision_page', schemaData: { title: 'vision_page', type: 'vision', status: 'active' }, linkTargets: ['vision_page'], type: 'vision', }, { label: 'Embedded Goal', + title: 'Embedded Goal', schemaData: { title: 'Embedded Goal', type: 'goal', @@ -195,6 +203,7 @@ describe('Schema validation', () => { }, { label: 'solution_page.md', + title: 'solution_page', schemaData: { title: 'solution_page', type: 'solution', @@ -302,7 +311,7 @@ describe('Schema validation', () => { ); const titleCounts = new Map(); for (const node of nodes) { - const title = node.schemaData.title as string; + const title = node.title; if (!titleCounts.has(title)) { titleCounts.set(title, []); } @@ -321,7 +330,7 @@ describe('Schema validation', () => { ); const titleCounts = new Map(); for (const node of nodes) { - const title = node.schemaData.title as string; + const title = node.title; if (!titleCounts.has(title)) { titleCounts.set(title, []); }