From f89d69147c053d98a21065e1c1ed2a7a63259f24 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 03:44:26 +0000 Subject: [PATCH 1/6] feat(render): formats from discovered plugins; validate and exportGraph callbacks Render formats now come from every available plugin, not only those a space declares. A plugin's config is validated when its format is rendered, so an undeclared plugin with required config fails only then. Render hooks get context.callbacks.validate(options) and exportGraph(), so a render plugin needs no runtime import of structured-context. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XphpeFwEYm8K11SoT9ewz --- docs/api.md | 5 + docs/concepts.md | 2 + src/commands/render.ts | 15 +-- src/plugins/loader.ts | 103 +++++++++++------- src/plugins/util.ts | 10 +- src/render/registry.ts | 10 +- src/render/render.ts | 33 +++--- .../.sctx/plugins/sctx-needs-config.ts | 13 +++ .../.sctx/plugins/sctx-probe.ts | 20 ++++ tests/fixtures/render-plugins/space/Grow.md | 4 + .../render-plugins/space/Missing status.md | 3 + tests/render/registry.test.ts | 14 +-- tests/render/render-plugins.test.ts | 43 ++++++++ 13 files changed, 202 insertions(+), 73 deletions(-) create mode 100644 tests/fixtures/render-plugins/.sctx/plugins/sctx-needs-config.ts create mode 100644 tests/fixtures/render-plugins/.sctx/plugins/sctx-probe.ts create mode 100644 tests/fixtures/render-plugins/space/Grow.md create mode 100644 tests/fixtures/render-plugins/space/Missing status.md create mode 100644 tests/render/render-plugins.test.ts diff --git a/docs/api.md b/docs/api.md index 7fac5cfe..0e47d005 100644 --- a/docs/api.md +++ b/docs/api.md @@ -142,3 +142,8 @@ const myRenderHook: RenderHook = { }; ``` +A render hook gets two callbacks in `context.callbacks`, so a plugin needs no runtime import of structured-context: + +- `validate(options)` validates the whole space and returns every issue, of every severity. Its options are those of `validateSpace`: `includeGated`, `ruleFilter`, `semantic` and `depth`. Semantic judging runs only when a run asks for it, so pass `semantic` from the render options, not from plugin config. +- `exportGraph()` returns the graph being rendered in the [space graph format](#space-graph-format), structured by the hierarchy the render selected. + diff --git a/docs/concepts.md b/docs/concepts.md index 652733bc..42122353 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -483,6 +483,8 @@ The `plugins` field in config is a **map** from plugin name to plugin config obj Each plugin declares a `configSchema` JSON Schema; the loader validates the config block against it before invoking the plugin. Config fields annotated with `format: 'path'` are resolved relative to the config root by the loader. +Render formats come from every available plugin: the ones a space declares, and any other plugin in a config root's plugins folder. An undeclared plugin gets an empty config block. A plugin's config is validated when one of its formats is rendered. + ### Config root The **config root** is the folder a config file's relative paths resolve from: the folder holding `.sctx.json` or `.sctx/`, or the global config's folder. Its **resources folder** (`.sctx/`, or the global config's folder) holds plugins, schemas and map view definitions. In code, `configDir` and `SpaceConfig.sourceDir` hold a config root. See [Resources folder](config.md#resources-folder). diff --git a/src/commands/render.ts b/src/commands/render.ts index 1e60cfae..5d6a5052 100644 --- a/src/commands/render.ts +++ b/src/commands/render.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; -import { discoverPlugins, type LoadedPlugin, loadPlugins } from '../plugins/loader'; +import { availablePlugins, type DeclaredPlugin, discoverPlugins } from '../plugins/loader'; import { isRenderMultiFileOutput } from '../plugins/util'; import { buildFormatRegistry } from '../render/registry'; import { executeRender } from '../render/render'; @@ -89,16 +89,11 @@ 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 plugins: DeclaredPlugin[] = context + ? await availablePlugins(context.space?.plugins ?? {}, context.configDir) + : (await discoverPlugins()).map((plugin) => ({ plugin, rawConfig: {} })); - const registry = buildFormatRegistry(loaded); + const registry = buildFormatRegistry(plugins); if (registry.length === 0) { console.log('No render formats available.'); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 9ff30280..faf0651f 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -58,18 +58,21 @@ async function resolveExternalPlugin(name: string, configDir: string): Promise configRoot(f)); +} + /** * Discover all available plugins: built-ins first, then any found in the plugins folder of - * each loaded config file's root. Does not load npm plugins + * each config root (by default, the roots of the loaded config files). Does not load npm plugins * (those are only declared in space configs). */ -export async function discoverPlugins(): Promise { - const configDirs = [...new Set(Array.from(getConfigSourceFiles()).map((f) => configRoot(f)))]; - +export async function discoverPlugins(configDirs: string[] = configRoots()): Promise { const plugins: StructuredContextPlugin[] = [...builtinPlugins]; const seenNames = new Set(builtinPlugins.map((p) => p.name)); - for (const configDir of configDirs) { + for (const configDir of new Set(configDirs)) { const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); if (pluginsDir) { const entries = readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX)); @@ -84,49 +87,75 @@ export async function discoverPlugins(): Promise { return plugins; } -/** - * 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. - */ -export async function loadPlugins( +/** A plugin with the config block a space declares for it, not yet validated. */ +export type DeclaredPlugin = { + plugin: StructuredContextPlugin; + rawConfig: Record; +}; + +/** The space's plugins: external ones from the map in declaration order, then the built-ins. */ +async function declaredPlugins( pluginMap: Record>, configDir: string, -): Promise { - 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 +): Promise { + const declared: DeclaredPlugin[] = []; 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 }); + declared.push({ plugin: await resolveExternalPlugin(name, configDir), rawConfig }); } - - // 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 }); + declared.push({ plugin: builtin, rawConfig }); } + return declared; +} - return loaded; +/** + * Validate a plugin's config block against its configSchema. Fields annotated with + * format:'path' are resolved relative to configDir first. + */ +export function configurePlugin({ plugin, rawConfig }: DeclaredPlugin, configDir: string): LoadedPlugin { + const ajv = new Ajv2020(); + ajv.addFormat('path', (value: string) => value.length > 0 && !value.includes('\0')); + 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 { plugin, pluginConfig }; +} + +/** + * Load plugins for a space, with their config validated. + * + * Built-in plugins are always included (with config from the map if declared, else {}). + * External plugins are loaded from the map and come first, in declaration order. + * Resolution order for external plugins: config-adjacent → npm. + */ +export async function loadPlugins( + pluginMap: Record>, + configDir: string, +): Promise { + return (await declaredPlugins(pluginMap, configDir)).map((declared) => configurePlugin(declared, configDir)); +} + +/** + * Every plugin a space can use: the ones it declares, then any other discovered plugin with + * an empty config. Config is not validated, so a plugin with invalid config fails only when + * it is used (see {@link configurePlugin}). + */ +export async function availablePlugins( + pluginMap: Record>, + configDir: string, +): Promise { + const available = await declaredPlugins(pluginMap, configDir); + const names = new Set(available.map(({ plugin }) => plugin.name)); + for (const plugin of await discoverPlugins([configDir, ...configRoots()])) { + if (!names.has(plugin.name)) available.push({ plugin, rawConfig: {} }); + } + return available; } diff --git a/src/plugins/util.ts b/src/plugins/util.ts index 9bd0aa30..510a09e5 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 f949112f..add1a793 100644 --- a/src/render/registry.ts +++ b/src/render/registry.ts @@ -1,21 +1,21 @@ -import type { LoadedPlugin } from '../plugins/loader'; +import type { DeclaredPlugin } from '../plugins/loader'; import type { RenderFormat } from '../plugins/util'; import { PLUGIN_PREFIX } from '../plugins/util'; export type ResolvedFormat = { qualifiedName: string; format: RenderFormat; - plugin: LoadedPlugin; + plugin: DeclaredPlugin; }; /** - * 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: DeclaredPlugin[]): ResolvedFormat[] { const registry: ResolvedFormat[] = []; - for (const lp of loaded) { + for (const lp of plugins) { if (!lp.plugin.render) continue; const shortName = lp.plugin.name.replace(PLUGIN_PREFIX, ''); for (const format of lp.plugin.render.formats) { diff --git a/src/render/render.ts b/src/render/render.ts index be1b636a..11b06910 100644 --- a/src/render/render.ts +++ b/src/render/render.ts @@ -1,8 +1,11 @@ import { updateSpaceField } from '../config'; import { assembleSpaceGraph } from '../load-space-graph'; -import { loadPlugins } from '../plugins/loader'; -import { type RenderResult, shortenPluginName } from '../plugins/util'; +import { availablePlugins, configurePlugin } 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 { validateSpace } from '../validate'; import { buildFormatRegistry } from './registry'; export async function executeRender( @@ -11,8 +14,7 @@ export async function executeRender( 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 = buildFormatRegistry(await availablePlugins(pluginMap, context.configDir)); const entry = registry.find((r) => r.qualifiedName === formatName); if (!entry) { @@ -21,23 +23,28 @@ export async function executeRender( `Unknown render format: "${formatName}".${available ? ` Available: ${available}` : ' No formats registered.'}`, ); } + const { plugin, pluginConfig } = configurePlugin(entry.plugin, 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 00000000..ab5c22ac --- /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 00000000..e6de94e4 --- /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 00000000..01e8ad73 --- /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 00000000..86878d45 --- /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 b2530219..174786ed 100644 --- a/tests/render/registry.test.ts +++ b/tests/render/registry.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'bun:test'; -import type { LoadedPlugin } from '../../src/plugins/loader'; +import type { DeclaredPlugin } 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 }[]): DeclaredPlugin { const plugin: StructuredContextPlugin = { name, configSchema: { type: 'object' }, @@ -12,13 +12,13 @@ function makePlugin(name: string, formats: { name: string; description: string } render: () => '', }, }; - return { plugin, pluginConfig: {} }; + return { plugin, rawConfig: {} }; } 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: DeclaredPlugin[] = [ + { plugin: { name: 'sctx-foo', configSchema: { type: 'object' } }, rawConfig: {} }, ]; expect(buildFormatRegistry(loaded)).toEqual([]); }); @@ -46,8 +46,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: DeclaredPlugin[] = [ + { plugin: { name: 'sctx-norender', configSchema: { type: 'object' } }, rawConfig: {} }, ...[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 00000000..aa0883ad --- /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']), + ); + }); +}); From ffa19453b78bba684a01c1eb3fe521a83d65627f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 00:35:37 +0000 Subject: [PATCH 2/6] refactor(render): simplify plugin discovery and config validation discoverPlugins takes an optional space (plugin map and config root) and then includes its declared plugins. validatePluginConfig validates one plugin's block from the map, and loadPlugins uses it. render/ owns listing formats (renderFormats), so the command no longer builds the registry. The render-format note moves from concepts.md to api.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XphpeFwEYm8K11SoT9ewz --- docs/api.md | 4 +- docs/concepts.md | 2 - src/commands/render.ts | 9 +-- src/plugins/loader.ts | 108 ++++++++++++++-------------------- src/render/registry.ts | 29 +++++---- src/render/render.ts | 10 ++-- tests/render/registry.test.ts | 13 ++-- 7 files changed, 76 insertions(+), 99 deletions(-) diff --git a/docs/api.md b/docs/api.md index 0e47d005..7c0fe3c3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -142,8 +142,10 @@ const myRenderHook: RenderHook = { }; ``` +A plugin in a config root's plugins folder provides its formats to every space, including spaces that don't list it in `plugins`. Its config is checked only when one of its formats is rendered. + A render hook gets two callbacks in `context.callbacks`, so a plugin needs no runtime import of structured-context: -- `validate(options)` validates the whole space and returns every issue, of every severity. Its options are those of `validateSpace`: `includeGated`, `ruleFilter`, `semantic` and `depth`. Semantic judging runs only when a run asks for it, so pass `semantic` from the render options, not from plugin config. +- `validate(options)` validates the whole space and returns every issue, of every severity. It takes the options of `validateSpace`. Pass `semantic` only when the render run asks for it, never from plugin config, because semantic judging costs money. - `exportGraph()` returns the graph being rendered in the [space graph format](#space-graph-format), structured by the hierarchy the render selected. diff --git a/docs/concepts.md b/docs/concepts.md index 42122353..652733bc 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -483,8 +483,6 @@ The `plugins` field in config is a **map** from plugin name to plugin config obj Each plugin declares a `configSchema` JSON Schema; the loader validates the config block against it before invoking the plugin. Config fields annotated with `format: 'path'` are resolved relative to the config root by the loader. -Render formats come from every available plugin: the ones a space declares, and any other plugin in a config root's plugins folder. An undeclared plugin gets an empty config block. A plugin's config is validated when one of its formats is rendered. - ### Config root The **config root** is the folder a config file's relative paths resolve from: the folder holding `.sctx.json` or `.sctx/`, or the global config's folder. Its **resources folder** (`.sctx/`, or the global config's folder) holds plugins, schemas and map view definitions. In code, `configDir` and `SpaceConfig.sourceDir` hold a config root. See [Resources folder](config.md#resources-folder). diff --git a/src/commands/render.ts b/src/commands/render.ts index 5d6a5052..f3557fb9 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 { availablePlugins, type DeclaredPlugin, discoverPlugins } 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,11 +88,7 @@ export async function render( } export async function renderList(context?: SpaceContext): Promise { - const plugins: DeclaredPlugin[] = context - ? await availablePlugins(context.space?.plugins ?? {}, context.configDir) - : (await discoverPlugins()).map((plugin) => ({ plugin, rawConfig: {} })); - - const registry = buildFormatRegistry(plugins); + 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 faf0651f..6b8dc7c6 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -58,67 +58,63 @@ async function resolveExternalPlugin(name: string, configDir: string): Promise configRoot(f)); +/** The plugins a space declares: external ones from the map in declaration order, then the built-ins. */ +async function declaredPlugins( + pluginMap: Record>, + configDir: string, +): Promise { + const external: 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}")`); + } + external.push(await resolveExternalPlugin(name, configDir)); + } + return [...external, ...builtinPlugins]; } /** * Discover all available plugins: built-ins first, then any found in the plugins folder of - * each config root (by default, the roots of the loaded config files). Does not load npm plugins - * (those are only declared in space configs). + * each loaded config file's root (the main config and the configs it includes). + * Given a space's plugin map and config root, also includes the plugins the space declares, + * which may come from npm, and that config root's plugins folder. */ -export async function discoverPlugins(configDirs: string[] = configRoots()): Promise { - const plugins: StructuredContextPlugin[] = [...builtinPlugins]; - const seenNames = new Set(builtinPlugins.map((p) => p.name)); +export async function discoverPlugins(space?: { + pluginMap: Record>; + configDir: string; +}): Promise { + const plugins = space ? await declaredPlugins(space.pluginMap, space.configDir) : [...builtinPlugins]; + const seenNames = new Set(plugins.map((p) => p.name)); - for (const configDir of new Set(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 resolveExternalPlugin(entry, configDir); + if (seenNames.has(plugin.name)) continue; + plugins.push(plugin); + seenNames.add(plugin.name); } } } return plugins; } -/** A plugin with the config block a space declares for it, not yet validated. */ -export type DeclaredPlugin = { - plugin: StructuredContextPlugin; - rawConfig: Record; -}; - -/** The space's plugins: external ones from the map in declaration order, then the built-ins. */ -async function declaredPlugins( - pluginMap: Record>, - configDir: string, -): Promise { - const declared: DeclaredPlugin[] = []; - 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}")`); - } - declared.push({ plugin: await resolveExternalPlugin(name, configDir), rawConfig }); - } - for (const builtin of builtinPlugins) { - const rawConfig = pluginMap[builtin.name] ?? pluginMap[shortenPluginName(builtin.name)] ?? {}; - declared.push({ plugin: builtin, rawConfig }); - } - return declared; -} - /** - * Validate a plugin's config block against its configSchema. Fields annotated with - * format:'path' are resolved relative to configDir first. + * 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 function configurePlugin({ plugin, rawConfig }: DeclaredPlugin, configDir: string): LoadedPlugin { +export function validatePluginConfig( + plugin: StructuredContextPlugin, + pluginMap: Record>, + configDir: string, +): 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 pluginConfig = resolveConfigPaths(plugin.configSchema, rawConfig, configDir); @@ -126,11 +122,11 @@ export function configurePlugin({ plugin, rawConfig }: DeclaredPlugin, configDir if (!validate(pluginConfig)) { throw new Error(`Invalid config for plugin "${plugin.name}": ${JSON.stringify(validate.errors)}`); } - return { plugin, pluginConfig }; + return pluginConfig; } /** - * Load plugins for a space, with their config validated. + * Load the plugins a space declares, each with its validated config. * * Built-in plugins are always included (with config from the map if declared, else {}). * External plugins are loaded from the map and come first, in declaration order. @@ -140,22 +136,8 @@ export async function loadPlugins( pluginMap: Record>, configDir: string, ): Promise { - return (await declaredPlugins(pluginMap, configDir)).map((declared) => configurePlugin(declared, configDir)); -} - -/** - * Every plugin a space can use: the ones it declares, then any other discovered plugin with - * an empty config. Config is not validated, so a plugin with invalid config fails only when - * it is used (see {@link configurePlugin}). - */ -export async function availablePlugins( - pluginMap: Record>, - configDir: string, -): Promise { - const available = await declaredPlugins(pluginMap, configDir); - const names = new Set(available.map(({ plugin }) => plugin.name)); - for (const plugin of await discoverPlugins([configDir, ...configRoots()])) { - if (!names.has(plugin.name)) available.push({ plugin, rawConfig: {} }); - } - return available; + return (await declaredPlugins(pluginMap, configDir)).map((plugin) => ({ + plugin, + pluginConfig: validatePluginConfig(plugin, pluginMap, configDir), + })); } diff --git a/src/render/registry.ts b/src/render/registry.ts index add1a793..c95c65c4 100644 --- a/src/render/registry.ts +++ b/src/render/registry.ts @@ -1,11 +1,12 @@ -import type { DeclaredPlugin } 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: DeclaredPlugin; + plugin: StructuredContextPlugin; }; /** @@ -13,18 +14,20 @@ export type ResolvedFormat = { * Formats are namespaced as `{shortPluginName}.{formatName}` where * shortPluginName strips the `sctx-` prefix. */ -export function buildFormatRegistry(plugins: DeclaredPlugin[]): ResolvedFormat[] { +export function buildFormatRegistry(plugins: StructuredContextPlugin[]): ResolvedFormat[] { const registry: ResolvedFormat[] = []; - for (const lp of plugins) { - 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 11b06910..c53fedbf 100644 --- a/src/render/render.ts +++ b/src/render/render.ts @@ -1,20 +1,19 @@ import { updateSpaceField } from '../config'; import { assembleSpaceGraph } from '../load-space-graph'; -import { availablePlugins, configurePlugin } from '../plugins/loader'; +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 { validateSpace } from '../validate'; -import { buildFormatRegistry } from './registry'; +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 registry = buildFormatRegistry(await availablePlugins(pluginMap, context.configDir)); + const registry = await renderFormats(context); const entry = registry.find((r) => r.qualifiedName === formatName); if (!entry) { @@ -23,7 +22,8 @@ export async function executeRender( `Unknown render format: "${formatName}".${available ? ` Available: ${available}` : ' No formats registered.'}`, ); } - const { plugin, pluginConfig } = configurePlugin(entry.plugin, context.configDir); + const { plugin } = entry; + const pluginConfig = validatePluginConfig(plugin, context.space?.plugins ?? {}, context.configDir); const readResult = await readSpace(context); const graph = await assembleSpaceGraph(context, { filter: options.filter, hierarchy: options.hierarchy, readResult }); diff --git a/tests/render/registry.test.ts b/tests/render/registry.test.ts index 174786ed..83717baf 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 { DeclaredPlugin } 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 }[]): DeclaredPlugin { +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, rawConfig: {} }; + return plugin; } describe('buildFormatRegistry', () => { it('returns empty registry for plugins with no render hook', () => { - const loaded: DeclaredPlugin[] = [ - { plugin: { name: 'sctx-foo', configSchema: { type: 'object' } }, rawConfig: {} }, - ]; + 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: DeclaredPlugin[] = [ - { plugin: { name: 'sctx-norender', configSchema: { type: 'object' } }, rawConfig: {} }, + const loaded: StructuredContextPlugin[] = [ + { name: 'sctx-norender', configSchema: { type: 'object' } }, ...[makePlugin('sctx-markdown', [{ name: 'bullets', description: 'Bullets' }])], ]; const registry = buildFormatRegistry(loaded); From bc4bc679ab1468b5387c31b5bfab46c11aadaf32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 00:50:56 +0000 Subject: [PATCH 3/6] refactor(plugins): name the plugins a space uses spacePlugins Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XphpeFwEYm8K11SoT9ewz --- src/plugins/loader.ts | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 6b8dc7c6..de9680af 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -58,34 +58,37 @@ async function resolveExternalPlugin(name: string, configDir: string): Promise>, configDir: string, ): Promise { - const external: StructuredContextPlugin[] = []; + 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}")`); } - external.push(await resolveExternalPlugin(name, configDir)); + named.push(await resolveExternalPlugin(name, configDir)); } - return [...external, ...builtinPlugins]; + return [...named, ...builtinPlugins]; } /** - * Discover all available plugins: built-ins first, then any found in the plugins folder of + * 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's plugin map and config root, also includes the plugins the space declares, - * which may come from npm, and that config root's plugins folder. + * 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 declaredPlugins(space.pluginMap, space.configDir) : [...builtinPlugins]; + const plugins = space ? await spacePlugins(space.pluginMap, space.configDir) : [...builtinPlugins]; const seenNames = new Set(plugins.map((p) => p.name)); const configDirs = Array.from(getConfigSourceFiles()).map((f) => configRoot(f)); @@ -125,18 +128,12 @@ export function validatePluginConfig( return pluginConfig; } -/** - * Load the plugins a space declares, each with its validated config. - * - * Built-in plugins are always included (with config from the map if declared, else {}). - * External plugins are loaded from the map and come first, in declaration order. - * Resolution order for external plugins: config-adjacent → npm. - */ +/** 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 declaredPlugins(pluginMap, configDir)).map((plugin) => ({ + return (await spacePlugins(pluginMap, configDir)).map((plugin) => ({ plugin, pluginConfig: validatePluginConfig(plugin, pluginMap, configDir), })); From 2bf4742a478dc8bbc5fc8e3fb3e38949e47d52d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 00:55:13 +0000 Subject: [PATCH 4/6] docs(api): trim render hook callbacks note Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XphpeFwEYm8K11SoT9ewz --- docs/api.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/api.md b/docs/api.md index 7c0fe3c3..c52c30c4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -142,10 +142,7 @@ const myRenderHook: RenderHook = { }; ``` -A plugin in a config root's plugins folder provides its formats to every space, including spaces that don't list it in `plugins`. Its config is checked only when one of its formats is rendered. +A plugin in a config root's plugins folder renders for every space, even one that doesn't list it in `plugins`. -A render hook gets two callbacks in `context.callbacks`, so a plugin needs no runtime import of structured-context: - -- `validate(options)` validates the whole space and returns every issue, of every severity. It takes the options of `validateSpace`. Pass `semantic` only when the render run asks for it, never from plugin config, because semantic judging costs money. -- `exportGraph()` returns the graph being rendered in the [space graph format](#space-graph-format), structured by the hierarchy the render selected. +A render hook can use `context.callbacks.validate()` and `exportGraph()` instead of importing structured-context at runtime. Pass `semantic` to `validate` only when the render run asks for it, never from plugin config. From 0d79dee2b60c26ac9f8133eb8aa3078e83a91032 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 00:59:24 +0000 Subject: [PATCH 5/6] docs(api): state what the render callbacks are for Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XphpeFwEYm8K11SoT9ewz --- docs/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index c52c30c4..c848dfc1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -144,5 +144,5 @@ const myRenderHook: RenderHook = { A plugin in a config root's plugins folder renders for every space, even one that doesn't list it in `plugins`. -A render hook can use `context.callbacks.validate()` and `exportGraph()` instead of importing structured-context at runtime. Pass `semantic` to `validate` only when the render run asks for it, never from plugin config. +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. From 3bf621c6bc3a557c1398f3da967a18284880512d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:03:51 +0000 Subject: [PATCH 6/6] refactor(plugins): importPlugin; document where plugins come from Rename resolveExternalPlugin to importPlugin, and add a table to docs/config.md showing when built-in, plugins-folder and installed plugins are used. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XphpeFwEYm8K11SoT9ewz --- docs/api.md | 2 -- docs/config.md | 10 +++++++++- src/plugins/loader.ts | 11 ++++------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/api.md b/docs/api.md index c848dfc1..818fd7c7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -142,7 +142,5 @@ const myRenderHook: RenderHook = { }; ``` -A plugin in a config root's plugins folder renders for every space, even one that doesn't list it in `plugins`. - 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 0bc8c6d5..1cbe5460 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/plugins/loader.ts b/src/plugins/loader.ts index de9680af..d651c418 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`)); @@ -73,7 +70,7 @@ async function spacePlugins( if (!name.startsWith(PLUGIN_PREFIX)) { throw new Error(`Plugin name must start with "${PLUGIN_PREFIX}" (got "${rawName}")`); } - named.push(await resolveExternalPlugin(name, configDir)); + named.push(await importPlugin(name, configDir)); } return [...named, ...builtinPlugins]; } @@ -98,7 +95,7 @@ export async function discoverPlugins(space?: { const entries = readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX)); for (const entry of entries) { if (seenNames.has(entry)) continue; - const plugin = await resolveExternalPlugin(entry, configDir); + const plugin = await importPlugin(entry, configDir); if (seenNames.has(plugin.name)) continue; plugins.push(plugin); seenNames.add(plugin.name);