mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
77adf85ccd
* fix: never replace an in-flight indexeddb open, only a settled one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: keep a version history for session artifacts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: let the assistant browse an artifact's earlier versions Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: pick an older artifact version from the preview panel Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(ai_evals): cover the change note the assistant writes on each edit Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: bound every indexeddb open, not only one told it is blocked --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2381 lines
71 KiB
TypeScript
2381 lines
71 KiB
TypeScript
import path from "node:path";
|
|
import ts from "typescript";
|
|
import type {
|
|
AppValidationSpec,
|
|
BenchmarkCheck,
|
|
CliTrace,
|
|
CliValidationSpec,
|
|
FlowValidationSpec,
|
|
GlobalValidationSpec,
|
|
ModeRunOutput,
|
|
ToolValidationSpec,
|
|
} from "./types";
|
|
|
|
export interface ScriptState {
|
|
path: string;
|
|
lang: string;
|
|
args?: Record<string, unknown>;
|
|
code: string;
|
|
}
|
|
|
|
export interface FlowState {
|
|
path?: string;
|
|
summary?: string;
|
|
value?: {
|
|
preprocessor_module?: Record<string, unknown>;
|
|
failure_module?: Record<string, unknown>;
|
|
modules?: Array<Record<string, unknown>>;
|
|
[key: string]: unknown;
|
|
};
|
|
schema?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface AppFilesState {
|
|
frontend: Record<string, string>;
|
|
backend: Record<string, AppRunnableState>;
|
|
datatables: AppDatatableState[];
|
|
}
|
|
|
|
export interface AppRunnableState {
|
|
type?: string;
|
|
name?: string;
|
|
path?: string;
|
|
inlineScript?: {
|
|
language?: string;
|
|
content?: string;
|
|
};
|
|
}
|
|
|
|
export interface AppDatatableState {
|
|
datatable_name: string;
|
|
schemas: Record<string, Record<string, Record<string, string>>>;
|
|
error?: string;
|
|
}
|
|
|
|
export interface GlobalDraftState {
|
|
drafts: GlobalDraft[];
|
|
}
|
|
|
|
export interface GlobalDraft {
|
|
type: string;
|
|
path: string;
|
|
triggerKind?: string;
|
|
summary?: string;
|
|
language?: string;
|
|
value?: unknown;
|
|
isDraft?: boolean;
|
|
}
|
|
|
|
const TS_LIKE_LANGUAGES = new Set(["bun", "deno", "nativets", "bunnative", "ts", "typescript"]);
|
|
const CONTROL_FLOW_MODULE_TYPES = new Set(["branchone", "branchall", "forloopflow", "whileloopflow"]);
|
|
|
|
export function validateScriptState(input: {
|
|
actual: ScriptState;
|
|
initial?: ScriptState;
|
|
expected?: ScriptState;
|
|
}): BenchmarkCheck[] {
|
|
const checks: BenchmarkCheck[] = [
|
|
check("script exports entrypoint", hasSupportedEntrypoint(input.actual.code)),
|
|
check("script has no syntax errors", getScriptSyntaxErrors(input.actual.code, input.actual.lang).length === 0),
|
|
];
|
|
|
|
if (input.expected) {
|
|
checks.push(
|
|
check(
|
|
"script path matches expected",
|
|
input.actual.path === input.expected.path,
|
|
`expected ${input.expected.path}, got ${input.actual.path}`
|
|
)
|
|
);
|
|
checks.push(
|
|
check(
|
|
"script language matches expected",
|
|
input.actual.lang === input.expected.lang,
|
|
`expected ${input.expected.lang}, got ${input.actual.lang}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (input.initial) {
|
|
checks.push(
|
|
check(
|
|
"script differs from initial",
|
|
normalizeText(input.actual.code) !== normalizeText(input.initial.code)
|
|
)
|
|
);
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
export function validateFlowState(input: {
|
|
actual: FlowState;
|
|
initial?: FlowState;
|
|
expected?: FlowState;
|
|
validate?: FlowValidationSpec;
|
|
}): BenchmarkCheck[] {
|
|
const actualModules = getFlowModules(input.actual);
|
|
const placeholderModuleIds = getInlineScriptPlaceholderModuleIds(input.actual);
|
|
const checks: BenchmarkCheck[] = [
|
|
check("flow has modules", actualModules.length > 0),
|
|
check(
|
|
"flow has no inline placeholder code",
|
|
placeholderModuleIds.length === 0,
|
|
placeholderModuleIds.length > 0
|
|
? `placeholder content in: ${placeholderModuleIds.join(", ")}`
|
|
: undefined
|
|
),
|
|
];
|
|
|
|
if (input.initial) {
|
|
checks.push(
|
|
check(
|
|
"flow differs from initial",
|
|
normalizeJson(input.actual) !== normalizeJson(input.initial)
|
|
)
|
|
);
|
|
}
|
|
|
|
if (input.expected) {
|
|
checks.push(...validateFlowExpectedStructure(input.actual, input.expected));
|
|
}
|
|
|
|
if (input.validate) {
|
|
checks.push(...validateFlowRequirements(input.actual, input.validate));
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
export function validateToolExpectations(input: {
|
|
run: ModeRunOutput<unknown>;
|
|
toolExpect?: ToolValidationSpec;
|
|
}): BenchmarkCheck[] {
|
|
const expect = input.toolExpect;
|
|
if (!expect) {
|
|
return [];
|
|
}
|
|
|
|
const checks: BenchmarkCheck[] = [];
|
|
const toolCallDetails = input.run.toolCallDetails ?? [];
|
|
|
|
for (const toolName of expect.requiredToolsUsed ?? []) {
|
|
checks.push(
|
|
check(
|
|
`uses ${toolName}`,
|
|
input.run.toolsUsed.includes(toolName),
|
|
`tools used: ${input.run.toolsUsed.join(", ") || "none"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const group of expect.requiredToolsAnyOf ?? []) {
|
|
checks.push(
|
|
check(
|
|
`uses one of ${group.join(", ")}`,
|
|
group.some((toolName) => input.run.toolsUsed.includes(toolName)),
|
|
`tools used: ${input.run.toolsUsed.join(", ") || "none"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const toolName of expect.forbiddenToolsUsed ?? []) {
|
|
checks.push(
|
|
check(
|
|
`does not use ${toolName}`,
|
|
!input.run.toolsUsed.includes(toolName),
|
|
`tools used: ${input.run.toolsUsed.join(", ") || "none"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const rule of expect.toolCallArgs ?? []) {
|
|
const calls = toolCallDetails.filter((call) => call.name === rule.tool);
|
|
checks.push(
|
|
check(
|
|
`${rule.tool} was called for argument validation`,
|
|
calls.length > 0,
|
|
`tools called: ${toolCallDetails.map((call) => call.name).join(", ") || "none"}`
|
|
)
|
|
);
|
|
if (calls.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const values = calls.map((call) => getToolArgumentValue(call.arguments, rule.field));
|
|
if (rule.stringStartsWithAnyOf && rule.stringStartsWithAnyOf.length > 0) {
|
|
const invalidValues = values.filter(
|
|
(value) =>
|
|
typeof value !== "string" ||
|
|
!rule.stringStartsWithAnyOf!.some((prefix) => value.startsWith(prefix))
|
|
);
|
|
checks.push(
|
|
check(
|
|
`${rule.tool}.${rule.field} uses an accepted prefix`,
|
|
invalidValues.length === 0,
|
|
`accepted prefixes: ${rule.stringStartsWithAnyOf.join(", ")}; values: ${summarizeToolValues(values)}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (rule.stringMustNotStartWithAnyOf && rule.stringMustNotStartWithAnyOf.length > 0) {
|
|
const invalidValues = values.filter(
|
|
(value) =>
|
|
typeof value === "string" &&
|
|
rule.stringMustNotStartWithAnyOf!.some((prefix) => value.startsWith(prefix))
|
|
);
|
|
checks.push(
|
|
check(
|
|
`${rule.tool}.${rule.field} avoids rejected prefixes`,
|
|
invalidValues.length === 0,
|
|
`rejected prefixes: ${rule.stringMustNotStartWithAnyOf.join(", ")}; values: ${summarizeToolValues(values)}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (rule.nonEmpty) {
|
|
const blankValues = values.filter(
|
|
(value) => typeof value !== "string" || value.trim().length === 0
|
|
);
|
|
checks.push(
|
|
check(
|
|
`${rule.tool}.${rule.field} is filled in on every call`,
|
|
blankValues.length === 0,
|
|
`blank on ${blankValues.length} of ${values.length} call(s); values: ${summarizeToolValues(values)}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (rule.stringIncludesAnyOf && rule.stringIncludesAnyOf.length > 0) {
|
|
// Existential: at least one call must contain one of the substrings.
|
|
// Other calls to the same tool may do anything — this suits SQL, where a
|
|
// model mixes the requested statement (e.g. an UPDATE) with verification
|
|
// SELECTs that would otherwise fail an "all calls" check.
|
|
const needles = rule.stringIncludesAnyOf.map((needle) => needle.toLowerCase());
|
|
// Array-valued fields (e.g. open_page.items) match on any element.
|
|
const haystacks = (value: unknown): string[] =>
|
|
typeof value === "string"
|
|
? [value]
|
|
: Array.isArray(value)
|
|
? value.filter((v): v is string => typeof v === "string")
|
|
: [];
|
|
const hasMatch = values.some((value) =>
|
|
haystacks(value).some((hay) => needles.some((needle) => hay.toLowerCase().includes(needle)))
|
|
);
|
|
checks.push(
|
|
check(
|
|
`${rule.tool}.${rule.field} includes a required substring`,
|
|
hasMatch,
|
|
`accepted substrings: ${rule.stringIncludesAnyOf.join(", ")}; values: ${summarizeToolValues(values)}`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
export function validateGlobalState(input: {
|
|
actual: GlobalDraftState;
|
|
expected?: GlobalDraftState;
|
|
validate?: GlobalValidationSpec;
|
|
}): BenchmarkCheck[] {
|
|
const drafts = input.actual.drafts ?? [];
|
|
const checks: BenchmarkCheck[] = [];
|
|
|
|
// Read-only global cases are valid; only enforce draft production when the
|
|
// case explicitly asks for draft output.
|
|
if (globalValidationExpectsDrafts(input)) {
|
|
checks.push(
|
|
check(
|
|
"global produced at least one draft",
|
|
drafts.length > 0,
|
|
`drafts=${drafts.length}`
|
|
)
|
|
);
|
|
}
|
|
|
|
checks.push(
|
|
check(
|
|
"all global outputs are drafts",
|
|
drafts.every((draft) => draft.isDraft === true),
|
|
summarizeGlobalDrafts(drafts)
|
|
)
|
|
);
|
|
|
|
for (const draft of drafts) {
|
|
if (draft.type !== "script" || typeof draft.value !== "string") {
|
|
continue;
|
|
}
|
|
|
|
const language = (draft.language ?? "bun").toLowerCase();
|
|
const syntaxErrors = getScriptSyntaxErrors(draft.value, language);
|
|
if (TS_LIKE_LANGUAGES.has(language)) {
|
|
checks.push(
|
|
check(
|
|
`script draft ${draft.path} exports entrypoint`,
|
|
hasSupportedEntrypoint(draft.value)
|
|
)
|
|
);
|
|
}
|
|
checks.push(
|
|
check(
|
|
`script draft ${draft.path} has no syntax errors`,
|
|
syntaxErrors.length === 0,
|
|
summarizeProblems(syntaxErrors)
|
|
)
|
|
);
|
|
}
|
|
|
|
if (input.expected) {
|
|
checks.push(
|
|
check(
|
|
"global drafts match expected",
|
|
globalDraftStatesEqual(input.actual, input.expected),
|
|
describeGlobalDraftStateMismatch(input.actual, input.expected)
|
|
)
|
|
);
|
|
}
|
|
|
|
const validate = input.validate;
|
|
if (!validate) {
|
|
return checks;
|
|
}
|
|
|
|
if (validate.draftCountAtLeast !== undefined) {
|
|
checks.push(
|
|
check(
|
|
`global includes at least ${validate.draftCountAtLeast} draft(s)`,
|
|
drafts.length >= validate.draftCountAtLeast,
|
|
`drafts=${drafts.length}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (validate.draftCountExactly !== undefined) {
|
|
checks.push(
|
|
check(
|
|
`global includes exactly ${validate.draftCountExactly} draft(s)`,
|
|
drafts.length === validate.draftCountExactly,
|
|
`drafts=${drafts.length}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const required of validate.requiredDrafts ?? []) {
|
|
const requirementLabel = formatGlobalDraftRequirement(required);
|
|
const draft = findGlobalDraft(drafts, required);
|
|
checks.push(
|
|
check(
|
|
`global includes ${requirementLabel}`,
|
|
Boolean(draft),
|
|
summarizeGlobalDrafts(drafts)
|
|
)
|
|
);
|
|
if (!draft) {
|
|
continue;
|
|
}
|
|
|
|
if (required.language !== undefined) {
|
|
checks.push(
|
|
check(
|
|
`${requirementLabel} uses ${required.language}`,
|
|
draft.language === required.language,
|
|
`language=${draft.language ?? "(none)"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const snippet of required.summaryIncludes ?? []) {
|
|
checks.push(
|
|
check(
|
|
`${requirementLabel} summary includes '${snippet}'`,
|
|
normalizeText(draft.summary ?? "").includes(normalizeText(snippet)),
|
|
`summary=${draft.summary ?? ""}`
|
|
)
|
|
);
|
|
}
|
|
|
|
const valueText = stringifyGlobalDraftValue(draft.value);
|
|
for (const snippet of required.valueIncludes ?? []) {
|
|
checks.push(
|
|
check(
|
|
`${requirementLabel} value includes '${snippet}'`,
|
|
normalizeText(valueText).includes(normalizeText(snippet)),
|
|
truncateForDetails(valueText)
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const snippet of required.valueExcludes ?? []) {
|
|
checks.push(
|
|
check(
|
|
`${requirementLabel} value excludes '${snippet}'`,
|
|
!normalizeText(valueText).includes(normalizeText(snippet)),
|
|
truncateForDetails(valueText)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const forbidden of validate.forbiddenDrafts ?? []) {
|
|
checks.push(
|
|
check(
|
|
`global does not include ${forbidden.type} draft ${forbidden.path}`,
|
|
!findGlobalDraft(drafts, forbidden),
|
|
summarizeGlobalDrafts(drafts)
|
|
)
|
|
);
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
export function validateAppState(input: {
|
|
actual: AppFilesState;
|
|
initial?: AppFilesState;
|
|
expected?: AppFilesState;
|
|
validate?: AppValidationSpec;
|
|
toolsUsed?: string[];
|
|
}): BenchmarkCheck[] {
|
|
const checks: BenchmarkCheck[] = [];
|
|
const frontendEntries = Object.entries(input.actual.frontend ?? {});
|
|
const backendEntries = Object.entries(input.actual.backend ?? {});
|
|
const frontendSyntaxProblems = getAppFrontendSyntaxProblems(input.actual.frontend);
|
|
const backendSyntaxProblems = getAppBackendSyntaxProblems(input.actual.backend);
|
|
const unresolvedBackendRefs = getUnresolvedBackendReferences(
|
|
input.actual.frontend,
|
|
input.actual.backend
|
|
);
|
|
|
|
checks.push(check("app has frontend entrypoint", Boolean(input.actual.frontend["/index.tsx"])));
|
|
checks.push(
|
|
check(
|
|
"app has non-empty frontend files",
|
|
frontendEntries.some(([, content]) => content.trim().length > 0)
|
|
)
|
|
);
|
|
checks.push(
|
|
check(
|
|
"frontend files have no syntax errors",
|
|
frontendSyntaxProblems.length === 0,
|
|
summarizeProblems(frontendSyntaxProblems)
|
|
)
|
|
);
|
|
checks.push(
|
|
check(
|
|
"backend inline scripts have entrypoints",
|
|
backendEntries.every(([, runnable]) => {
|
|
if (runnable.type !== "inline") {
|
|
return true;
|
|
}
|
|
return hasSupportedEntrypoint(runnable.inlineScript?.content ?? "");
|
|
})
|
|
)
|
|
);
|
|
checks.push(
|
|
check(
|
|
"backend inline scripts have no syntax errors",
|
|
backendSyntaxProblems.length === 0,
|
|
summarizeProblems(backendSyntaxProblems)
|
|
)
|
|
);
|
|
checks.push(
|
|
check(
|
|
"frontend backend references resolve",
|
|
unresolvedBackendRefs.length === 0,
|
|
summarizeProblems(unresolvedBackendRefs)
|
|
)
|
|
);
|
|
|
|
if (input.initial) {
|
|
checks.push(check("app differs from initial", !appStatesEqual(input.actual, input.initial)));
|
|
}
|
|
|
|
if (input.expected) {
|
|
for (const [filePath, content] of Object.entries(input.expected.frontend)) {
|
|
checks.push(
|
|
check(
|
|
`frontend includes ${filePath}`,
|
|
normalizeText(input.actual.frontend[filePath] ?? "") === normalizeText(content)
|
|
)
|
|
);
|
|
}
|
|
for (const [runnableName, runnable] of Object.entries(input.expected.backend)) {
|
|
const actualRunnable = input.actual.backend[runnableName];
|
|
checks.push(check(`backend includes ${runnableName}`, Boolean(actualRunnable)));
|
|
if (actualRunnable && runnable.inlineScript?.content) {
|
|
checks.push(
|
|
check(
|
|
`${runnableName} code matches expected`,
|
|
normalizeText(actualRunnable.inlineScript?.content ?? "") ===
|
|
normalizeText(runnable.inlineScript.content)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const expectedDatatable of input.expected.datatables ?? []) {
|
|
const expectedSchemas = Object.entries(expectedDatatable.schemas ?? {});
|
|
if (expectedSchemas.length === 0) {
|
|
checks.push(
|
|
check(
|
|
`datatable ${expectedDatatable.datatable_name} exists`,
|
|
(input.actual.datatables ?? []).some(
|
|
(datatable) => datatable.datatable_name === expectedDatatable.datatable_name
|
|
)
|
|
)
|
|
);
|
|
continue;
|
|
}
|
|
|
|
for (const [schemaName, tables] of expectedSchemas) {
|
|
const tableNames = Object.keys(tables ?? {});
|
|
for (const tableName of tableNames) {
|
|
checks.push(
|
|
check(
|
|
`datatable table ${expectedDatatable.datatable_name}/${schemaName}.${tableName} exists`,
|
|
hasDatatableTable(input.actual.datatables ?? [], {
|
|
datatableName: expectedDatatable.datatable_name,
|
|
schema: schemaName,
|
|
table: tableName,
|
|
})
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (input.validate) {
|
|
checks.push(...validateAppRequirements(input.actual, input.validate, input.toolsUsed ?? []));
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
export function validateCliWorkspace(input: {
|
|
actualFiles: Record<string, string>;
|
|
expectedFiles?: Record<string, string>;
|
|
initialFiles?: Record<string, string>;
|
|
assistantOutput?: string;
|
|
trace?: CliTrace;
|
|
cliExpect?: CliValidationSpec;
|
|
}): BenchmarkCheck[] {
|
|
const checks: BenchmarkCheck[] = [];
|
|
|
|
if (input.expectedFiles) {
|
|
for (const [filePath, expectedContent] of Object.entries(input.expectedFiles)) {
|
|
const actualContent = input.actualFiles[filePath];
|
|
checks.push(check(`creates ${filePath}`, actualContent !== undefined));
|
|
if (actualContent !== undefined) {
|
|
checks.push(
|
|
check(
|
|
`${filePath} contains expected content`,
|
|
cliFileContainsExpectedContent(actualContent, expectedContent)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
const expectedPaths = new Set(Object.keys(input.expectedFiles));
|
|
const unexpectedPaths = Object.keys(input.actualFiles).filter((filePath) => !expectedPaths.has(filePath));
|
|
checks.push(
|
|
check(
|
|
"workspace contains no unexpected files",
|
|
unexpectedPaths.length === 0,
|
|
summarizeProblems(unexpectedPaths)
|
|
)
|
|
);
|
|
}
|
|
|
|
if (input.cliExpect?.workspaceUnchanged) {
|
|
const baselineFiles = input.initialFiles ?? {};
|
|
checks.push(
|
|
check(
|
|
"workspace remains unchanged",
|
|
fileMapsEqual(input.actualFiles, baselineFiles),
|
|
summarizeWorkspaceDiff(input.actualFiles, baselineFiles)
|
|
)
|
|
);
|
|
} else if (input.initialFiles) {
|
|
checks.push(check("workspace differs from initial", !fileMapsEqual(input.actualFiles, input.initialFiles)));
|
|
}
|
|
|
|
if (input.cliExpect) {
|
|
checks.push(
|
|
...validateCliExpectations(input.assistantOutput ?? "", input.trace, input.cliExpect)
|
|
);
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
function cliFileContainsExpectedContent(actualContent: string, expectedContent: string): boolean {
|
|
const expectedSnippets = expectedContent
|
|
.replace(/\r\n/g, "\n")
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0);
|
|
|
|
if (expectedSnippets.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const normalizedActual = actualContent.replace(/\r\n/g, "\n");
|
|
|
|
return expectedSnippets.every((snippet) => normalizedActual.includes(snippet));
|
|
}
|
|
|
|
function check(name: string, passed: boolean, details?: string): BenchmarkCheck {
|
|
return !passed && details ? { name, passed, details } : { name, passed };
|
|
}
|
|
|
|
function getToolArgumentValue(args: unknown, dottedPath: string): unknown {
|
|
if (!isObjectRecord(args)) {
|
|
return undefined;
|
|
}
|
|
return getValueAtPath(args, dottedPath);
|
|
}
|
|
|
|
function summarizeToolValues(values: unknown[]): string {
|
|
return values.map((value) => JSON.stringify(value)).join(", ") || "none";
|
|
}
|
|
|
|
function normalizeText(value: string): string {
|
|
return value.replace(/\r\n/g, "\n").trim();
|
|
}
|
|
|
|
function normalizeJson(value: unknown): string {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function summarizeProblems(problems: string[], limit = 5): string | undefined {
|
|
if (problems.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
if (problems.length <= limit) {
|
|
return problems.join("; ");
|
|
}
|
|
|
|
return `${problems.slice(0, limit).join("; ")}; ...and ${problems.length - limit} more`;
|
|
}
|
|
|
|
function findGlobalDraft(
|
|
drafts: GlobalDraft[],
|
|
requirement: {
|
|
type: string;
|
|
path?: string;
|
|
pathIncludes?: string[];
|
|
pathStartsWith?: string;
|
|
triggerKind?: string;
|
|
summaryIncludes?: string[];
|
|
valueIncludes?: string[];
|
|
valueExcludes?: string[];
|
|
}
|
|
): GlobalDraft | undefined {
|
|
const candidates = drafts.filter((draft) =>
|
|
globalDraftMatchesLocator(draft, requirement)
|
|
);
|
|
return (
|
|
candidates.find((draft) => globalDraftMatchesContent(draft, requirement)) ??
|
|
candidates[0]
|
|
);
|
|
}
|
|
|
|
function globalDraftMatchesLocator(
|
|
draft: GlobalDraft,
|
|
requirement: {
|
|
type: string;
|
|
path?: string;
|
|
pathIncludes?: string[];
|
|
pathStartsWith?: string;
|
|
triggerKind?: string;
|
|
}
|
|
): boolean {
|
|
return (
|
|
draft.type === requirement.type &&
|
|
(requirement.path === undefined || draft.path === requirement.path) &&
|
|
(requirement.pathStartsWith === undefined ||
|
|
draft.path.startsWith(requirement.pathStartsWith)) &&
|
|
(requirement.pathIncludes ?? []).every((snippet) =>
|
|
normalizeText(draft.path).includes(normalizeText(snippet))
|
|
) &&
|
|
(requirement.triggerKind === undefined ||
|
|
draft.triggerKind === requirement.triggerKind)
|
|
);
|
|
}
|
|
|
|
function globalDraftMatchesContent(
|
|
draft: GlobalDraft,
|
|
requirement: {
|
|
summaryIncludes?: string[];
|
|
valueIncludes?: string[];
|
|
valueExcludes?: string[];
|
|
}
|
|
): boolean {
|
|
const summary = normalizeText(draft.summary ?? "");
|
|
const value = normalizeText(stringifyGlobalDraftValue(draft.value));
|
|
return (
|
|
(requirement.summaryIncludes ?? []).every((snippet) =>
|
|
summary.includes(normalizeText(snippet))
|
|
) &&
|
|
(requirement.valueIncludes ?? []).every((snippet) =>
|
|
value.includes(normalizeText(snippet))
|
|
) &&
|
|
(requirement.valueExcludes ?? []).every(
|
|
(snippet) => !value.includes(normalizeText(snippet))
|
|
)
|
|
);
|
|
}
|
|
|
|
function formatGlobalDraftRequirement(
|
|
requirement: {
|
|
type: string;
|
|
path?: string;
|
|
pathIncludes?: string[];
|
|
pathStartsWith?: string;
|
|
triggerKind?: string;
|
|
}
|
|
): string {
|
|
const typeLabel =
|
|
requirement.triggerKind === undefined
|
|
? requirement.type
|
|
: `${requirement.triggerKind} ${requirement.type}`;
|
|
if (requirement.path !== undefined) {
|
|
return `${typeLabel} draft ${requirement.path}`;
|
|
}
|
|
|
|
const filters = [
|
|
...(requirement.pathStartsWith === undefined
|
|
? []
|
|
: [`path starts with ${requirement.pathStartsWith}`]),
|
|
...(requirement.pathIncludes ?? []).map(
|
|
(snippet) => `path includes ${snippet}`
|
|
),
|
|
];
|
|
return filters.length === 0
|
|
? `${typeLabel} draft`
|
|
: `${typeLabel} draft (${filters.join(", ")})`;
|
|
}
|
|
|
|
function summarizeGlobalDrafts(drafts: GlobalDraft[]): string {
|
|
const summary = drafts
|
|
.map((draft) => formatGlobalDraftKey(draft))
|
|
.join(", ");
|
|
return `drafts: ${summary || "none"}`;
|
|
}
|
|
|
|
function formatGlobalDraftKey(draft: GlobalDraft): string {
|
|
return `${draft.type}${draft.triggerKind ? `:${draft.triggerKind}` : ""}:${draft.path}`;
|
|
}
|
|
|
|
function globalValidationExpectsDrafts(input: {
|
|
expected?: GlobalDraftState;
|
|
validate?: GlobalValidationSpec;
|
|
}): boolean {
|
|
const validate = input.validate;
|
|
return (
|
|
(input.expected?.drafts?.length ?? 0) > 0 ||
|
|
(validate?.requiredDrafts?.length ?? 0) > 0 ||
|
|
(validate?.draftCountAtLeast ?? 0) > 0 ||
|
|
(validate?.draftCountExactly ?? 0) > 0
|
|
);
|
|
}
|
|
|
|
function globalDraftStatesEqual(left: GlobalDraftState, right: GlobalDraftState): boolean {
|
|
return (
|
|
JSON.stringify(canonicalizeGlobalDrafts(left.drafts ?? [])) ===
|
|
JSON.stringify(canonicalizeGlobalDrafts(right.drafts ?? []))
|
|
);
|
|
}
|
|
|
|
function describeGlobalDraftStateMismatch(
|
|
actual: GlobalDraftState,
|
|
expected: GlobalDraftState
|
|
): string {
|
|
const actualDrafts = actual.drafts ?? [];
|
|
const expectedDrafts = expected.drafts ?? [];
|
|
const actualByKey = new Map(
|
|
actualDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const)
|
|
);
|
|
const expectedByKey = new Map(
|
|
expectedDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const)
|
|
);
|
|
|
|
for (const key of Array.from(expectedByKey.keys()).sort()) {
|
|
const expectedDraft = expectedByKey.get(key);
|
|
if (expectedDraft && !actualByKey.has(key)) {
|
|
return `missing expected draft ${formatGlobalDraftKey(expectedDraft)}; actual=${summarizeGlobalDrafts(actualDrafts)}`;
|
|
}
|
|
}
|
|
|
|
for (const key of Array.from(actualByKey.keys()).sort()) {
|
|
const actualDraft = actualByKey.get(key);
|
|
if (actualDraft && !expectedByKey.has(key)) {
|
|
return `unexpected draft ${formatGlobalDraftKey(actualDraft)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`;
|
|
}
|
|
}
|
|
|
|
for (const key of Array.from(expectedByKey.keys()).sort()) {
|
|
const actualDraft = actualByKey.get(key);
|
|
const expectedDraft = expectedByKey.get(key);
|
|
if (!actualDraft || !expectedDraft) {
|
|
continue;
|
|
}
|
|
|
|
const fieldMismatch = describeGlobalDraftFieldMismatch(
|
|
formatGlobalDraftKey(expectedDraft),
|
|
actualDraft,
|
|
expectedDraft
|
|
);
|
|
if (fieldMismatch) {
|
|
return fieldMismatch;
|
|
}
|
|
}
|
|
|
|
return `actual=${summarizeGlobalDrafts(actualDrafts)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`;
|
|
}
|
|
|
|
function describeGlobalDraftFieldMismatch(
|
|
key: string,
|
|
actual: GlobalDraft,
|
|
expected: GlobalDraft
|
|
): string | undefined {
|
|
const fields: Array<"language" | "summary" | "value" | "isDraft"> = [
|
|
"language",
|
|
"summary",
|
|
"value",
|
|
"isDraft",
|
|
];
|
|
|
|
for (const field of fields) {
|
|
const actualValue = comparableGlobalDraftFieldValue(actual, field);
|
|
const expectedValue = comparableGlobalDraftFieldValue(expected, field);
|
|
if (JSON.stringify(actualValue) === JSON.stringify(expectedValue)) {
|
|
continue;
|
|
}
|
|
|
|
return `${key} ${field} differs: actual=${formatGlobalDraftFieldValue(
|
|
actualValue
|
|
)}; expected=${formatGlobalDraftFieldValue(expectedValue)}`;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function comparableGlobalDraftFieldValue(
|
|
draft: GlobalDraft,
|
|
field: "language" | "summary" | "value" | "isDraft"
|
|
): unknown {
|
|
if (field === "summary" && typeof draft.summary === "string") {
|
|
return normalizeText(draft.summary);
|
|
}
|
|
if (field === "value" && typeof draft.value === "string") {
|
|
return normalizeText(draft.value);
|
|
}
|
|
if (field === "value") {
|
|
return canonicalizeJsonValue(draft.value);
|
|
}
|
|
return draft[field];
|
|
}
|
|
|
|
function formatGlobalDraftFieldValue(value: unknown): string {
|
|
if (value === undefined) {
|
|
return "(missing)";
|
|
}
|
|
return truncateForDetails(JSON.stringify(value), 300);
|
|
}
|
|
|
|
function canonicalizeGlobalDrafts(drafts: GlobalDraft[]): unknown[] {
|
|
return drafts
|
|
.slice()
|
|
.sort((left, right) => globalDraftSortKey(left).localeCompare(globalDraftSortKey(right)))
|
|
.map((draft) =>
|
|
canonicalizeJsonValue({
|
|
type: draft.type,
|
|
path: draft.path,
|
|
triggerKind: draft.triggerKind,
|
|
language: draft.language,
|
|
summary:
|
|
typeof draft.summary === "string" ? normalizeText(draft.summary) : draft.summary,
|
|
value:
|
|
typeof draft.value === "string"
|
|
? normalizeText(draft.value)
|
|
: canonicalizeJsonValue(draft.value),
|
|
isDraft: draft.isDraft,
|
|
})
|
|
);
|
|
}
|
|
|
|
function globalDraftSortKey(draft: GlobalDraft): string {
|
|
return `${draft.type}:${draft.triggerKind ?? ""}:${draft.path}`;
|
|
}
|
|
|
|
function canonicalizeJsonValue(value: unknown): unknown {
|
|
if (Array.isArray(value)) {
|
|
return value.map(canonicalizeJsonValue);
|
|
}
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value as Record<string, unknown>)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, nested]) => [key, canonicalizeJsonValue(nested)])
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function stringifyGlobalDraftValue(value: unknown): string {
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
return JSON.stringify(value ?? null, null, 2);
|
|
}
|
|
|
|
function truncateForDetails(value: string, maxLength = 500): string {
|
|
const normalized = value.replace(/\s+/g, " ").trim();
|
|
if (normalized.length <= maxLength) {
|
|
return normalized;
|
|
}
|
|
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
}
|
|
|
|
function validateCliExpectations(
|
|
assistantOutput: string,
|
|
trace: CliTrace | undefined,
|
|
cliExpect: CliValidationSpec
|
|
): BenchmarkCheck[] {
|
|
const checks: BenchmarkCheck[] = [];
|
|
|
|
if (!trace) {
|
|
checks.push(check("cli trace is available", false));
|
|
return checks;
|
|
}
|
|
|
|
for (const skillName of cliExpect.requiredSkills ?? []) {
|
|
checks.push(
|
|
check(
|
|
`invokes skill ${skillName}`,
|
|
cliSkillWasInvoked(trace, skillName),
|
|
`skills=${trace.skillsInvoked.join(", ") || "(none)"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const skillName of cliExpect.forbiddenSkills ?? []) {
|
|
checks.push(
|
|
check(
|
|
`does not invoke skill ${skillName}`,
|
|
!cliSkillWasInvoked(trace, skillName),
|
|
`skills=${trace.skillsInvoked.join(", ") || "(none)"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const skillName of cliExpect.requiredSkillsBeforeFirstMutation ?? []) {
|
|
const firstSkillIndex = getFirstSkillToolIndex(trace, skillName);
|
|
const firstMutationIndex = trace.firstMutationToolIndex;
|
|
checks.push(
|
|
check(
|
|
`invokes skill ${skillName} before first mutation`,
|
|
firstSkillIndex !== null &&
|
|
firstMutationIndex !== null &&
|
|
firstSkillIndex < firstMutationIndex,
|
|
`firstSkillIndex=${firstSkillIndex ?? "none"}; firstMutationIndex=${firstMutationIndex ?? "none"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const phrase of cliExpect.requiredAssistantMentions ?? []) {
|
|
checks.push(
|
|
check(
|
|
`assistant mentions ${phrase}`,
|
|
assistantMentions(assistantOutput, phrase)
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const phrase of cliExpect.forbiddenAssistantMentions ?? []) {
|
|
checks.push(
|
|
check(
|
|
`assistant does not mention ${phrase}`,
|
|
!assistantMentions(assistantOutput, phrase)
|
|
)
|
|
);
|
|
}
|
|
|
|
if ((cliExpect.orderedAssistantMentions?.length ?? 0) > 0) {
|
|
checks.push(
|
|
check(
|
|
"assistant mentions expected items in order",
|
|
stringsAppearInOrder(assistantOutput, cliExpect.orderedAssistantMentions!),
|
|
`ordered=${cliExpect.orderedAssistantMentions!.join(" -> ")}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const command of cliExpect.requiredProposedCommands ?? []) {
|
|
checks.push(
|
|
check(
|
|
`assistant proposes ${command}`,
|
|
commandListContains(trace.proposedCommands, command),
|
|
`proposed=${trace.proposedCommands.join("; ") || "(none)"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const command of cliExpect.forbiddenProposedCommands ?? []) {
|
|
checks.push(
|
|
check(
|
|
`assistant does not propose ${command}`,
|
|
!commandListContains(trace.proposedCommands, command),
|
|
`proposed=${trace.proposedCommands.join("; ") || "(none)"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if ((cliExpect.orderedProposedCommands?.length ?? 0) > 0) {
|
|
checks.push(
|
|
check(
|
|
"assistant proposes expected commands in order",
|
|
stringsAppearInOrder(trace.proposedCommands.join("\n"), cliExpect.orderedProposedCommands!),
|
|
`ordered=${cliExpect.orderedProposedCommands!.join(" -> ")}; proposed=${trace.proposedCommands.join("; ") || "(none)"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const pattern of cliExpect.forbiddenExecutedCommands ?? []) {
|
|
checks.push(
|
|
check(
|
|
`does not execute ${pattern}`,
|
|
!trace.executedWmillCommands.some((command) => matchesCommandPattern(command, pattern)),
|
|
`executed=${trace.executedWmillCommands.join("; ") || "(none)"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
function cliSkillWasInvoked(trace: CliTrace, skillName: string): boolean {
|
|
return trace.skillsInvoked.some((skill) => skill === skillName);
|
|
}
|
|
|
|
function getFirstSkillToolIndex(trace: CliTrace, skillName: string): number | null {
|
|
for (const [index, tool] of trace.toolsUsed.entries()) {
|
|
if (tool.tool !== "Skill") {
|
|
continue;
|
|
}
|
|
|
|
const inputSkill = typeof tool.input.skill === "string" ? tool.input.skill : null;
|
|
if (inputSkill === skillName) {
|
|
return index;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function assistantMentions(output: string, phrase: string): boolean {
|
|
return output.toLowerCase().includes(phrase.toLowerCase());
|
|
}
|
|
|
|
function stringsAppearInOrder(output: string, phrases: string[]): boolean {
|
|
const normalizedOutput = output.toLowerCase();
|
|
let startIndex = 0;
|
|
|
|
for (const phrase of phrases) {
|
|
const index = normalizedOutput.indexOf(phrase.toLowerCase(), startIndex);
|
|
if (index === -1) {
|
|
return false;
|
|
}
|
|
startIndex = index + phrase.length;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function commandListContains(commands: string[], command: string): boolean {
|
|
const normalizedNeedle = command.toLowerCase();
|
|
return commands.some((entry) => entry.toLowerCase().includes(normalizedNeedle));
|
|
}
|
|
|
|
function matchesCommandPattern(command: string, pattern: string): boolean {
|
|
try {
|
|
return new RegExp(pattern, "i").test(command);
|
|
} catch {
|
|
return command.toLowerCase().includes(pattern.toLowerCase());
|
|
}
|
|
}
|
|
|
|
function summarizeWorkspaceDiff(
|
|
actualFiles: Record<string, string>,
|
|
baselineFiles: Record<string, string>
|
|
): string | undefined {
|
|
const changes: string[] = [];
|
|
|
|
for (const filePath of Object.keys(actualFiles)) {
|
|
if (!(filePath in baselineFiles)) {
|
|
changes.push(`added ${filePath}`);
|
|
} else if (actualFiles[filePath] !== baselineFiles[filePath]) {
|
|
changes.push(`changed ${filePath}`);
|
|
}
|
|
}
|
|
|
|
for (const filePath of Object.keys(baselineFiles)) {
|
|
if (!(filePath in actualFiles)) {
|
|
changes.push(`removed ${filePath}`);
|
|
}
|
|
}
|
|
|
|
return summarizeProblems(changes);
|
|
}
|
|
|
|
function hasSupportedEntrypoint(code: string): boolean {
|
|
return (
|
|
/export\s+(async\s+)?function\s+main\s*\(/.test(code) ||
|
|
/export\s+default\s+(async\s+)?function\s*\(/.test(code)
|
|
);
|
|
}
|
|
|
|
function getScriptSyntaxErrors(code: string, lang: string): string[] {
|
|
if (!TS_LIKE_LANGUAGES.has(lang)) {
|
|
return [];
|
|
}
|
|
|
|
return getTypeScriptSyntaxErrors(code, "eval.ts");
|
|
}
|
|
|
|
function getTypeScriptSyntaxErrors(code: string, fileName: string): string[] {
|
|
const result = ts.transpileModule(code, {
|
|
compilerOptions: {
|
|
target: ts.ScriptTarget.ES2022,
|
|
module: ts.ModuleKind.ESNext,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
},
|
|
reportDiagnostics: true,
|
|
fileName,
|
|
});
|
|
|
|
return (result.diagnostics ?? []).map((diagnostic) =>
|
|
ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")
|
|
);
|
|
}
|
|
|
|
function getAppFrontendSyntaxProblems(frontend: Record<string, string>): string[] {
|
|
const problems: string[] = [];
|
|
|
|
for (const [filePath, content] of Object.entries(frontend)) {
|
|
if (!isFrontendCodeFile(filePath)) {
|
|
continue;
|
|
}
|
|
|
|
const errors = getTypeScriptSyntaxErrors(content, filePath);
|
|
for (const error of errors) {
|
|
problems.push(`${filePath}: ${error}`);
|
|
}
|
|
}
|
|
|
|
return problems;
|
|
}
|
|
|
|
function getAppBackendSyntaxProblems(backend: Record<string, AppRunnableState>): string[] {
|
|
const problems: string[] = [];
|
|
|
|
for (const [key, runnable] of Object.entries(backend)) {
|
|
if (runnable.type !== "inline") {
|
|
continue;
|
|
}
|
|
|
|
const language = runnable.inlineScript?.language ?? "";
|
|
const content = runnable.inlineScript?.content ?? "";
|
|
for (const error of getScriptSyntaxErrors(content, language)) {
|
|
problems.push(`${key}: ${error}`);
|
|
}
|
|
}
|
|
|
|
return problems;
|
|
}
|
|
|
|
function isFrontendCodeFile(filePath: string): boolean {
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
return extension === ".ts" || extension === ".tsx" || extension === ".js" || extension === ".jsx";
|
|
}
|
|
|
|
function getUnresolvedBackendReferences(
|
|
frontend: Record<string, string>,
|
|
backend: Record<string, AppRunnableState>
|
|
): string[] {
|
|
const backendKeys = new Set(Object.keys(backend));
|
|
const unresolved = new Set<string>();
|
|
|
|
for (const [filePath, content] of Object.entries(frontend)) {
|
|
for (const key of extractBackendCallKeys(content)) {
|
|
if (!backendKeys.has(key)) {
|
|
unresolved.add(`${filePath} references missing backend.${key}()`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...unresolved];
|
|
}
|
|
|
|
function extractBackendCallKeys(content: string): string[] {
|
|
const matches = content.matchAll(/\bbackend\.([A-Za-z_][A-Za-z0-9_]*)\s*\(/g);
|
|
return [...new Set([...matches].map((match) => match[1]))];
|
|
}
|
|
|
|
function getFlowModules(flow: FlowState): Array<Record<string, unknown>> {
|
|
return Array.isArray(flow.value?.modules) ? flow.value.modules : [];
|
|
}
|
|
|
|
function validateFlowExpectedStructure(
|
|
actual: FlowState,
|
|
expected: FlowState
|
|
): BenchmarkCheck[] {
|
|
const checks: BenchmarkCheck[] = [];
|
|
const expectedTopLevelModules = getFlowModules(expected);
|
|
const actualTopLevelModules = getFlowModules(actual);
|
|
|
|
const expectedSchemaFields = getTopLevelSchemaFields(expected.schema);
|
|
if (expectedSchemaFields.length > 0) {
|
|
checks.push(
|
|
check(
|
|
"flow schema includes expected top-level fields",
|
|
expectedSchemaFields.every((field) => hasSchemaPath(actual.schema, field)),
|
|
`missing one of: ${expectedSchemaFields.join(", ")}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (expectedTopLevelModules.length > 0) {
|
|
const actualIds = actualTopLevelModules
|
|
.map((module) => (typeof module.id === "string" ? module.id : null))
|
|
.filter((id): id is string => Boolean(id));
|
|
const expectedIds = expectedTopLevelModules
|
|
.map((module) => (typeof module.id === "string" ? module.id : null))
|
|
.filter((id): id is string => Boolean(id));
|
|
|
|
checks.push(
|
|
check(
|
|
"flow includes expected top-level step ids",
|
|
expectedIds.every((id) => actualIds.includes(id)),
|
|
`expected ids: ${expectedIds.join(", ")}; actual ids: ${actualIds.join(", ")}`
|
|
)
|
|
);
|
|
|
|
checks.push(
|
|
check(
|
|
"flow preserves expected top-level step order",
|
|
preservesRelativeOrder(actualIds, expectedIds),
|
|
`expected order: ${expectedIds.join(" -> ")}; actual ids: ${actualIds.join(" -> ")}`
|
|
)
|
|
);
|
|
|
|
for (const expectedModule of expectedTopLevelModules) {
|
|
const moduleId = typeof expectedModule.id === "string" ? expectedModule.id : null;
|
|
if (!moduleId) {
|
|
continue;
|
|
}
|
|
|
|
const actualModule = actualTopLevelModules.find((module) => module.id === moduleId);
|
|
if (!actualModule) {
|
|
continue;
|
|
}
|
|
|
|
const expectedType = getModuleType(expectedModule);
|
|
if (expectedType && !(hasSuspendConfig(expectedModule) || hasSuspendConfig(actualModule))) {
|
|
checks.push(
|
|
check(
|
|
`${moduleId} type matches expected`,
|
|
getModuleType(actualModule) === expectedType,
|
|
`expected ${expectedType}, got ${getModuleType(actualModule) ?? "(missing)"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
const expectedPath = getModulePath(expectedModule);
|
|
if (expectedPath) {
|
|
checks.push(
|
|
check(
|
|
`${moduleId} path matches expected`,
|
|
getModulePath(actualModule) === expectedPath,
|
|
`expected ${expectedPath}, got ${getModulePath(actualModule) ?? "(missing)"}`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const specialModuleKey of ["preprocessor_module", "failure_module"] as const) {
|
|
const expectedSpecialModule = getSpecialFlowModule(expected, specialModuleKey);
|
|
if (!expectedSpecialModule) {
|
|
continue;
|
|
}
|
|
|
|
const actualSpecialModule = getSpecialFlowModule(actual, specialModuleKey);
|
|
checks.push(check(`${specialModuleKey} matches expected presence`, Boolean(actualSpecialModule)));
|
|
|
|
if (!actualSpecialModule) {
|
|
continue;
|
|
}
|
|
|
|
const expectedType = getModuleType(expectedSpecialModule);
|
|
if (expectedType) {
|
|
checks.push(
|
|
check(
|
|
`${specialModuleKey} type matches expected`,
|
|
getModuleType(actualSpecialModule) === expectedType,
|
|
`expected ${expectedType}, got ${getModuleType(actualSpecialModule) ?? "(missing)"}`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
function validateFlowRequirements(
|
|
flow: FlowState,
|
|
validate: FlowValidationSpec
|
|
): BenchmarkCheck[] {
|
|
const checks: BenchmarkCheck[] = [];
|
|
const actualTopLevelModules = getFlowModules(flow);
|
|
const actualIds = actualTopLevelModules
|
|
.map((module) => (typeof module.id === "string" ? module.id : null))
|
|
.filter((id): id is string => Boolean(id));
|
|
|
|
if (validate.exactTopLevelStepIds && validate.exactTopLevelStepIds.length > 0) {
|
|
checks.push(
|
|
check(
|
|
"flow top-level step ids match exactly",
|
|
stringArraysEqual(actualIds, validate.exactTopLevelStepIds),
|
|
`expected ids: ${validate.exactTopLevelStepIds.join(", ")}; actual ids: ${actualIds.join(", ")}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (validate.topLevelStepIds && validate.topLevelStepIds.length > 0) {
|
|
checks.push(
|
|
check(
|
|
"flow includes required top-level step ids",
|
|
validate.topLevelStepIds.every((id) => actualIds.includes(id)),
|
|
`required ids: ${validate.topLevelStepIds.join(", ")}; actual ids: ${actualIds.join(", ")}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (validate.topLevelStepOrder && validate.topLevelStepOrder.length > 0) {
|
|
checks.push(
|
|
check(
|
|
"flow preserves required top-level step order",
|
|
preservesRelativeOrder(actualIds, validate.topLevelStepOrder),
|
|
`required order: ${validate.topLevelStepOrder.join(" -> ")}; actual ids: ${actualIds.join(" -> ")}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const typeRequirement of validate.topLevelStepTypeCountsAtLeast ?? []) {
|
|
const actualCount = actualTopLevelModules.filter(
|
|
(module) => getModuleType(module) === typeRequirement.type
|
|
).length;
|
|
checks.push(
|
|
check(
|
|
`flow includes at least ${typeRequirement.count} top-level ${typeRequirement.type} step${typeRequirement.count === 1 ? "" : "s"}`,
|
|
actualCount >= typeRequirement.count,
|
|
`expected at least ${typeRequirement.count}, got ${actualCount}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const requiredStep of validate.topLevelStepTypes ?? []) {
|
|
const module = actualTopLevelModules.find((candidate) => candidate.id === requiredStep.id);
|
|
checks.push(check(`${requiredStep.id} step exists`, Boolean(module)));
|
|
if (!module) {
|
|
continue;
|
|
}
|
|
|
|
const allowedTypes = Array.isArray(requiredStep.type)
|
|
? requiredStep.type
|
|
: [requiredStep.type];
|
|
checks.push(
|
|
check(
|
|
`${requiredStep.id} type matches required`,
|
|
allowedTypes.includes(getModuleType(module) ?? ""),
|
|
`expected ${allowedTypes.join(" or ")}, got ${getModuleType(module) ?? "(missing)"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const moduleRule of validate.moduleRules ?? []) {
|
|
const module = findFlowModuleById(flow, moduleRule.id);
|
|
checks.push(check(`${moduleRule.id} module exists for rule validation`, Boolean(module)));
|
|
if (!module) {
|
|
continue;
|
|
}
|
|
|
|
if (moduleRule.hasStopAfterIf !== undefined) {
|
|
checks.push(
|
|
check(
|
|
`${moduleRule.id} stop_after_if presence matches required shape`,
|
|
hasStopAfterIf(module) === moduleRule.hasStopAfterIf,
|
|
`expected stop_after_if=${moduleRule.hasStopAfterIf}, got ${hasStopAfterIf(module)}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (moduleRule.hasStopAfterAllItersIf !== undefined) {
|
|
checks.push(
|
|
check(
|
|
`${moduleRule.id} stop_after_all_iters_if presence matches required shape`,
|
|
hasStopAfterAllItersIf(module) === moduleRule.hasStopAfterAllItersIf,
|
|
`expected stop_after_all_iters_if=${moduleRule.hasStopAfterAllItersIf}, got ${hasStopAfterAllItersIf(module)}`
|
|
)
|
|
);
|
|
}
|
|
|
|
const immediateChildren = getImmediateNestedModules(module);
|
|
const childIds = immediateChildren
|
|
.map((child) => (typeof child.id === "string" ? child.id : null))
|
|
.filter((id): id is string => Boolean(id));
|
|
|
|
if (moduleRule.immediateChildStepIds && moduleRule.immediateChildStepIds.length > 0) {
|
|
checks.push(
|
|
check(
|
|
`${moduleRule.id} includes required immediate child steps`,
|
|
moduleRule.immediateChildStepIds.every((id) => childIds.includes(id)),
|
|
`required child ids: ${moduleRule.immediateChildStepIds.join(", ")}; actual child ids: ${childIds.join(", ")}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (moduleRule.exactImmediateChildStepIds && moduleRule.exactImmediateChildStepIds.length > 0) {
|
|
checks.push(
|
|
check(
|
|
`${moduleRule.id} immediate child steps match exactly`,
|
|
stringArraysEqual(childIds, moduleRule.exactImmediateChildStepIds),
|
|
`expected child ids: ${moduleRule.exactImmediateChildStepIds.join(", ")}; actual child ids: ${childIds.join(", ")}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const requiredChild of moduleRule.immediateChildStepTypes ?? []) {
|
|
const child = immediateChildren.find((candidate) => candidate.id === requiredChild.id);
|
|
checks.push(check(`${moduleRule.id}.${requiredChild.id} child step exists`, Boolean(child)));
|
|
if (!child) {
|
|
continue;
|
|
}
|
|
|
|
checks.push(
|
|
check(
|
|
`${moduleRule.id}.${requiredChild.id} child type matches required`,
|
|
getModuleType(child) === requiredChild.type,
|
|
`expected ${requiredChild.type}, got ${getModuleType(child) ?? "(missing)"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
const inputTransforms = getInputTransformRecords(module);
|
|
for (const requiredTransform of moduleRule.requiredInputTransforms ?? []) {
|
|
const matchedTransform = inputTransforms.find((transform) =>
|
|
matchesRequiredInputTransform(transform, requiredTransform)
|
|
);
|
|
|
|
const expectedParts = [
|
|
requiredTransform.type ? `type=${JSON.stringify(requiredTransform.type)}` : null,
|
|
requiredTransform.expr ? `expr=${JSON.stringify(requiredTransform.expr)}` : null,
|
|
requiredTransform.exprAnyOf && requiredTransform.exprAnyOf.length > 0
|
|
? `exprAnyOf=${JSON.stringify(requiredTransform.exprAnyOf)}`
|
|
: null,
|
|
requiredTransform.value !== undefined
|
|
? `value=${JSON.stringify(requiredTransform.value)}`
|
|
: null,
|
|
].filter(Boolean);
|
|
|
|
checks.push(
|
|
check(
|
|
`${moduleRule.id} includes required input transform (${expectedParts.join(", ")})`,
|
|
Boolean(matchedTransform),
|
|
`available transforms: ${summarizeInputTransforms(inputTransforms)}`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const fieldRule of validate.moduleFieldRules ?? []) {
|
|
const module = findFlowModuleById(flow, fieldRule.id);
|
|
checks.push(check(`${fieldRule.id} module exists for field validation`, Boolean(module)));
|
|
if (!module) {
|
|
continue;
|
|
}
|
|
|
|
const actualValue = getValueAtPath(module, fieldRule.path);
|
|
checks.push(
|
|
check(
|
|
`${fieldRule.id}.${fieldRule.path} matches required value`,
|
|
valuesEqualForValidation(actualValue, fieldRule.equals),
|
|
`expected ${JSON.stringify(fieldRule.equals)}, got ${JSON.stringify(actualValue)}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const requiredPath of validate.schemaRequiredPaths ?? []) {
|
|
checks.push(
|
|
check(
|
|
`schema includes ${requiredPath}`,
|
|
hasSchemaPath(flow.schema, requiredPath),
|
|
`missing schema path ${requiredPath}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (validate.schemaAnyOf && validate.schemaAnyOf.length > 0) {
|
|
const matchingVariant = validate.schemaAnyOf.find((variant) =>
|
|
variant.requiredPaths.every((requiredPath) => hasSchemaPath(flow.schema, requiredPath))
|
|
);
|
|
|
|
checks.push(
|
|
check(
|
|
"schema matches one accepted input shape",
|
|
Boolean(matchingVariant),
|
|
matchingVariant
|
|
? undefined
|
|
: `expected one of: ${validate.schemaAnyOf
|
|
.map((variant) => `[${variant.requiredPaths.join(", ")}]`)
|
|
.join(" or ")}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (validate.resolveResultsRefs) {
|
|
const unresolved = collectUnresolvedResultsRefs(flow);
|
|
checks.push(
|
|
check(
|
|
"results references resolve",
|
|
unresolved.length === 0,
|
|
unresolved.length > 0 ? unresolved.join("; ") : undefined
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const specialModule of validate.requireSpecialModules ?? []) {
|
|
checks.push(
|
|
check(
|
|
`${specialModule} exists`,
|
|
Boolean(getSpecialFlowModule(flow, specialModule))
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const suspendStep of validate.requireSuspendSteps ?? []) {
|
|
const module = findFlowModuleById(flow, suspendStep.id);
|
|
checks.push(check(`${suspendStep.id} step exists`, Boolean(module)));
|
|
if (!module) {
|
|
continue;
|
|
}
|
|
|
|
checks.push(check(`${suspendStep.id} includes suspend config`, hasSuspendConfig(module)));
|
|
if (!hasSuspendConfig(module)) {
|
|
continue;
|
|
}
|
|
|
|
if (suspendStep.requiredEvents !== undefined) {
|
|
checks.push(
|
|
check(
|
|
`${suspendStep.id} requires ${suspendStep.requiredEvents} approval event${suspendStep.requiredEvents === 1 ? "" : "s"}`,
|
|
getSuspendRequiredEvents(module) === suspendStep.requiredEvents,
|
|
`expected ${suspendStep.requiredEvents}, got ${getSuspendRequiredEvents(module) ?? "(missing)"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (
|
|
suspendStep.resumeRequiredStringFieldAnyOf &&
|
|
suspendStep.resumeRequiredStringFieldAnyOf.length > 0
|
|
) {
|
|
const stringFields = getSuspendResumeStringFields(module);
|
|
checks.push(
|
|
check(
|
|
`${suspendStep.id} resume form includes one accepted comment field`,
|
|
suspendStep.resumeRequiredStringFieldAnyOf.some((field) =>
|
|
stringFields.includes(field)
|
|
),
|
|
`expected one of [${suspendStep.resumeRequiredStringFieldAnyOf.join(", ")}], got [${stringFields.join(", ")}]`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
function hasSchemaPath(schema: Record<string, unknown> | undefined, dottedPath: string): boolean {
|
|
if (!schema || typeof schema !== "object") {
|
|
return false;
|
|
}
|
|
|
|
const segments = dottedPath.split(".").filter(Boolean);
|
|
if (segments.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
let current: Record<string, unknown> | undefined = schema;
|
|
for (const segment of segments) {
|
|
const properties = current?.properties;
|
|
if (!properties || typeof properties !== "object") {
|
|
return false;
|
|
}
|
|
|
|
const next = (properties as Record<string, unknown>)[segment];
|
|
if (!next || typeof next !== "object") {
|
|
return false;
|
|
}
|
|
current = next as Record<string, unknown>;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function getTopLevelSchemaFields(schema: Record<string, unknown> | undefined): string[] {
|
|
if (!schema || typeof schema !== "object") {
|
|
return [];
|
|
}
|
|
|
|
const properties = schema.properties;
|
|
if (!properties || typeof properties !== "object") {
|
|
return [];
|
|
}
|
|
|
|
return Object.keys(properties as Record<string, unknown>).filter((key) => key.length > 0);
|
|
}
|
|
|
|
function preservesRelativeOrder(actualIds: string[], expectedIds: string[]): boolean {
|
|
if (expectedIds.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
let cursor = 0;
|
|
for (const actualId of actualIds) {
|
|
if (actualId === expectedIds[cursor]) {
|
|
cursor += 1;
|
|
if (cursor === expectedIds.length) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Exact equality, including order. Use a different helper for order-insensitive checks.
|
|
function stringArraysEqual(left: string[], right: string[]): boolean {
|
|
if (left.length !== right.length) {
|
|
return false;
|
|
}
|
|
|
|
return left.every((value, index) => value === right[index]);
|
|
}
|
|
|
|
function valuesEqualForValidation(
|
|
actual: unknown,
|
|
expected: string | number | boolean | null
|
|
): boolean {
|
|
if (typeof expected === "string" && typeof actual === "string") {
|
|
return normalizeInlineExpression(actual) === normalizeInlineExpression(expected);
|
|
}
|
|
|
|
return actual === expected;
|
|
}
|
|
|
|
function normalizeInlineExpression(value: string): string {
|
|
return value.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function collectUnresolvedResultsRefs(flow: FlowState): string[] {
|
|
const unresolved = new Set<string>();
|
|
validateModuleSequence(getFlowModules(flow), new Map<string, Record<string, unknown>>(), unresolved);
|
|
return [...unresolved];
|
|
}
|
|
|
|
function validateModuleSequence(
|
|
modules: Array<Record<string, unknown>>,
|
|
parentVisibleModules: Map<string, Record<string, unknown>>,
|
|
unresolved: Set<string>
|
|
): void {
|
|
const visibleModules = new Map(parentVisibleModules);
|
|
|
|
for (const module of modules) {
|
|
validateResultsRefsInRecord(module, visibleModules, unresolved);
|
|
validateNestedModuleResultsRefs(module, visibleModules, unresolved);
|
|
|
|
if (typeof module.id === "string" && module.id.length > 0) {
|
|
visibleModules.set(module.id, module);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateNestedModuleResultsRefs(
|
|
module: Record<string, unknown>,
|
|
visibleModules: Map<string, Record<string, unknown>>,
|
|
unresolved: Set<string>
|
|
): void {
|
|
const value = isObjectRecord(module.value) ? module.value : null;
|
|
if (!value) {
|
|
return;
|
|
}
|
|
|
|
const nestedSequences: Array<Array<Record<string, unknown>>> = [];
|
|
|
|
if (Array.isArray(value.modules)) {
|
|
nestedSequences.push(asModuleArray(value.modules));
|
|
}
|
|
|
|
if (Array.isArray(value.default)) {
|
|
nestedSequences.push(asModuleArray(value.default));
|
|
}
|
|
|
|
if (Array.isArray(value.branches)) {
|
|
for (const branch of value.branches) {
|
|
if (!isObjectRecord(branch)) {
|
|
continue;
|
|
}
|
|
if (typeof branch.expr === "string") {
|
|
validateResultsRefsInExpression(
|
|
branch.expr,
|
|
`branch ${module.id ?? "(unnamed)"}`,
|
|
visibleModules,
|
|
unresolved
|
|
);
|
|
}
|
|
if (Array.isArray(branch.modules)) {
|
|
nestedSequences.push(asModuleArray(branch.modules));
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const sequence of nestedSequences) {
|
|
validateModuleSequence(sequence, visibleModules, unresolved);
|
|
}
|
|
}
|
|
|
|
function validateResultsRefsInRecord(
|
|
value: unknown,
|
|
visibleModules: Map<string, Record<string, unknown>>,
|
|
unresolved: Set<string>,
|
|
context = "expression"
|
|
): void {
|
|
if (typeof value === "string") {
|
|
validateResultsRefsInExpression(value, context, visibleModules, unresolved);
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
for (const entry of value) {
|
|
validateResultsRefsInRecord(entry, visibleModules, unresolved, context);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!isObjectRecord(value)) {
|
|
return;
|
|
}
|
|
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
if (key === "content" || key === "modules" || key === "branches" || key === "default") {
|
|
continue;
|
|
}
|
|
validateResultsRefsInRecord(entry, visibleModules, unresolved, key);
|
|
}
|
|
}
|
|
|
|
function validateResultsRefsInExpression(
|
|
expression: string,
|
|
context: string,
|
|
visibleModules: Map<string, Record<string, unknown>>,
|
|
unresolved: Set<string>
|
|
): void {
|
|
for (const ref of extractResultsRefs(expression)) {
|
|
const module = visibleModules.get(ref.root);
|
|
if (!module) {
|
|
unresolved.add(`${context} references missing results.${ref.root}`);
|
|
continue;
|
|
}
|
|
validateNestedResultsRefPath(ref.root, ref.path, module, context, unresolved);
|
|
}
|
|
}
|
|
|
|
function extractResultsRefs(
|
|
expression: string
|
|
): Array<{ root: string; path: string[] }> {
|
|
const matches = expression.matchAll(/\bresults\.([A-Za-z0-9_-]+)((?:\.[A-Za-z0-9_-]+)*)/g);
|
|
const refs = new Map<string, { root: string; path: string[] }>();
|
|
|
|
for (const match of matches) {
|
|
const root = match[1];
|
|
const path = match[2]
|
|
.split(".")
|
|
.filter(Boolean);
|
|
const key = `${root}:${path.join(".")}`;
|
|
refs.set(key, { root, path });
|
|
}
|
|
|
|
return [...refs.values()];
|
|
}
|
|
|
|
function validateNestedResultsRefPath(
|
|
rootId: string,
|
|
path: string[],
|
|
module: Record<string, unknown>,
|
|
context: string,
|
|
unresolved: Set<string>
|
|
): void {
|
|
if (path.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const moduleType = getModuleType(module);
|
|
if (!moduleType || !CONTROL_FLOW_MODULE_TYPES.has(moduleType)) {
|
|
return;
|
|
}
|
|
|
|
const nestedIds = new Set(getImmediateNestedModuleIds(module));
|
|
const [firstSegment] = path;
|
|
if (nestedIds.has(firstSegment)) {
|
|
unresolved.add(
|
|
`${context} references nested results.${rootId}.${firstSegment} inside ${moduleType} ${rootId}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function getAllFlowModules(flow: FlowState): Array<Record<string, unknown>> {
|
|
const modules: Array<Record<string, unknown>> = [];
|
|
const specialModules = ["preprocessor_module", "failure_module"] as const;
|
|
|
|
for (const key of specialModules) {
|
|
const specialModule = getSpecialFlowModule(flow, key);
|
|
if (specialModule) {
|
|
modules.push(specialModule);
|
|
modules.push(...collectNestedModules(specialModule));
|
|
}
|
|
}
|
|
|
|
for (const module of getFlowModules(flow)) {
|
|
modules.push(module);
|
|
modules.push(...collectNestedModules(module));
|
|
}
|
|
|
|
return modules;
|
|
}
|
|
|
|
function collectNestedModules(module: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
const nested: Array<Record<string, unknown>> = [];
|
|
const value = isObjectRecord(module.value) ? module.value : null;
|
|
if (!value) {
|
|
return nested;
|
|
}
|
|
|
|
if (Array.isArray(value.modules)) {
|
|
for (const child of asModuleArray(value.modules)) {
|
|
nested.push(child, ...collectNestedModules(child));
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(value.default)) {
|
|
for (const child of asModuleArray(value.default)) {
|
|
nested.push(child, ...collectNestedModules(child));
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(value.branches)) {
|
|
for (const branch of value.branches) {
|
|
if (!isObjectRecord(branch) || !Array.isArray(branch.modules)) {
|
|
continue;
|
|
}
|
|
for (const child of asModuleArray(branch.modules)) {
|
|
nested.push(child, ...collectNestedModules(child));
|
|
}
|
|
}
|
|
}
|
|
|
|
return nested;
|
|
}
|
|
|
|
function findFlowModuleById(flow: FlowState, id: string): Record<string, unknown> | null {
|
|
for (const module of getAllFlowModules(flow)) {
|
|
if (module.id === id) {
|
|
return module;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getInlineScriptPlaceholderModuleIds(flow: FlowState): string[] {
|
|
return getAllFlowModules(flow).flatMap((module) => {
|
|
const code = getModuleCode(module)?.trim();
|
|
if (!code || !/^inline_script\.[A-Za-z0-9_-]+$/.test(code)) {
|
|
return [];
|
|
}
|
|
|
|
if (typeof module.id === "string" && module.id.length > 0) {
|
|
return [module.id];
|
|
}
|
|
|
|
return ["(unnamed)"];
|
|
});
|
|
}
|
|
|
|
function getImmediateNestedModuleIds(module: Record<string, unknown>): string[] {
|
|
return getImmediateNestedModules(module).flatMap((child) =>
|
|
typeof child.id === "string" ? [child.id] : []
|
|
);
|
|
}
|
|
|
|
function getImmediateNestedModules(module: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
const nested: Array<Record<string, unknown>> = [];
|
|
const value = isObjectRecord(module.value) ? module.value : null;
|
|
if (!value) {
|
|
return nested;
|
|
}
|
|
|
|
if (Array.isArray(value.modules)) {
|
|
nested.push(...asModuleArray(value.modules));
|
|
}
|
|
|
|
if (Array.isArray(value.default)) {
|
|
nested.push(...asModuleArray(value.default));
|
|
}
|
|
|
|
if (Array.isArray(value.branches)) {
|
|
for (const branch of value.branches) {
|
|
if (!isObjectRecord(branch) || !Array.isArray(branch.modules)) {
|
|
continue;
|
|
}
|
|
nested.push(...asModuleArray(branch.modules));
|
|
}
|
|
}
|
|
|
|
return nested;
|
|
}
|
|
|
|
function getModuleCode(module: Record<string, unknown>): string | null {
|
|
const value = isObjectRecord(module.value) ? module.value : null;
|
|
return typeof value?.content === "string" ? value.content : null;
|
|
}
|
|
|
|
function getValueAtPath(record: Record<string, unknown>, dottedPath: string): unknown {
|
|
const segments = dottedPath.split(".").filter(Boolean);
|
|
let current: unknown = record;
|
|
|
|
for (const segment of segments) {
|
|
if (!isObjectRecord(current)) {
|
|
return undefined;
|
|
}
|
|
current = current[segment];
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
function getInputTransformRecords(module: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
const value = isObjectRecord(module.value) ? module.value : null;
|
|
const inputTransforms = isObjectRecord(value?.input_transforms) ? value.input_transforms : null;
|
|
if (!inputTransforms) {
|
|
return [];
|
|
}
|
|
|
|
return Object.values(inputTransforms).filter(isObjectRecord);
|
|
}
|
|
|
|
function matchesRequiredInputTransform(
|
|
actual: Record<string, unknown>,
|
|
required: {
|
|
type?: string;
|
|
expr?: string;
|
|
exprAnyOf?: string[];
|
|
value?: string | number | boolean | null;
|
|
}
|
|
): boolean {
|
|
if (required.type !== undefined && !valuesEqualForValidation(actual.type, required.type)) {
|
|
return false;
|
|
}
|
|
|
|
if (required.expr !== undefined && !valuesEqualForValidation(actual.expr, required.expr)) {
|
|
return false;
|
|
}
|
|
|
|
if (required.exprAnyOf !== undefined) {
|
|
if (
|
|
typeof actual.expr !== "string" ||
|
|
!required.exprAnyOf.some((candidate) => valuesEqualForValidation(actual.expr, candidate))
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (required.value !== undefined && !valuesEqualForValidation(actual.value, required.value)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function summarizeInputTransforms(transforms: Array<Record<string, unknown>>): string {
|
|
if (transforms.length === 0) {
|
|
return "(none)";
|
|
}
|
|
|
|
return transforms
|
|
.map((transform) =>
|
|
JSON.stringify({
|
|
type: transform.type,
|
|
expr: transform.expr,
|
|
value: transform.value,
|
|
})
|
|
)
|
|
.join("; ");
|
|
}
|
|
|
|
function asModuleArray(value: unknown[]): Array<Record<string, unknown>> {
|
|
return value.filter(isObjectRecord);
|
|
}
|
|
|
|
function isObjectRecord(value: unknown): value is Record<string, any> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function getSpecialFlowModule(
|
|
flow: FlowState,
|
|
key: "preprocessor_module" | "failure_module"
|
|
): Record<string, unknown> | null {
|
|
if (!flow.value || typeof flow.value !== "object") {
|
|
return null;
|
|
}
|
|
const module = (flow.value as Record<string, unknown>)[key];
|
|
return module && typeof module === "object" ? (module as Record<string, unknown>) : null;
|
|
}
|
|
|
|
function getModuleType(module: Record<string, unknown>): string | null {
|
|
const value = module.value;
|
|
if (!value || typeof value !== "object") {
|
|
return null;
|
|
}
|
|
return typeof (value as Record<string, unknown>).type === "string"
|
|
? ((value as Record<string, string>).type)
|
|
: null;
|
|
}
|
|
|
|
function getModulePath(module: Record<string, unknown>): string | null {
|
|
const value = module.value;
|
|
if (!value || typeof value !== "object") {
|
|
return null;
|
|
}
|
|
|
|
return typeof (value as Record<string, unknown>).path === "string"
|
|
? ((value as Record<string, string>).path)
|
|
: null;
|
|
}
|
|
|
|
function hasSuspendConfig(module: Record<string, unknown>): boolean {
|
|
return typeof module.suspend === "object" && module.suspend !== null;
|
|
}
|
|
|
|
function hasStopAfterIf(module: Record<string, unknown>): boolean {
|
|
return isObjectRecord(module.stop_after_if);
|
|
}
|
|
|
|
function hasStopAfterAllItersIf(module: Record<string, unknown>): boolean {
|
|
return isObjectRecord(module.stop_after_all_iters_if);
|
|
}
|
|
|
|
function getSuspendRequiredEvents(module: Record<string, unknown>): number | null {
|
|
const suspend = isObjectRecord(module.suspend) ? module.suspend : null;
|
|
return typeof suspend?.required_events === "number" ? suspend.required_events : null;
|
|
}
|
|
|
|
function getSuspendResumeStringFields(module: Record<string, unknown>): string[] {
|
|
const suspend = isObjectRecord(module.suspend) ? module.suspend : null;
|
|
const resumeForm = isObjectRecord(suspend?.resume_form) ? suspend.resume_form : null;
|
|
const schema = isObjectRecord(resumeForm?.schema) ? resumeForm.schema : null;
|
|
const properties = isObjectRecord(schema?.properties) ? schema.properties : null;
|
|
if (!properties) {
|
|
return [];
|
|
}
|
|
|
|
return Object.entries(properties).flatMap(([field, property]) => {
|
|
if (!isObjectRecord(property) || property.type !== "string") {
|
|
return [];
|
|
}
|
|
return [field];
|
|
});
|
|
}
|
|
|
|
function appStatesEqual(left: AppFilesState, right: AppFilesState): boolean {
|
|
return (
|
|
fileMapsEqual(left.frontend, right.frontend) &&
|
|
fileMapsEqual(stringifyBackend(left.backend), stringifyBackend(right.backend)) &&
|
|
normalizeJson(canonicalizeDatatables(left.datatables ?? [])) ===
|
|
normalizeJson(canonicalizeDatatables(right.datatables ?? []))
|
|
);
|
|
}
|
|
|
|
function stringifyBackend(backend: Record<string, AppRunnableState>): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(backend)) {
|
|
result[key] = JSON.stringify(value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function fileMapsEqual(left: Record<string, string>, right: Record<string, string>): boolean {
|
|
const leftEntries = Object.entries(left).sort(([a], [b]) => a.localeCompare(b));
|
|
const rightEntries = Object.entries(right).sort(([a], [b]) => a.localeCompare(b));
|
|
if (leftEntries.length !== rightEntries.length) {
|
|
return false;
|
|
}
|
|
return leftEntries.every(([key, value], index) => {
|
|
const [otherKey, otherValue] = rightEntries[index];
|
|
return key === otherKey && normalizeText(value) === normalizeText(otherValue);
|
|
});
|
|
}
|
|
|
|
function validateAppRequirements(
|
|
app: AppFilesState,
|
|
validate: AppValidationSpec,
|
|
toolsUsed: string[] = []
|
|
): BenchmarkCheck[] {
|
|
const checks: BenchmarkCheck[] = [];
|
|
const frontendPaths = Object.keys(app.frontend ?? {});
|
|
const backendKeys = Object.keys(app.backend ?? {});
|
|
const datatables = app.datatables ?? [];
|
|
|
|
if (validate.requiredFrontendPaths && validate.requiredFrontendPaths.length > 0) {
|
|
checks.push(
|
|
check(
|
|
"app includes required frontend paths",
|
|
validate.requiredFrontendPaths.every((filePath) => frontendPaths.includes(filePath)),
|
|
`required paths: ${validate.requiredFrontendPaths.join(", ")}; actual paths: ${frontendPaths.join(", ")}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const contentRequirement of validate.requiredFrontendFileContent ?? []) {
|
|
const content = app.frontend?.[contentRequirement.path];
|
|
checks.push(
|
|
check(
|
|
`frontend ${contentRequirement.path} exists for content validation`,
|
|
content !== undefined
|
|
)
|
|
);
|
|
if (content === undefined) {
|
|
continue;
|
|
}
|
|
|
|
checks.push(
|
|
check(
|
|
`frontend ${contentRequirement.path} includes required content`,
|
|
contentIncludesAll(content, contentRequirement.includes),
|
|
missingSnippetsDetails(content, contentRequirement.includes)
|
|
)
|
|
);
|
|
}
|
|
|
|
if (validate.requiredBackendRunnableKeys && validate.requiredBackendRunnableKeys.length > 0) {
|
|
checks.push(
|
|
check(
|
|
"app includes required backend runnables",
|
|
validate.requiredBackendRunnableKeys.every((key) => backendKeys.includes(key)),
|
|
`required runnables: ${validate.requiredBackendRunnableKeys.join(", ")}; actual runnables: ${backendKeys.join(", ")}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (typeof validate.backendRunnableCountAtLeast === "number") {
|
|
checks.push(
|
|
check(
|
|
`app includes at least ${validate.backendRunnableCountAtLeast} backend runnable${validate.backendRunnableCountAtLeast === 1 ? "" : "s"}`,
|
|
backendKeys.length >= validate.backendRunnableCountAtLeast,
|
|
`expected at least ${validate.backendRunnableCountAtLeast}, got ${backendKeys.length}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const runnableRequirement of validate.requiredBackendRunnableTypes ?? []) {
|
|
const runnable = app.backend?.[runnableRequirement.key];
|
|
checks.push(check(`${runnableRequirement.key} backend runnable exists`, Boolean(runnable)));
|
|
if (!runnable) {
|
|
continue;
|
|
}
|
|
|
|
checks.push(
|
|
check(
|
|
`${runnableRequirement.key} backend runnable type matches required`,
|
|
runnable.type === runnableRequirement.type,
|
|
`expected ${runnableRequirement.type}, got ${runnable.type ?? "(missing)"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const contentRequirement of validate.requiredBackendRunnableContent ?? []) {
|
|
const runnable = app.backend?.[contentRequirement.key];
|
|
checks.push(
|
|
check(
|
|
`${contentRequirement.key} backend runnable exists for content validation`,
|
|
Boolean(runnable)
|
|
)
|
|
);
|
|
if (!runnable) {
|
|
continue;
|
|
}
|
|
|
|
const content = runnable.inlineScript?.content ?? "";
|
|
checks.push(
|
|
check(
|
|
`${contentRequirement.key} backend runnable includes required content`,
|
|
contentIncludesAll(content, contentRequirement.includes),
|
|
missingSnippetsDetails(content, contentRequirement.includes)
|
|
)
|
|
);
|
|
}
|
|
|
|
if (typeof validate.datatableCountAtLeast === "number") {
|
|
checks.push(
|
|
check(
|
|
`app includes at least ${validate.datatableCountAtLeast} datatable${validate.datatableCountAtLeast === 1 ? "" : "s"}`,
|
|
datatables.length >= validate.datatableCountAtLeast,
|
|
`expected at least ${validate.datatableCountAtLeast}, got ${datatables.length}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (typeof validate.datatableTableCountAtLeast === "number") {
|
|
const actualTableCount = countDatatableTables(datatables);
|
|
checks.push(
|
|
check(
|
|
`app includes at least ${validate.datatableTableCountAtLeast} datatable table${validate.datatableTableCountAtLeast === 1 ? "" : "s"}`,
|
|
actualTableCount >= validate.datatableTableCountAtLeast,
|
|
`expected at least ${validate.datatableTableCountAtLeast}, got ${actualTableCount}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (typeof validate.datatableTableCountExactly === "number") {
|
|
const actualTableCount = countDatatableTables(datatables);
|
|
checks.push(
|
|
check(
|
|
`app includes exactly ${validate.datatableTableCountExactly} datatable table${validate.datatableTableCountExactly === 1 ? "" : "s"}`,
|
|
actualTableCount === validate.datatableTableCountExactly,
|
|
`expected exactly ${validate.datatableTableCountExactly}, got ${actualTableCount}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const datatableRequirement of validate.requiredDatatables ?? []) {
|
|
const label = datatableRequirement.datatableName
|
|
? `${datatableRequirement.datatableName}/${datatableRequirement.schema}.${datatableRequirement.table}`
|
|
: `${datatableRequirement.schema}.${datatableRequirement.table}`;
|
|
checks.push(
|
|
check(
|
|
`datatable table ${label} exists`,
|
|
hasDatatableTable(datatables, datatableRequirement)
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const toolName of validate.requiredToolsUsed ?? []) {
|
|
checks.push(
|
|
check(
|
|
`tool ${toolName} was used`,
|
|
toolsUsed.includes(toolName),
|
|
`tools used: ${toolsUsed.join(", ") || "none"}`
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const forbiddenSnippet of validate.forbiddenAppContent ?? []) {
|
|
checks.push(
|
|
check(
|
|
`app does not include forbidden content '${forbiddenSnippet}'`,
|
|
!appContentIncludes(app, forbiddenSnippet),
|
|
`forbidden snippet: ${forbiddenSnippet}`
|
|
)
|
|
);
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
function contentIncludesAll(content: string, snippets: string[]): boolean {
|
|
const normalizedContent = content.toLowerCase();
|
|
return snippets.every((snippet) => normalizedContent.includes(snippet.toLowerCase()));
|
|
}
|
|
|
|
function missingSnippetsDetails(content: string, snippets: string[]): string {
|
|
const normalizedContent = content.toLowerCase();
|
|
const missing = snippets.filter(
|
|
(snippet) => !normalizedContent.includes(snippet.toLowerCase())
|
|
);
|
|
return missing.length > 0 ? `missing snippets: ${missing.join(", ")}` : "";
|
|
}
|
|
|
|
function appContentIncludes(app: AppFilesState, snippet: string): boolean {
|
|
const normalizedSnippet = snippet.toLowerCase();
|
|
const frontendContent = Object.values(app.frontend ?? {});
|
|
const backendContent = Object.values(app.backend ?? {}).map(
|
|
(runnable) => runnable.inlineScript?.content ?? ""
|
|
);
|
|
return [...frontendContent, ...backendContent].some((content) =>
|
|
content.toLowerCase().includes(normalizedSnippet)
|
|
);
|
|
}
|
|
|
|
function countDatatableTables(datatables: AppDatatableState[]): number {
|
|
return datatables.reduce((count, datatable) => {
|
|
return (
|
|
count +
|
|
Object.values(datatable.schemas ?? {}).reduce((schemaCount, tables) => {
|
|
return schemaCount + Object.keys(tables ?? {}).length;
|
|
}, 0)
|
|
);
|
|
}, 0);
|
|
}
|
|
|
|
function hasDatatableTable(
|
|
datatables: AppDatatableState[],
|
|
input: { schema: string; table: string; datatableName?: string }
|
|
): boolean {
|
|
return datatables.some((datatable) => {
|
|
if (input.datatableName && datatable.datatable_name !== input.datatableName) {
|
|
return false;
|
|
}
|
|
return Boolean(datatable.schemas?.[input.schema]?.[input.table]);
|
|
});
|
|
}
|
|
|
|
function canonicalizeDatatables(datatables: AppDatatableState[]): AppDatatableState[] {
|
|
return [...datatables]
|
|
.map((datatable) => ({
|
|
datatable_name: datatable.datatable_name,
|
|
error: datatable.error,
|
|
schemas: Object.fromEntries(
|
|
Object.entries(datatable.schemas ?? {})
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([schemaName, tables]) => [
|
|
schemaName,
|
|
Object.fromEntries(
|
|
Object.entries(tables ?? {})
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([tableName, columns]) => [
|
|
tableName,
|
|
Object.fromEntries(
|
|
Object.entries(columns ?? {}).sort(([left], [right]) => left.localeCompare(right))
|
|
),
|
|
])
|
|
),
|
|
])
|
|
),
|
|
}))
|
|
.sort((left, right) => left.datatable_name.localeCompare(right.datatable_name));
|
|
}
|