158 lines
6.7 KiB
TypeScript
158 lines
6.7 KiB
TypeScript
/**
|
|
* Plugin step-parser adapter (U12, KTD-12).
|
|
*
|
|
* Bridges plugin-contributed step parsers into core's {@link StepParserRegistry},
|
|
* mirroring {@link import("./plugin-trait-adapter.js")} for traits. Plugins
|
|
* register parsers under namespaced ids (`plugin:<pluginId>:<parserId>`) so they
|
|
* can never collide with or override the built-ins (`step-headings`,
|
|
* `json-steps`) — the registry enforces builtin-namespace protection and the
|
|
* `plugin:` id shape on registration.
|
|
*
|
|
* Contract (KTD-12): a plugin parser is `(artifactContent) => { steps }`. The
|
|
* adapter wraps each contributed parser so that:
|
|
* - a throw is re-thrown as a {@link PluginParserError} (fail-closed): the
|
|
* engine's `parse-steps` handler maps any throw to a routable
|
|
* `outcome:parse-error` (audited) — never a crash;
|
|
* - an unavailable parser (the plugin provides no usable `parse` function) is
|
|
* likewise a fail-closed throw;
|
|
* - a result that is not a `{ steps: [...] }` object is rejected (fail-closed).
|
|
*
|
|
* Timeout posture (documented deviation): the core registry's `parse` is
|
|
* synchronous (the engine handler calls it inline), so a plugin parser cannot be
|
|
* pre-empted mid-call by a timer the way an async runtime hook (trait adapter)
|
|
* can. Plugin parsers run with the same trust tier as project-local script steps
|
|
* (KTD-15 framing). The adapter therefore enforces the timeout BUDGET it is
|
|
* given by measuring wall time AROUND the synchronous call and failing closed
|
|
* (throw → parse-error) when the parser overran — the result is discarded so a
|
|
* slow parser can never silently feed a stale/partial step list. A truly
|
|
* runaway synchronous parser is a plugin bug bounded by the same posture as a
|
|
* runaway script step.
|
|
*/
|
|
|
|
import { StepParserRegistry, getStepParserRegistry } from "@fusion/core";
|
|
import type { ParsedStep, StepParseResult, StepParser } from "@fusion/core";
|
|
|
|
/** Default budget for a plugin parser invocation (ms). */
|
|
export const PLUGIN_PARSER_TIMEOUT_MS = 5_000;
|
|
|
|
/** Build the registry-facing id for a plugin parser. */
|
|
export function pluginParserRegistryId(pluginId: string, parserId: string): string {
|
|
return `plugin:${pluginId}:${parserId}`;
|
|
}
|
|
|
|
/** A plugin's step-parser contribution. `parse` is synchronous (project-local
|
|
* trust tier); the adapter wraps it fail-closed. */
|
|
export interface PluginStepParserContribution {
|
|
parserId: string;
|
|
/** `(artifactContent) => { steps }`. May throw on malformed input. */
|
|
parse: (content: string) => StepParseResult;
|
|
}
|
|
|
|
/** Fail-closed error the wrapped parser throws; the parse-steps handler maps any
|
|
* throw to a routable `outcome:parse-error` (audited). */
|
|
export class PluginParserError extends Error {
|
|
readonly parserId: string;
|
|
readonly reason: "unavailable" | "throw" | "timeout" | "bad-result";
|
|
constructor(parserId: string, reason: PluginParserError["reason"], message: string) {
|
|
super(message);
|
|
this.name = "PluginParserError";
|
|
this.parserId = parserId;
|
|
this.reason = reason;
|
|
}
|
|
}
|
|
|
|
/** Validate that a value matches the `{ steps: ParsedStep[] }` contract. */
|
|
function assertStepParseResult(registryId: string, value: unknown): StepParseResult {
|
|
if (typeof value !== "object" || value === null || !Array.isArray((value as { steps?: unknown }).steps)) {
|
|
throw new PluginParserError(registryId, "bad-result", `plugin parser '${registryId}' returned a non-{steps} result`);
|
|
}
|
|
const steps = (value as { steps: unknown[] }).steps;
|
|
for (const s of steps) {
|
|
if (typeof s !== "object" || s === null || typeof (s as { name?: unknown }).name !== "string") {
|
|
throw new PluginParserError(registryId, "bad-result", `plugin parser '${registryId}' returned a step without a string name`);
|
|
}
|
|
}
|
|
return { steps: steps as ParsedStep[] };
|
|
}
|
|
|
|
/**
|
|
* Wrap a plugin contribution into a registry {@link StepParser} (fail-closed).
|
|
* The wrapped `parse` re-throws every failure as a {@link PluginParserError};
|
|
* the engine's parse-steps handler maps the throw to `outcome:parse-error`.
|
|
*/
|
|
export function pluginParserToRegistryParser(
|
|
pluginId: string,
|
|
contribution: PluginStepParserContribution,
|
|
timeoutMs: number = PLUGIN_PARSER_TIMEOUT_MS,
|
|
): StepParser {
|
|
const registryId = pluginParserRegistryId(pluginId, contribution.parserId);
|
|
return {
|
|
id: registryId,
|
|
parse(content: string): StepParseResult {
|
|
if (typeof contribution.parse !== "function") {
|
|
throw new PluginParserError(registryId, "unavailable", `plugin parser '${registryId}' has no parse function`);
|
|
}
|
|
const started = Date.now();
|
|
let raw: StepParseResult;
|
|
try {
|
|
raw = contribution.parse(content);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw new PluginParserError(registryId, "throw", `plugin parser '${registryId}' threw: ${message}`);
|
|
}
|
|
// Wall-time budget enforcement (documented sync-timeout posture): discard a
|
|
// result produced after the budget rather than feed a stale step list.
|
|
if (Date.now() - started > timeoutMs) {
|
|
throw new PluginParserError(
|
|
registryId,
|
|
"timeout",
|
|
`plugin parser '${registryId}' exceeded ${timeoutMs}ms budget`,
|
|
);
|
|
}
|
|
return assertStepParseResult(registryId, raw);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Register a plugin's step-parser contributions into the registry. Idempotent
|
|
* per id (a re-register of an already-present id is skipped). Returns the
|
|
* registry ids registered so the caller can later unregister them. Mirrors
|
|
* {@link import("./plugin-trait-adapter.js").registerPluginTraits}.
|
|
*/
|
|
export function registerPluginStepParsers(params: {
|
|
registry?: StepParserRegistry;
|
|
pluginId: string;
|
|
contributions: PluginStepParserContribution[];
|
|
timeoutMs?: number;
|
|
}): string[] {
|
|
const registry = params.registry ?? getStepParserRegistry();
|
|
const registered: string[] = [];
|
|
for (const contribution of params.contributions) {
|
|
const parser = pluginParserToRegistryParser(params.pluginId, contribution, params.timeoutMs);
|
|
if (!registry.has(parser.id)) {
|
|
// Registration enforces the `plugin:` id shape + builtin protection.
|
|
registry.register(parser, { builtin: false });
|
|
}
|
|
registered.push(parser.id);
|
|
}
|
|
return registered;
|
|
}
|
|
|
|
/**
|
|
* Unregister a plugin's step parsers (plugin teardown / reload). Built-ins are
|
|
* never removed (the registry refuses). Returns the removed registry ids.
|
|
*/
|
|
export function unregisterPluginStepParsers(
|
|
pluginId: string,
|
|
parserIds: string[],
|
|
registry: StepParserRegistry = getStepParserRegistry(),
|
|
): string[] {
|
|
const removed: string[] = [];
|
|
for (const parserId of parserIds) {
|
|
const id = pluginParserRegistryId(pluginId, parserId);
|
|
if (registry.unregister(id)) removed.push(id);
|
|
}
|
|
return removed;
|
|
}
|