diff --git a/docs/api.md b/docs/api.md index 7fac5cf..818fd7c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -142,3 +142,5 @@ const myRenderHook: RenderHook = { }; ``` +A render hook can validate the space and export the rendered graph through `context.callbacks.validate()` and `exportGraph()`. Pass `semantic` to `validate` only when the render run asks for it, never from plugin config. + diff --git a/docs/config.md b/docs/config.md index 0bc8c6d..1cbe546 100644 --- a/docs/config.md +++ b/docs/config.md @@ -88,7 +88,15 @@ Use `plugins` to load parse plugins that read spaces from non-markdown sources. } ``` -All plugin names must start with `sctx-` (the prefix is optional in config and normalised on load). The special name `markdown` refers to the built-in markdown plugin. External plugins are resolved in order: the root's plugins folder (`.sctx/plugins/{name}`, see [Resources folder](#resources-folder)), then npm. Each plugin must export a `configSchema` JSON Schema; config is validated against it on load. Fields annotated `format: 'path'` in a plugin's `configSchema` are resolved relative to the config root. +All plugin names must start with `sctx-` (the prefix is optional in config and normalised on load). The special name `markdown` refers to the built-in markdown plugin. A plugin named in `plugins` is resolved in order: the root's plugins folder (`.sctx/plugins/{name}`, see [Resources folder](#resources-folder)), then npm. Each plugin must export a `configSchema` JSON Schema; config is validated against it on load. Fields annotated `format: 'path'` in a plugin's `configSchema` are resolved relative to the config root. + +A plugin comes from one of three places, and where it comes from decides when it is used: + +| Plugin | Parse and template sync | Render formats | +|---|---|---| +| Built-in | Every space | Every space | +| In a plugins folder (`.sctx/plugins/sctx-*`) | Spaces that name it in `plugins` | Every space | +| Installed package (`sctx-*`) | Spaces that name it in `plugins` | Spaces that name it in `plugins` | Plugins can also contribute schemas and partials via a `/schemas/` directory (or `"sctx": { "schemas": "..." }` in `package.json`). If a space specifies a plugin-prefixed schema (e.g. `"sctx-wardley-mapping/wardley_map.json"`), schemas are resolved statically without executing plugin code, and the plugin's code hooks are loaded when parsing or rendering. diff --git a/src/commands/render.ts b/src/commands/render.ts index 1e60cfa..f3557fb 100644 --- a/src/commands/render.ts +++ b/src/commands/render.ts @@ -1,8 +1,7 @@ import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; -import { discoverPlugins, type LoadedPlugin, loadPlugins } from '../plugins/loader'; import { isRenderMultiFileOutput } from '../plugins/util'; -import { buildFormatRegistry } from '../render/registry'; +import { renderFormats } from '../render/registry'; import { executeRender } from '../render/render'; import type { SpaceContext } from '../types'; @@ -89,16 +88,7 @@ export async function render( } export async function renderList(context?: SpaceContext): Promise { - let loaded: LoadedPlugin[]; - 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); + const registry = await renderFormats(context); if (registry.length === 0) { console.log('No render formats available.'); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 9ff3028..d651c41 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -42,11 +42,8 @@ function resolveConfigPaths( return result; } -/** - * Resolve an external plugin by canonical name. - * Resolution order: the config root's plugins folder → npm (import(name)). - */ -async function resolveExternalPlugin(name: string, configDir: string): Promise { +/** Import a plugin by its `sctx-` name: from the config root's plugins folder if it is there, else as a package. */ +async function importPlugin(name: string, configDir: string): Promise { const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); const localPath = pluginsDir && resolve(join(pluginsDir, name)); const local = localPath && (existsSync(localPath) || existsSync(`${localPath}.ts`)); @@ -59,25 +56,49 @@ async function resolveExternalPlugin(name: string, configDir: string): Promise { - const configDirs = [...new Set(Array.from(getConfigSourceFiles()).map((f) => configRoot(f)))]; +async function spacePlugins( + pluginMap: Record>, + configDir: string, +): Promise { + const named: StructuredContextPlugin[] = []; + for (const rawName of Object.keys(pluginMap)) { + const name = normalizePluginName(rawName); + if (isBuiltinPluginName(name)) continue; + if (!name.startsWith(PLUGIN_PREFIX)) { + throw new Error(`Plugin name must start with "${PLUGIN_PREFIX}" (got "${rawName}")`); + } + named.push(await importPlugin(name, configDir)); + } + return [...named, ...builtinPlugins]; +} - const plugins: StructuredContextPlugin[] = [...builtinPlugins]; - const seenNames = new Set(builtinPlugins.map((p) => p.name)); +/** + * Discover all available plugins: the built-ins, then any found in the plugins folder of + * each loaded config file's root (the main config and the configs it includes). + * Given a space, starts from the plugins it uses instead of the built-ins, and also searches + * its config root's plugins folder. + */ +export async function discoverPlugins(space?: { + pluginMap: Record>; + configDir: string; +}): Promise { + const plugins = space ? await spacePlugins(space.pluginMap, space.configDir) : [...builtinPlugins]; + const seenNames = new Set(plugins.map((p) => p.name)); - for (const configDir of configDirs) { + const configDirs = Array.from(getConfigSourceFiles()).map((f) => configRoot(f)); + for (const configDir of new Set(space ? [space.configDir, ...configDirs] : configDirs)) { const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); if (pluginsDir) { const entries = readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX)); for (const entry of entries) { - if (!seenNames.has(entry)) { - plugins.push(await resolveExternalPlugin(entry, configDir)); - seenNames.add(entry); - } + if (seenNames.has(entry)) continue; + const plugin = await importPlugin(entry, configDir); + if (seenNames.has(plugin.name)) continue; + plugins.push(plugin); + seenNames.add(plugin.name); } } } @@ -85,48 +106,32 @@ export async function discoverPlugins(): Promise { } /** - * Load plugins for a space. - * - * Built-in plugins are always included (with config from the map if declared, else {}). - * External plugins are loaded from the map and prepended in declaration order. - * Resolution order for external plugins: config-adjacent → npm. - * Fields annotated with format:'path' in a plugin's configSchema are resolved - * relative to configDir. + * A plugin's config block from the space's plugin map ({} if absent), validated against its + * configSchema. Fields annotated with format:'path' are resolved relative to configDir first. */ -export async function loadPlugins( +export function validatePluginConfig( + plugin: StructuredContextPlugin, pluginMap: Record>, configDir: string, -): Promise { +): Record { + const rawConfig = pluginMap[plugin.name] ?? pluginMap[shortenPluginName(plugin.name)] ?? {}; const ajv = new Ajv2020(); ajv.addFormat('path', (value: string) => value.length > 0 && !value.includes('\0')); - const loaded: LoadedPlugin[] = []; - - // External plugins: entries in the map that are not built-in names - for (const [rawName, rawConfig] of Object.entries(pluginMap)) { - const name = normalizePluginName(rawName); - if (isBuiltinPluginName(name)) continue; - if (!name.startsWith(PLUGIN_PREFIX)) { - throw new Error(`Plugin name must start with "${PLUGIN_PREFIX}" (got "${rawName}")`); - } - const plugin = await resolveExternalPlugin(name, configDir); - const pluginConfig = resolveConfigPaths(plugin.configSchema, rawConfig, configDir); - const validate = ajv.compile(plugin.configSchema); - if (!validate(pluginConfig)) { - throw new Error(`Invalid config for plugin "${name}": ${JSON.stringify(validate.errors)}`); - } - loaded.push({ plugin, pluginConfig }); - } - - // Built-in plugins: always loaded, config taken from map if declared (with or without prefix) - for (const builtin of builtinPlugins) { - const rawConfig = pluginMap[builtin.name] ?? pluginMap[shortenPluginName(builtin.name)] ?? {}; - const pluginConfig = resolveConfigPaths(builtin.configSchema, rawConfig, configDir); - const validate = ajv.compile(builtin.configSchema); - if (!validate(pluginConfig)) { - throw new Error(`Invalid config for plugin "${builtin.name}": ${JSON.stringify(validate.errors)}`); - } - loaded.push({ plugin: builtin, pluginConfig }); + const pluginConfig = resolveConfigPaths(plugin.configSchema, rawConfig, configDir); + const validate = ajv.compile(plugin.configSchema); + if (!validate(pluginConfig)) { + throw new Error(`Invalid config for plugin "${plugin.name}": ${JSON.stringify(validate.errors)}`); } + return pluginConfig; +} - return loaded; +/** The plugins a space uses, each with its validated config. Parse and templateSync try them in this order. */ +export async function loadPlugins( + pluginMap: Record>, + configDir: string, +): Promise { + return (await spacePlugins(pluginMap, configDir)).map((plugin) => ({ + plugin, + pluginConfig: validatePluginConfig(plugin, pluginMap, configDir), + })); } diff --git a/src/plugins/util.ts b/src/plugins/util.ts index 9bd0aa3..510a09e 100644 --- a/src/plugins/util.ts +++ b/src/plugins/util.ts @@ -7,7 +7,9 @@ import { resourceDir } from '../config/layout'; import { PLUGIN_PREFIX as _PLUGIN_PREFIX } from '../constants'; import type { PluginSchemaSource } from '../schema/schema'; import type { SpaceGraph } from '../space-graph'; -import type { BaseNode, ParseIssue, SpaceContext } from '../types'; +import type { SpaceGraphData } from '../space-graph-data'; +import type { BaseNode, ParseIssue, SpaceContext, ValidationIssue } from '../types'; +import type { ValidateSpaceOptions } from '../validate'; import { isBuiltinPluginName } from './builtin-names'; export const PLUGIN_PREFIX = _PLUGIN_PREFIX; @@ -30,6 +32,12 @@ export type PluginContext = SpaceContext & { callbacks?: { /** Persist plugin config updates to the config file. */ persistConfig?: (updates: Record) => void; + /** Render only: validate the whole space and return every issue found, of every severity. */ + validate?: ( + options?: Pick, + ) => Promise; + /** Render only: the graph being rendered, in the space graph format. */ + exportGraph?: () => SpaceGraphData; }; }; diff --git a/src/render/registry.ts b/src/render/registry.ts index f949112..c95c65c 100644 --- a/src/render/registry.ts +++ b/src/render/registry.ts @@ -1,30 +1,33 @@ -import type { LoadedPlugin } from '../plugins/loader'; -import type { RenderFormat } from '../plugins/util'; +import { discoverPlugins } from '../plugins/loader'; +import type { RenderFormat, StructuredContextPlugin } from '../plugins/util'; import { PLUGIN_PREFIX } from '../plugins/util'; +import type { SpaceContext } from '../types'; export type ResolvedFormat = { qualifiedName: string; format: RenderFormat; - plugin: LoadedPlugin; + plugin: StructuredContextPlugin; }; /** - * Build a registry of all render formats from loaded plugins. + * Build a registry of all render formats from the given plugins. * Formats are namespaced as `{shortPluginName}.{formatName}` where * shortPluginName strips the `sctx-` prefix. */ -export function buildFormatRegistry(loaded: LoadedPlugin[]): ResolvedFormat[] { +export function buildFormatRegistry(plugins: StructuredContextPlugin[]): 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, - }); + for (const plugin of plugins) { + if (!plugin.render) continue; + const shortName = plugin.name.replace(PLUGIN_PREFIX, ''); + for (const format of plugin.render.formats) { + registry.push({ qualifiedName: `${shortName}.${format.name}`, format, plugin }); } } return registry; } + +/** The render formats of every discovered plugin, including those the space declares. */ +export async function renderFormats(context?: SpaceContext): Promise { + const space = context && { pluginMap: context.space?.plugins ?? {}, configDir: context.configDir }; + return buildFormatRegistry(await discoverPlugins(space)); +} diff --git a/src/render/render.ts b/src/render/render.ts index be1b636..c53fedb 100644 --- a/src/render/render.ts +++ b/src/render/render.ts @@ -1,18 +1,19 @@ import { updateSpaceField } from '../config'; import { assembleSpaceGraph } from '../load-space-graph'; -import { loadPlugins } from '../plugins/loader'; -import { type RenderResult, shortenPluginName } from '../plugins/util'; +import { validatePluginConfig } from '../plugins/loader'; +import { type PluginContext, type RenderResult, shortenPluginName } from '../plugins/util'; +import { readSpace } from '../read/read-space'; +import { exportSpaceGraph } from '../space-graph-data'; import type { SpaceContext } from '../types'; -import { buildFormatRegistry } from './registry'; +import { validateSpace } from '../validate'; +import { renderFormats } from './registry'; export async function executeRender( formatName: string, context: SpaceContext, options: { filter?: string; hierarchy?: string; data?: Record }, ): Promise { - const pluginMap: Record> = context.space?.plugins ?? {}; - const loaded = await loadPlugins(pluginMap, context.configDir); - const registry = buildFormatRegistry(loaded); + const registry = await renderFormats(context); const entry = registry.find((r) => r.qualifiedName === formatName); if (!entry) { @@ -21,23 +22,29 @@ export async function executeRender( `Unknown render format: "${formatName}".${available ? ` Available: ${available}` : ' No formats registered.'}`, ); } + const { plugin } = entry; + const pluginConfig = validatePluginConfig(plugin, context.space?.plugins ?? {}, context.configDir); - const graph = await assembleSpaceGraph(context, { filter: options.filter, hierarchy: options.hierarchy }); + const readResult = await readSpace(context); + const graph = await assembleSpaceGraph(context, { filter: options.filter, hierarchy: options.hierarchy, readResult }); - const shortName = shortenPluginName(entry.plugin.plugin.name); - const pluginContext = { + const shortName = shortenPluginName(plugin.name); + const pluginContext: PluginContext = { ...context, - pluginConfig: entry.plugin.pluginConfig, + pluginConfig, callbacks: { - persistConfig: (updates: Record) => { + persistConfig: (updates) => { for (const [field, value] of Object.entries(updates)) { updateSpaceField(context.space.name, field, value, shortName); } }, + validate: async (validateOptions) => (await validateSpace(context, { ...validateOptions, readResult })).issues, + exportGraph: () => + exportSpaceGraph(graph.nodes.values(), { + hierarchy: graph.hierarchy, + hierarchies: context.schema.metadata.hierarchies, + }), }, }; - return entry.plugin.plugin.render!.render(pluginContext, graph, { - format: entry.format.name, - data: options.data, - }); + return plugin.render!.render(pluginContext, graph, { format: entry.format.name, data: options.data }); } diff --git a/tests/fixtures/render-plugins/.sctx/plugins/sctx-needs-config.ts b/tests/fixtures/render-plugins/.sctx/plugins/sctx-needs-config.ts new file mode 100644 index 0000000..ab5c22a --- /dev/null +++ b/tests/fixtures/render-plugins/.sctx/plugins/sctx-needs-config.ts @@ -0,0 +1,13 @@ +import type { StructuredContextPlugin } from '../../../../../src/plugins/util'; + +/** A render plugin whose config requires a field no space declares. */ +const needsConfig: StructuredContextPlugin = { + name: 'sctx-needs-config', + configSchema: { type: 'object', required: ['token'] }, + render: { + formats: [{ name: 'out', description: 'Never renders' }], + render: () => '', + }, +}; + +export default needsConfig; diff --git a/tests/fixtures/render-plugins/.sctx/plugins/sctx-probe.ts b/tests/fixtures/render-plugins/.sctx/plugins/sctx-probe.ts new file mode 100644 index 0000000..e6de94e --- /dev/null +++ b/tests/fixtures/render-plugins/.sctx/plugins/sctx-probe.ts @@ -0,0 +1,20 @@ +import type { StructuredContextPlugin } from '../../../../../src/plugins/util'; + +/** Renders what the render callbacks return, for tests. */ +const probe: StructuredContextPlugin = { + name: 'sctx-probe', + configSchema: { type: 'object' }, + render: { + formats: [{ name: 'callbacks', description: 'Callback results as JSON' }], + async render(context) { + const issues = (await context.callbacks?.validate?.()) ?? []; + const graph = context.callbacks?.exportGraph?.(); + return JSON.stringify({ + issues: issues.map(({ severity, nodeId }) => ({ severity, nodeId })), + nodes: graph?.nodes.map((n) => n.title).sort(), + }); + }, + }, +}; + +export default probe; diff --git a/tests/fixtures/render-plugins/space/Grow.md b/tests/fixtures/render-plugins/space/Grow.md new file mode 100644 index 0000000..01e8ad7 --- /dev/null +++ b/tests/fixtures/render-plugins/space/Grow.md @@ -0,0 +1,4 @@ +--- +type: goal +status: exploring +--- diff --git a/tests/fixtures/render-plugins/space/Missing status.md b/tests/fixtures/render-plugins/space/Missing status.md new file mode 100644 index 0000000..86878d4 --- /dev/null +++ b/tests/fixtures/render-plugins/space/Missing status.md @@ -0,0 +1,3 @@ +--- +type: goal +--- diff --git a/tests/render/registry.test.ts b/tests/render/registry.test.ts index b253021..83717ba 100644 --- a/tests/render/registry.test.ts +++ b/tests/render/registry.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import type { LoadedPlugin } from '../../src/plugins/loader'; import type { StructuredContextPlugin } from '../../src/plugins/util'; import { buildFormatRegistry } from '../../src/render/registry'; -function makePlugin(name: string, formats: { name: string; description: string }[]): LoadedPlugin { +function makePlugin(name: string, formats: { name: string; description: string }[]): StructuredContextPlugin { const plugin: StructuredContextPlugin = { name, configSchema: { type: 'object' }, @@ -12,14 +11,12 @@ function makePlugin(name: string, formats: { name: string; description: string } render: () => '', }, }; - return { plugin, pluginConfig: {} }; + return plugin; } describe('buildFormatRegistry', () => { it('returns empty registry for plugins with no render hook', () => { - const loaded: LoadedPlugin[] = [ - { plugin: { name: 'sctx-foo', configSchema: { type: 'object' } }, pluginConfig: {} }, - ]; + const loaded: StructuredContextPlugin[] = [{ name: 'sctx-foo', configSchema: { type: 'object' } }]; expect(buildFormatRegistry(loaded)).toEqual([]); }); @@ -46,8 +43,8 @@ describe('buildFormatRegistry', () => { }); it('skips plugins without a render hook and includes those with one', () => { - const loaded: LoadedPlugin[] = [ - { plugin: { name: 'sctx-norender', configSchema: { type: 'object' } }, pluginConfig: {} }, + const loaded: StructuredContextPlugin[] = [ + { name: 'sctx-norender', configSchema: { type: 'object' } }, ...[makePlugin('sctx-markdown', [{ name: 'bullets', description: 'Bullets' }])], ]; const registry = buildFormatRegistry(loaded); diff --git a/tests/render/render-plugins.test.ts b/tests/render/render-plugins.test.ts new file mode 100644 index 0000000..aa0883a --- /dev/null +++ b/tests/render/render-plugins.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'bun:test'; +import { join } from 'node:path'; +import { renderList } from '../../src/commands/render'; +import { executeRender } from '../../src/render/render'; +import { makeSpaceContext } from '../helpers/context'; + +// The space's config root holds .sctx/plugins/, which no space declares. +const context = makeSpaceContext(join(import.meta.dir, '..', 'fixtures/render-plugins/space')); + +describe('render with discovered plugins', () => { + it('renders a format from a plugin the space does not declare', async () => { + const output = JSON.parse((await executeRender('probe.callbacks', context, {})) as string); + expect(output.nodes).toEqual(['Grow', 'Missing status']); + expect(output.issues).toContainEqual({ severity: 'error', nodeId: 'Missing status.md' }); + }); + + it('exports the filtered graph', async () => { + const output = JSON.parse( + (await executeRender('probe.callbacks', context, { filter: "WHERE status = 'exploring'" })) as string, + ); + expect(output.nodes).toEqual(['Grow']); + }); + + it("validates a plugin's config only when its format is rendered", async () => { + await expect(executeRender('needs-config.out', context, {})).rejects.toThrow( + 'Invalid config for plugin "sctx-needs-config"', + ); + }); + + it('lists formats from discovered plugins', async () => { + const lines: string[] = []; + const log = console.log; + console.log = (line: string) => lines.push(line); + try { + await renderList(context); + } finally { + console.log = log; + } + expect(lines.map((l) => l.trim().split(/\s+/)[0])).toEqual( + expect.arrayContaining(['probe.callbacks', 'needs-config.out']), + ); + }); +});