Files
harness-tools/extensions/workflows/meta.ts
T

238 lines
7.3 KiB
TypeScript

import {
parse,
type ArrayExpression,
type ExportNamedDeclaration,
type Expression,
type Identifier,
type Literal,
type ObjectExpression,
type Program,
type Property,
type VariableDeclaration,
} from "acorn";
/** Static workflow metadata and source preparation helpers. */
export interface WorkflowPhase {
title: string;
detail?: string;
}
export interface WorkflowMeta {
name?: string;
description?: string;
phases: WorkflowPhase[];
}
export interface PreparedWorkflowScript {
/** Script body with the metadata declaration removed. */
source: string;
meta: WorkflowMeta;
}
function parseProgram(source: string): Program {
return parse(source, {
ecmaVersion: "latest",
sourceType: "module",
allowReturnOutsideFunction: true,
});
}
function isIdentifier(node: Expression, name?: string): node is Identifier {
return (
node.type === "Identifier" && (name === undefined || node.name === name)
);
}
function isLiteral(node: Expression): node is Literal {
return node.type === "Literal";
}
function isProperty(
node: ObjectExpression["properties"][number],
): node is Property {
return node.type === "Property";
}
function propertyName(property: Property) {
if (property.computed || property.kind !== "init" || property.method) {
return undefined;
}
if (isIdentifier(property.key)) return property.key.name;
if (isLiteral(property.key) && typeof property.key.value === "string") {
return property.key.value;
}
return undefined;
}
/**
* Convert an Acorn expression to data without evaluating source. Only plain
* object/array/primitive literals are accepted. Getters, methods, spreads,
* computed keys, templates, identifiers, and calls all fail closed.
*/
function literalValue(node: Expression, depth = 0): unknown {
if (depth > 8) throw new Error("workflow metadata is nested too deeply");
if (isLiteral(node)) {
if (
node.value === null ||
typeof node.value === "string" ||
typeof node.value === "number" ||
typeof node.value === "boolean"
) {
return node.value;
}
throw new Error("workflow metadata contains an unsupported literal");
}
if (node.type === "ArrayExpression") {
const arrayNode: ArrayExpression = node;
return arrayNode.elements.map((element) => {
if (!element || element.type === "SpreadElement") {
throw new Error(
"workflow metadata arrays cannot contain holes or spreads",
);
}
return literalValue(element, depth + 1);
});
}
if (node.type === "ObjectExpression") {
const objectNode: ObjectExpression = node;
const value: Record<string, unknown> = Object.create(null);
for (const item of objectNode.properties) {
if (!isProperty(item)) {
throw new Error("workflow metadata objects cannot contain spreads");
}
const key = propertyName(item);
if (key === undefined || item.shorthand) {
throw new Error(
"workflow metadata keys and values must be plain literals",
);
}
value[key] = literalValue(item.value, depth + 1);
}
return value;
}
throw new Error("workflow metadata must contain only static literals");
}
function sanitizeMeta(value: unknown): WorkflowMeta {
const meta: WorkflowMeta = { phases: [] };
if (!value || typeof value !== "object") return meta;
const raw = value as {
name?: unknown;
description?: unknown;
phases?: unknown;
};
if (typeof raw.name === "string") meta.name = raw.name.slice(0, 160);
if (typeof raw.description === "string") {
meta.description = raw.description.slice(0, 2_000);
}
if (Array.isArray(raw.phases)) {
for (const item of raw.phases.slice(0, 64)) {
if (!item || typeof item !== "object") continue;
const phase = item as { title?: unknown; detail?: unknown };
if (typeof phase.title !== "string" || !phase.title.trim()) continue;
meta.phases.push({
title: phase.title.slice(0, 160),
...(typeof phase.detail === "string"
? { detail: phase.detail.slice(0, 2_000) }
: {}),
});
}
}
return meta;
}
function metadataDeclaration(statement: Program["body"][number]) {
if (statement.type !== "ExportNamedDeclaration") return undefined;
const exported: ExportNamedDeclaration = statement;
if (
exported.source ||
exported.specifiers.length > 0 ||
exported.declaration?.type !== "VariableDeclaration"
) {
throw new Error(
"workflow scripts may only export a static `const meta = {...}` declaration",
);
}
const declaration: VariableDeclaration = exported.declaration;
if (declaration.kind !== "const" || declaration.declarations.length !== 1) {
throw new Error(
"workflow metadata must be declared as `export const meta = {...}`",
);
}
const declarator = declaration.declarations[0];
if (
declarator.id.type !== "Identifier" ||
declarator.id.name !== "meta" ||
!declarator.init
) {
throw new Error("workflow scripts may only export a `meta` declaration");
}
return { exported, initializer: declarator.init };
}
/**
* Parse a workflow as JavaScript, reject module syntax other than the metadata
* API, statically decode metadata, and remove that declaration from the body.
* Source inside strings, comments, regexes, and templates is never rewritten.
*/
export function prepareWorkflowScript(source: string): PreparedWorkflowScript {
const program = parseProgram(source);
let meta: WorkflowMeta = { phases: [] };
let metadataRange: { start: number; end: number } | undefined;
for (const statement of program.body) {
if (statement.type === "ImportDeclaration") {
throw new Error("workflow scripts cannot use static imports");
}
if (
statement.type === "ExportDefaultDeclaration" ||
statement.type === "ExportAllDeclaration"
) {
throw new Error(
"workflow scripts may only export a static `const meta = {...}` declaration",
);
}
const declaration = metadataDeclaration(statement);
if (!declaration) continue;
if (metadataRange)
throw new Error("workflow metadata may only be declared once");
meta = sanitizeMeta(literalValue(declaration.initializer));
metadataRange = {
start: declaration.exported.start,
end: declaration.exported.end,
};
}
if (!metadataRange) return { source, meta };
// Preserve line numbers for parse/runtime errors while removing every byte of
// executable metadata. A semicolon keeps adjacent statements separated.
const removed = source.slice(metadataRange.start, metadataRange.end);
const replacement = `;${removed.slice(1).replace(/[^\n\r]/g, " ")}`;
return {
source:
source.slice(0, metadataRange.start) +
replacement +
source.slice(metadataRange.end),
meta,
};
}
const metaCache = new Map<string, WorkflowMeta>();
const META_CACHE_LIMIT = 32;
/** Safe metadata extraction for render paths. Invalid/partial scripts are empty. */
export function extractMeta(script: string): WorkflowMeta {
const cached = metaCache.get(script);
if (cached) return cached;
let meta: WorkflowMeta = { phases: [] };
try {
meta = prepareWorkflowScript(script).meta;
} catch {
// Renderers must tolerate partial model output and invalid scripts.
}
if (metaCache.size >= META_CACHE_LIMIT) metaCache.clear();
metaCache.set(script, meta);
return meta;
}