Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
141 changes: 7 additions & 134 deletions src/commands/diagram.ts
Original file line number Diff line number Diff line change
@@ -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 { executeRender } from '../render/render';
import type { SpaceContext } from '../types';

/**
* 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<void> {
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<string>();

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<void> {
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);
}
}
42 changes: 42 additions & 0 deletions src/commands/render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { writeFileSync } from 'node:fs';
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,
format: string,
options: { filter?: string; output?: string },
): Promise<void> {
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<void> {
let loaded: LoadedPlugin[];
if (context) {
const pluginMap: Record<string, Record<string, unknown>> = 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}`);
}
}
64 changes: 5 additions & 59 deletions src/commands/show.ts
Original file line number Diff line number Diff line change
@@ -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 { executeRender } from '../render/render';
import type { SpaceContext } from '../types';

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<string>();

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');
}
7 changes: 3 additions & 4 deletions src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -169,7 +169,7 @@ export async function validate(context: SpaceContext): Promise<number> {
// Detect duplicate node keys (titles)
const titleToFiles = new Map<string, string[]>();
for (const node of nodes) {
const title = node.schemaData.title as string;
const title = node.title;
if (!titleToFiles.has(title)) {
titleToFiles.set(title, []);
}
Expand All @@ -190,8 +190,7 @@ export async function validate(context: SpaceContext): Promise<number> {

// 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
Expand Down
Loading
Loading