Files
windmill/windmill-yaml-validator/scripts/generate-resource-schemas.js
centdix 37d1277b91 feat(cli): add lint command (#7917)
* feat(yaml-validator)!: unify flow, schedule, and trigger validation

- replace FlowValidator with WindmillYamlValidator.validate(doc, target)

- generate schedule/trigger schemas from backend OpenAPI and OpenFlow refs

- add schedule/trigger/filename-target tests and update AI agent fixtures

- bump windmill-yaml-validator to 2.0.0

BREAKING CHANGE: FlowValidator and validateFlow() are replaced by WindmillYamlValidator.validate(doc, target).

* add lint command

* add deno-compat script and docs for local yaml-validator testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: make nullable fields pass yaml validation

Add nullable: true to static_asset_config and authentication_resource_path
in HttpTrigger schema. Post-process generated JSON schemas to add null to
enums with nullable: true (AJV doesn't handle OpenAPI 3.0 nullable + enum).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add nullable to all Option<T> fields in trigger and schedule OpenAPI schemas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(frontend): handle nullable fields from updated OpenAPI types

Add ?? undefined coalescing at assignment sites where generated types
now include | null from the OpenAPI nullable additions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(lint): show allowed values in enum validation errors

Instead of "must be equal to one of the allowed values", now shows
"must be one of: 'r', 'w', 'rw'" for enum validation failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add nullable to Edit/New trigger and schedule OpenAPI schemas

Ensures create/update request body types accept null for the same
fields that GET response types return as nullable, enabling clean
round-tripping without type mismatches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* use published package

* publish

* refactor(lint): remove unused --includes/--excludes/--extra-includes CLI options

These options were defined but never wired to the file filtering logic.
The lint command still respects includes/excludes from wmill.yaml via
mergeConfigWithConfigFile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(lint): handle additionalProperties errors and expand test coverage

Add formatting for AJV additionalProperties keyword to show the unknown
property name. Add unit tests for all formatValidationError branches and
integration tests for --json report shape, --fail-on-warn with mixed
files, non-existent directory, and enum error output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add realistic validator tests for schedules, triggers, and edge cases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add email trigger validation support

Add email trigger schema generation, validation, and linting. Email
triggers are no longer skipped with a warning — they are validated
like all other trigger types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(cli): bump windmill-yaml-validator to 1.1.1 (email trigger support)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* publish

* rm

* fix: address PR review feedback for lint command

- Add email to trigger kinds test loop instead of separate test
- Add email to ValidationTarget docs in README
- Type formatYamlDiagnostics param directly instead of unsafe cast
- Destructure json option before mergeConfigWithConfigFile for clarity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(cli): add --lint option to sync push command

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:41:04 +00:00

235 lines
6.0 KiB
JavaScript

#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const CLI_EXCLUDED_FIELDS = new Set([
"workspace_id",
"path",
"name",
"versions",
"id",
"created_at",
"updated_at",
"created_by",
"updated_by",
"edited_at",
"edited_by",
"archived",
"has_draft",
"error",
"last_server_ping",
"server_id",
"extra_perms",
"email",
"mode",
]);
const TARGET_SCHEMAS = {
schedule: "Schedule",
triggers: {
http: "HttpTrigger",
websocket: "WebsocketTrigger",
kafka: "KafkaTrigger",
nats: "NatsTrigger",
postgres: "PostgresTrigger",
mqtt: "MqttTrigger",
sqs: "SqsTrigger",
gcp: "GcpTrigger",
email: "EmailTrigger",
},
};
function deepClone(value) {
return JSON.parse(JSON.stringify(value));
}
function loadJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function mergeSchemas(target, source) {
if (!source || typeof source !== "object") {
return;
}
if (source.type && !target.type) {
target.type = source.type;
}
if (source.properties && typeof source.properties === "object") {
target.properties = target.properties || {};
Object.assign(target.properties, deepClone(source.properties));
}
if (Array.isArray(source.required)) {
target.required = target.required || [];
target.required.push(...source.required);
}
}
function getRefName(refPath) {
const marker = "#/components/schemas/";
const markerIndex = refPath.indexOf(marker);
if (markerIndex === -1) {
return null;
}
return refPath.slice(markerIndex + marker.length);
}
function resolveRef(refPath, backendSchemas, openflowSchemas) {
const refName = getRefName(refPath);
if (!refName) {
return null;
}
if (refPath.startsWith("#/components/schemas/")) {
if (backendSchemas[refName]) {
return deepClone(backendSchemas[refName]);
}
if (openflowSchemas[refName]) {
return deepClone(openflowSchemas[refName]);
}
return null;
}
if (refPath.includes("openflow.openapi.yaml#/components/schemas/")) {
if (openflowSchemas[refName]) {
return deepClone(openflowSchemas[refName]);
}
return null;
}
return null;
}
function extractCliSchema(schema, allSchemas, openflowSchemas) {
if (!schema || typeof schema !== "object") {
return {};
}
const result = { type: "object", properties: {}, required: [] };
if (Array.isArray(schema.allOf)) {
for (const item of schema.allOf) {
if (!item || typeof item !== "object") {
continue;
}
if (item.$ref) {
const refSchema = resolveRef(item.$ref, allSchemas, openflowSchemas);
const transformed = extractCliSchema(refSchema, allSchemas, openflowSchemas);
mergeSchemas(result, transformed);
} else {
const transformed = extractCliSchema(item, allSchemas, openflowSchemas);
mergeSchemas(result, transformed);
}
}
}
if (schema.properties && typeof schema.properties === "object") {
for (const [key, value] of Object.entries(schema.properties)) {
if (CLI_EXCLUDED_FIELDS.has(key)) {
continue;
}
result.properties[key] = deepClone(value);
}
}
if (Array.isArray(schema.required)) {
for (const field of schema.required) {
if (!CLI_EXCLUDED_FIELDS.has(field)) {
result.required.push(field);
}
}
}
const dedupRequired = Array.from(new Set(result.required));
result.required = dedupRequired.filter((key) => key in result.properties);
return result;
}
function resolveSchemaRefs(value, backendSchemas, openflowSchemas, stack = new Set()) {
if (Array.isArray(value)) {
return value.map((item) => resolveSchemaRefs(item, backendSchemas, openflowSchemas, stack));
}
if (!value || typeof value !== "object") {
return value;
}
if (typeof value.$ref === "string") {
const refName = getRefName(value.$ref);
if (!refName) {
return value;
}
if (stack.has(refName)) {
return {};
}
const resolved = resolveRef(value.$ref, backendSchemas, openflowSchemas);
if (!resolved) {
return value;
}
const merged = { ...resolved, ...value };
delete merged.$ref;
const nextStack = new Set(stack);
nextStack.add(refName);
return resolveSchemaRefs(merged, backendSchemas, openflowSchemas, nextStack);
}
const result = {};
for (const [key, nested] of Object.entries(value)) {
result[key] = resolveSchemaRefs(nested, backendSchemas, openflowSchemas, stack);
}
return result;
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
}
function generateSchemas(backendPath, openflowPath, outputDir) {
const backendOpenapi = loadJson(backendPath);
const openflowOpenapi = loadJson(openflowPath);
const backendSchemas = backendOpenapi.components?.schemas || {};
const openflowSchemas = openflowOpenapi.components?.schemas || {};
const scheduleSchema = extractCliSchema(
backendSchemas[TARGET_SCHEMAS.schedule],
backendSchemas,
openflowSchemas
);
const resolvedScheduleSchema = resolveSchemaRefs(scheduleSchema, backendSchemas, openflowSchemas);
writeJson(path.join(outputDir, "schedule.json"), resolvedScheduleSchema);
for (const [triggerKind, schemaName] of Object.entries(TARGET_SCHEMAS.triggers)) {
const triggerSchema = extractCliSchema(
backendSchemas[schemaName],
backendSchemas,
openflowSchemas
);
const resolvedTriggerSchema = resolveSchemaRefs(triggerSchema, backendSchemas, openflowSchemas);
writeJson(path.join(outputDir, "triggers", `${triggerKind}.json`), resolvedTriggerSchema);
}
}
function main() {
const [backendPath, openflowPath, outputDir] = process.argv.slice(2);
if (!backendPath || !openflowPath || !outputDir) {
console.error(
"Usage: node scripts/generate-resource-schemas.js <backend-openapi.json> <openflow.json> <output-dir>"
);
process.exit(1);
}
generateSchemas(backendPath, openflowPath, outputDir);
console.log("Generated schedule and trigger schemas");
}
main();