feat: report script metadata with no content file in wmill lint (#11053)

Claude-Session: https://claude.ai/code/session_01PENfHNJceAbrcUsgfFCjXN

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-10 09:15:44 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 9a563f6d72
commit 8820b9fc64
7 changed files with 314 additions and 12 deletions
+130 -4
View File
@@ -31,10 +31,18 @@ import {
import {
isFlowInlineScriptPath,
isAppInlineScriptPath,
isRawAppPath,
isFolderResourcePathAnyFormat,
getFolderSuffix,
getScriptBasePathFromModulePath,
} from "../../utils/resource_folders.ts";
import { exts } from "../script/script.ts";
import { isFilesetResource } from "../../utils/utils.ts";
import {
exts,
findContentFile,
hasScriptExt,
isModuleEntryMetadata,
UnresolvableScriptContentFileError,
} from "../script/script.ts";
interface LintOptions extends GlobalOptions {
json?: boolean;
@@ -67,6 +75,9 @@ export interface LintReport {
const YAML_FILE_REGEX = /\.ya?ml$/i;
const NATIVE_TRIGGER_REGEX = /\.[^.]+_native_trigger\.ya?ml$/i;
// The metadata suffixes `findContentFile` resolves a flat script from. `.yml` is
// deliberately absent, since the push does not accept it there either.
const FLAT_SCRIPT_METADATA_REGEX = /\.script\.(yaml|json|lock)$/;
function normalizePath(p: string): string {
return p.replaceAll(SEP, "/");
@@ -643,6 +654,83 @@ export async function checkMissingLocks(
return issues;
}
/**
* Whether a path is a script's own metadata, as opposed to metadata the push
* deploys through some parent: a folder resource's inline scripts, a fileset's
* children (arbitrarily named, so one may be spelled exactly like a script's
* metadata) and the files of a module or dbt bundle all belong to that parent.
*
* Takes the path as the SYNC ROOT spells it, like the push. Relative to the
* lint target the enclosing folder is gone whenever the target IS that folder;
* absolute, the classifiers match their suffixes ANYWHERE in the string, so a
* checkout under `acme.app` reads as one app and nothing is ever reported.
*/
function isStandaloneScriptMetadata(rootedPath: string): boolean {
// Both suffix formats, because the dotted/non-dotted setting is read from the
// invocation directory and an explicit lint target may not share it.
if (
isFolderResourcePathAnyFormat(rootedPath) ||
isFilesetResource(rootedPath)
) {
return false;
}
// A module folder keeps its metadata inside itself (`<base>__mod/script.yaml`),
// which is standalone even though every other path under `__mod/` is not.
if (isModuleEntryMetadata(rootedPath)) return true;
if (getScriptBasePathFromModulePath(rootedPath) !== undefined) return false;
return FLAT_SCRIPT_METADATA_REGEX.test(rootedPath);
}
/**
* `findContentFile` quotes the paths it was given back in its errors, so the
* lint target's own prefix comes off them again. Anchored at a path start: a
* plain substring replace of `f/` also eats the one inside `conf/`, mangling
* the very filename the message is telling the reader to delete.
*/
function relativizeMessage(message: string, prefix: string): string {
if (!prefix) return message;
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return message.replaceAll(new RegExp(`(^|[\\s(])${escaped}/`, "g"), "$1");
}
/**
* Script metadata files that cannot be paired with exactly one content file:
* the push refuses those, and no metadata format makes them deployable, so the
* inactive twin of a format switch (`foo.script.json` in a yaml repo) is dead
* weight worth reporting even though the push skips it rather than refusing it.
*
* Resolved through `findContentFile` rather than by probing `exts` directly, so
* lint and push agree on what counts as paired: a dbt project's descriptor is
* optional and its absence is not an orphan, while two content files beside one
* metadata file is just as undeployable as none. It classifies what it is given
* and looks under `syncRoot`, so where the command was invoked from is not part
* of the answer.
*/
async function checkOrphanScriptMetadata(
syncRoot: string,
prefix: string,
metadataPaths: string[],
): Promise<FileIssue[]> {
const issues: FileIssue[] = [];
for (const metadataPath of metadataPaths) {
const rootedPath = prefix ? `${prefix}/${metadataPath}` : metadataPath;
try {
await findContentFile(rootedPath, syncRoot);
} catch (e) {
if (!(e instanceof UnresolvableScriptContentFileError)) {
log.debug(`Failed to resolve content file for ${rootedPath}: ${e}`);
continue;
}
issues.push({
path: metadataPath,
target: "script",
errors: [relativizeMessage(e.message, prefix)],
});
}
}
return issues;
}
export async function runLint(
opts: LintOptions,
directory?: string,
@@ -674,8 +762,16 @@ export async function runLint(
const root = await FSFSElement(targetDirectory, [], false);
const validator = new WindmillYamlValidator();
// Walked paths are relative to the lint target; this puts them back the way
// the sync root spells them, which is what the two below are written against.
const syncRoot = await findSyncRoot(targetDirectory);
const metadataPrefix = normalizePath(
path.relative(syncRoot, targetDirectory),
);
const warnings: LintWarning[] = [];
const issues: FileIssue[] = [];
const scriptMetadataPaths: string[] = [];
let scannedFiles = 0;
let validatedFiles = 0;
let validFiles = 0;
@@ -689,6 +785,17 @@ export async function runLint(
const normalizedPath = normalizePath(entry.path);
scannedFiles += 1;
// Collected before the YAML filter below: `.script.lock` and `.script.json`
// are metadata too, and both fail the push when nothing pairs with them.
if (
isStandaloneScriptMetadata(
metadataPrefix ? `${metadataPrefix}/${normalizedPath}` : normalizedPath,
)
) {
scriptMetadataPaths.push(normalizedPath);
}
if (!YAML_FILE_REGEX.test(normalizedPath)) {
continue;
}
@@ -727,6 +834,16 @@ export async function runLint(
}
}
// Unconditional: unlike a missing lock, metadata with no content file fails
// every push, so there is no mode in which it is acceptable.
issues.push(
...(await checkOrphanScriptMetadata(
syncRoot,
metadataPrefix,
scriptMetadataPaths,
)),
);
// Check for missing locks if --locks-required is set
if (opts.locksRequired) {
const lockIssues = await checkMissingLocks(opts, explicitTargetDirectory);
@@ -820,6 +937,15 @@ async function lint(opts: LintOptions & { watch?: boolean }, directory?: string)
}
}
/**
* Whether a changed file can change what a lint run reports: metadata in any of
* its formats, and the content files whose presence is what keeps that metadata
* from being an orphan.
*/
function affectsLint(filename: string): boolean {
return /\.(ya?ml|json|lock)$/i.test(filename) || hasScriptExt(filename);
}
async function lintWatch(opts: LintOptions, directory?: string) {
const { watch } = await import("node:fs");
const targetDir = directory ? path.resolve(process.cwd(), directory) : process.cwd();
@@ -842,7 +968,7 @@ async function lintWatch(opts: LintOptions, directory?: string) {
let debounce: ReturnType<typeof setTimeout> | null = null;
watch(targetDir, { recursive: true }, (_event, filename) => {
if (!filename || !filename.toString().endsWith(".yaml") && !filename.toString().endsWith(".yml")) return;
if (!filename || !affectsLint(filename.toString())) return;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(runAndReport, 300);
});
@@ -853,7 +979,7 @@ async function lintWatch(opts: LintOptions, directory?: string) {
const command = new Command()
.description(
"Validate Windmill flow, schedule, and trigger YAML files in a directory",
"Validate Windmill flow, schedule, and trigger YAML files in a directory, and report script metadata that has no deployable content file",
)
.arguments("[directory:string]")
.option("--json", "Output results in JSON format")
+14 -4
View File
@@ -1075,10 +1075,12 @@ export class DbtPathCollisionError extends UnresolvableScriptContentFileError {}
* guard on one of them leaves the other silently overwriting.
*/
export async function collidingDbtProject(
basePath: string
basePath: string,
baseDir?: string
): Promise<string | undefined> {
const project = basePath + "__dbt/dbt_project.yml";
return (await stat(project).then(() => true).catch(() => false))
const onDisk = baseDir ? path.join(baseDir, project) : project;
return (await stat(onDisk).then(() => true).catch(() => false))
? project
: undefined;
}
@@ -1139,7 +1141,14 @@ async function readScriptContent(filePath: string): Promise<string> {
}
}
export async function findContentFile(filePath: string) {
/**
* The script file `filePath`'s metadata belongs to. `baseDir`, when given, is
* where the disk lookups happen, leaving `filePath` classified as written: the
* layout helpers below match their suffixes ANYWHERE in a path, so a caller
* that prefixed a checkout named `repo__mod` would have it read as the module.
*/
export async function findContentFile(filePath: string, baseDir?: string) {
const onDisk = (p: string) => (baseDir ? path.join(baseDir, p) : p);
// Folder layout: __mod/script.yaml -> __mod/script.ts
const isModuleFolderMeta = isModuleEntryMetadata(filePath);
const toCandidate = (ext: string) =>
@@ -1163,7 +1172,7 @@ export async function findContentFile(filePath: string) {
const validCandidates = (
await Promise.all(
candidates.map((x) => {
return stat(x)
return stat(onDisk(x))
.catch(() => undefined)
.then((x) => x?.isFile())
.then((e) => {
@@ -1183,6 +1192,7 @@ export async function findContentFile(filePath: string) {
const dbtCandidate = toCandidate("__dbt/" + DBT_DESCRIPTOR_NAME);
const dbtProject = await collidingDbtProject(
dbtCandidate.slice(0, -("__dbt/" + DBT_DESCRIPTOR_NAME).length),
baseDir,
);
const nonDbtCandidates = validCandidates.filter((c) => c !== dbtCandidate);
if (dbtProject && nonDbtCandidates.length > 0) {
+1 -1
View File
@@ -7403,7 +7403,7 @@ Manage jobs (import/export)
### lint
Validate Windmill flow, schedule, and trigger YAML files in a directory
Validate Windmill flow, schedule, and trigger YAML files in a directory, and report script metadata that has no deployable content file
**Arguments:** \`[directory:string]\`
+166
View File
@@ -0,0 +1,166 @@
import { expect, test, describe } from "bun:test";
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import * as path from "node:path";
import { runLint } from "../src/commands/lint/lint.ts";
const WMILL_YAML = "defaultTs: bun\nincludes:\n - f/**\nexcludes: []\n";
const METADATA = "summary: test\nlock: ''\nschema:\n properties: {}\n";
async function write(dir: string, rel: string, content: string) {
const full = path.join(dir, rel);
await mkdir(path.dirname(full), { recursive: true });
await writeFile(full, content, "utf-8");
}
/**
* Runs `fn` with a sync root at `<temp>/<rootName>`, from which lint resolves
* every walked path. The name is a parameter because it is load-bearing: the
* folder suffixes lint classifies by (`.app`, `__mod`, …) are matched anywhere
* in a path, so a root carrying one must not change what lint reports.
*/
async function withSyncRoot(
rootName: string,
fn: (syncRoot: string) => Promise<void>,
opts: { runFromParent?: boolean } = {},
): Promise<void> {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "wmill_lint_orphan_"));
const syncRoot = path.join(tempDir, rootName);
const originalCwd = process.cwd();
try {
await write(syncRoot, "wmill.yaml", WMILL_YAML);
process.chdir(opts.runFromParent ? tempDir : syncRoot);
await fn(syncRoot);
} finally {
process.chdir(originalCwd);
await rm(tempDir, { recursive: true });
}
}
describe("orphan script metadata", () => {
test("reports metadata with no content file, with locks not required", async () => {
await withSyncRoot("repo", async (syncRoot) => {
await write(syncRoot, "f/paired.py", "def main():\n pass\n");
await write(syncRoot, "f/paired.script.yaml", METADATA);
await write(syncRoot, "f/orphan.script.yaml", METADATA);
await write(syncRoot, "f/orphan_json.script.json", "{}\n");
await write(syncRoot, "f/orphan_lock.script.lock", "some-dep==1.0.0\n");
const report = await runLint({} as any, syncRoot);
expect(report.exitCode).toBe(1);
expect(report.issues.map((i) => i.path).sort()).toEqual([
"f/orphan.script.yaml",
"f/orphan_json.script.json",
"f/orphan_lock.script.lock",
]);
expect(report.issues[0].target).toBe("script");
expect(report.issues[0].errors[0]).toContain("No script file found next to");
});
});
test("reports a module folder's own metadata with no content file", async () => {
await withSyncRoot("repo", async (syncRoot) => {
await write(syncRoot, "f/orphan__mod/script.yaml", METADATA);
const report = await runLint({} as any, syncRoot);
expect(report.issues.map((i) => i.path)).toEqual([
"f/orphan__mod/script.yaml",
]);
// Linting the module folder itself: the walked paths no longer carry the
// `__mod/` boundary that says this is a module's metadata.
const inFolder = await runLint(
{} as any,
path.join(syncRoot, "f/orphan__mod"),
);
expect(inFolder.issues.map((i) => i.path)).toEqual(["script.yaml"]);
});
});
test("reports orphans under a sync root named like a resource folder", async () => {
await withSyncRoot("acme.app", async (syncRoot) => {
await write(syncRoot, "f/orphan.script.yaml", METADATA);
const report = await runLint({} as any, syncRoot);
expect(report.issues.map((i) => i.path)).toEqual(["f/orphan.script.yaml"]);
});
});
test("does not report a paired module under a sync root named like a module folder", async () => {
// Run from OUTSIDE the checkout, the one invocation whose paths carry the
// root's own name: nothing above the sync root may be classified.
await withSyncRoot(
"repo__mod",
async (syncRoot) => {
await write(syncRoot, "f/example__mod/script.yaml", METADATA);
await write(
syncRoot,
"f/example__mod/script.ts",
"export function main() {}\n",
);
const report = await runLint({} as any, syncRoot);
expect(report.issues).toEqual([]);
},
{ runFromParent: true },
);
});
test("does not report a non-dotted folder resource's child", async () => {
// The dotted/non-dotted setting is read from the invocation directory, so
// an explicit target configured the other way must still be recognized.
await withSyncRoot(
"repo",
async (syncRoot) => {
await write(syncRoot, "f/a__raw_app/raw_app.yaml", "value: {}\n");
await write(syncRoot, "f/a__raw_app/backend/config.script.lock", "x\n");
const report = await runLint({} as any, syncRoot);
expect(report.issues).toEqual([]);
},
{ runFromParent: true },
);
});
test("keeps the reported path whole when the lint target is a path segment", async () => {
await withSyncRoot("repo", async (syncRoot) => {
await write(syncRoot, "f/conf/orphan.script.yaml", METADATA);
const report = await runLint({} as any, path.join(syncRoot, "f"));
expect(report.issues.map((i) => i.path)).toEqual([
"conf/orphan.script.yaml",
]);
expect(report.issues[0].errors[0]).toContain("conf/orphan.script.yaml");
});
});
test("does not report a fileset child spelled like script metadata", async () => {
await withSyncRoot("repo", async (syncRoot) => {
await write(syncRoot, "f/data.resource.yaml", "value: {}\n");
await write(syncRoot, "f/data.fileset/config.script.yaml", "a: 1\n");
const report = await runLint({} as any, syncRoot);
expect(report.issues).toEqual([]);
});
});
test("does not report a dbt project whose optional descriptor is absent", async () => {
await withSyncRoot("repo", async (syncRoot) => {
await write(syncRoot, "f/proj.script.yaml", METADATA);
await write(syncRoot, "f/proj__dbt/dbt_project.yml", "name: proj\n");
await write(syncRoot, "f/proj__dbt/models/a.sql", "select 1\n");
const report = await runLint({} as any, syncRoot);
expect(report.issues).toEqual([]);
});
});
});
@@ -375,7 +375,7 @@ Manage jobs (import/export)
### lint
Validate Windmill flow, schedule, and trigger YAML files in a directory
Validate Windmill flow, schedule, and trigger YAML files in a directory, and report script metadata that has no deployable content file
**Arguments:** `[directory:string]`
+1 -1
View File
@@ -3555,7 +3555,7 @@ Manage jobs (import/export)
### lint
Validate Windmill flow, schedule, and trigger YAML files in a directory
Validate Windmill flow, schedule, and trigger YAML files in a directory, and report script metadata that has no deployable content file
**Arguments:** \`[directory:string]\`
@@ -380,7 +380,7 @@ Manage jobs (import/export)
### lint
Validate Windmill flow, schedule, and trigger YAML files in a directory
Validate Windmill flow, schedule, and trigger YAML files in a directory, and report script metadata that has no deployable content file
**Arguments:** `[directory:string]`