diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index e234402c..0c2b81a8 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -2,6 +2,7 @@ ## Next - Add `mops info ` command to show detailed package metadata from the registry +- Add `[lint.extra]` config for applying additional lint rules to specific files via glob patterns ## 2.8.1 diff --git a/cli/commands/lint.ts b/cli/commands/lint.ts index c9ad0fea..4cc70e5e 100644 --- a/cli/commands/lint.ts +++ b/cli/commands/lint.ts @@ -103,6 +103,62 @@ export interface LintOptions { extraArgs: string[]; } +function buildCommonArgs( + options: Partial, + config: Config, +): string[] { + const args: string[] = []; + if (options.verbose) { + args.push("--verbose"); + } + if (options.fix) { + args.push("--fix"); + } + if (config.lint?.args) { + if (typeof config.lint.args === "string") { + cliError( + `[lint] config 'args' should be an array of strings in mops.toml config file`, + ); + } + args.push(...config.lint.args); + } + if (options.extraArgs && options.extraArgs.length > 0) { + args.push(...options.extraArgs); + } + return args; +} + +async function runLintoko( + lintokoBinPath: string, + rootDir: string, + args: string[], + options: Partial, + label: string, +): Promise { + try { + if (options.verbose) { + console.log( + chalk.blue("lint"), + chalk.gray(`Running lintoko (${label}):`), + ); + console.log(chalk.gray(lintokoBinPath)); + console.log(chalk.gray(JSON.stringify(args))); + } + + const result = await execa(lintokoBinPath, args, { + cwd: rootDir, + stdio: "inherit", + reject: false, + }); + + return result.exitCode === 0; + } catch (err: any) { + cliError( + `Error while running lintoko${err?.message ? `\n${err.message}` : ""}`, + ); + } +} + export async function lint( filter: string | undefined, options: Partial, @@ -131,59 +187,93 @@ export async function lint( } } - let args: string[] = []; - if (options.verbose) { - args.push("--verbose"); - } - if (options.fix) { - args.push("--fix"); - } + const commonArgs = buildCommonArgs(options, config); + + // --- base run --- + const baseArgs: string[] = [...commonArgs]; const rules = options.rules !== undefined ? options.rules : await collectLintRules(config, rootDir); - rules.forEach((rule) => args.push("--rules", rule)); + rules.forEach((rule) => baseArgs.push("--rules", rule)); + baseArgs.push(...filesToLint); - if (config.lint?.args) { - if (typeof config.lint.args === "string") { - cliError( - `[lint] config 'args' should be an array of strings in mops.toml config file`, - ); - } - args.push(...config.lint.args); - } + let failed = !(await runLintoko( + lintokoBinPath, + rootDir, + baseArgs, + options, + "base", + )); - if (options.extraArgs && options.extraArgs.length > 0) { - args.push(...options.extraArgs); - } + // --- extra runs --- + const extraEntries = config.lint?.extra; + if (extraEntries) { + const isFiltered = filter || (options.files && options.files.length > 0); + const baseFileSet = isFiltered + ? new Set(filesToLint.map((f) => path.resolve(rootDir, f))) + : undefined; - args.push(...filesToLint); + for (const [globPattern, ruleDirs] of Object.entries(extraEntries)) { + if (!Array.isArray(ruleDirs) || ruleDirs.length === 0) { + console.warn( + chalk.yellow( + `[lint.extra] skipping '${globPattern}': value must be a non-empty array of rule directories`, + ), + ); + continue; + } - try { - if (options.verbose) { - console.log(chalk.blue("lint"), chalk.gray("Running lintoko:")); - console.log(chalk.gray(lintokoBinPath)); - console.log(chalk.gray(JSON.stringify(args))); - } + for (const dir of ruleDirs) { + if (!existsSync(path.join(rootDir, dir))) { + cliError( + `[lint.extra] rule directory '${dir}' not found (referenced by glob '${globPattern}')`, + ); + } + } - const result = await execa(lintokoBinPath, args, { - cwd: rootDir, - stdio: "inherit", - reject: false, - }); + let matchedFiles = globSync(path.join(rootDir, globPattern), { + ...MOTOKO_GLOB_CONFIG, + cwd: rootDir, + }); - if (result.exitCode !== 0) { - cliError(`Lint failed with exit code ${result.exitCode}`); - } + if (baseFileSet) { + matchedFiles = matchedFiles.filter((f) => + baseFileSet.has(path.resolve(rootDir, f)), + ); + } - if (options.fix) { - console.log(chalk.green("✓ Lint fixes applied")); - } else { - console.log(chalk.green("✓ Lint succeeded")); + if (matchedFiles.length === 0) { + console.warn( + chalk.yellow( + `[lint.extra] no files matched glob '${globPattern}', skipping`, + ), + ); + continue; + } + + const extraArgs: string[] = [...commonArgs]; + for (const dir of ruleDirs) { + extraArgs.push("--rules", dir); + } + extraArgs.push(...matchedFiles); + + const passed = await runLintoko( + lintokoBinPath, + rootDir, + extraArgs, + options, + `extra: ${globPattern}`, + ); + failed ||= !passed; } - } catch (err: any) { - cliError( - `Error while running lintoko${err?.message ? `\n${err.message}` : ""}`, - ); + } + + if (failed) { + cliError("Lint failed"); + } else if (options.fix) { + console.log(chalk.green("✓ Lint fixes applied")); + } else { + console.log(chalk.green("✓ Lint succeeded")); } } diff --git a/cli/tests/__snapshots__/lint.test.ts.snap b/cli/tests/__snapshots__/lint.test.ts.snap index 79adc59f..8b5a2c92 100644 --- a/cli/tests/__snapshots__/lint.test.ts.snap +++ b/cli/tests/__snapshots__/lint.test.ts.snap @@ -1,5 +1,163 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`lint [lint.extra] --rules CLI flag does not affect extra runs, multi-rules 1`] = ` +{ + "exitCode": 1, + "stderr": " × [ERROR]: no-bool-switch + ╭─[/lint-extra-with-cli-rules/src/Restricted.mo:3:5] + 2 │ public func boolSwitch(b : Bool) : Bool { + 3 │ ╭─▶ switch (b) { + 4 │ │ case false { false }; + 5 │ │ case true { true }; + 6 │ ├─▶ }; + · ╰──── Don't switch on boolean values, use if instead + 7 │ }; + ╰──── + + × [ERROR]: no-bool-switch-2 + ╭─[/lint-extra-with-cli-rules/src/Restricted.mo:3:5] + 2 │ public func boolSwitch(b : Bool) : Bool { + 3 │ ╭─▶ switch (b) { + 4 │ │ case false { false }; + 5 │ │ case true { true }; + 6 │ ├─▶ }; + · ╰──── Duplicate detection: don't switch on boolean values + 7 │ }; + ╰──── + +Error: Found 2 errors +Lint failed", + "stdout": "lint Running lintoko (base): +/lintoko/0.7.0/lintoko +["--verbose","--rules","empty-rules","/lint-extra-with-cli-rules/src/Restricted.mo","/lint-extra-with-cli-rules/src/Ok.mo"] +DEBUG file input: /lint-extra-with-cli-rules/src/Restricted.mo +DEBUG file input: /lint-extra-with-cli-rules/src/Ok.mo +DEBUG Loading rules from: empty-rules +DEBUG Linting file: /lint-extra-with-cli-rules/src/Ok.mo +DEBUG Linting file: /lint-extra-with-cli-rules/src/Restricted.mo +lint Running lintoko (extra: src/Restricted.mo): +/lintoko/0.7.0/lintoko +["--verbose","--rules","../lint/lints","--rules","rules-b","/lint-extra-with-cli-rules/src/Restricted.mo"] +DEBUG file input: /lint-extra-with-cli-rules/src/Restricted.mo +DEBUG Loading rules from: ../lint/lints +DEBUG Parsing extra rule at: ../lint/lints/no-bool-switch.toml +DEBUG Loading rules from: rules-b +DEBUG Parsing extra rule at: rules-b/no-bool-switch-2.toml +DEBUG Linting file: /lint-extra-with-cli-rules/src/Restricted.mo", +} +`; + +exports[`lint [lint.extra] base rules still run alongside extra rules 1`] = ` +{ + "exitCode": 1, + "stderr": " × [ERROR]: no-bool-switch + ╭─[/lint-extra-with-base/src/BadBase.mo:3:5] + 2 │ public func boolSwitch(b : Bool) : Bool { + 3 │ ╭─▶ switch (b) { + 4 │ │ case false { false }; + 5 │ │ case true { true }; + 6 │ ├─▶ }; + · ╰──── Don't switch on boolean values, use if instead + 7 │ }; + ╰──── + +Error: Found 1 errors +Lint failed", + "stdout": "", +} +`; + +exports[`lint [lint.extra] edge cases: pass, empty value, no-match, missing dir 1`] = ` +{ + "exitCode": 1, + "stderr": "[lint.extra] skipping 'src/also-missing/*.mo': value must be a non-empty array of rule directories +[lint.extra] no files matched glob 'src/nonexistent/*.mo', skipping +[lint.extra] rule directory 'nonexistent-rules' not found (referenced by glob 'src/*.mo')", + "stdout": "", +} +`; + +exports[`lint [lint.extra] example rules: no-types, types-only, migration-only 1`] = ` +{ + "exitCode": 1, + "stderr": " × [ERROR]: no-types + ╭─[/lint-extra-example-rules/src/Main.mo:6:10] + 5 │ + 6 │ ╭─▶ public type User = { + 7 │ │ name : Text; + 8 │ │ age : Nat; + 9 │ ├─▶ }; + · ╰──── File must not contain type declarations. Move types to a separate Types module. + 10 │ }; + ╰──── + +Error: Found 1 errors + × [ERROR]: types-only + ╭─[/lint-extra-example-rules/src/Types.mo:7:10] + 6 │ + 7 │ ╭─▶ public func helper() : Text { + 8 │ │ "oops"; + 9 │ ├─▶ }; + · ╰──── File must contain only type declarations. No functions, classes, or variable bindings. + 10 │ }; + ╰──── + +Error: Found 1 errors + × [ERROR]: migration-only + ╭─[/lint-extra-example-rules/src/Migration.mo:6:10] + 5 │ + 6 │ ╭─▶ public func notAllowed(old : {}) : {} { + 7 │ │ {}; + 8 │ ├─▶ }; + · ╰──── Only migration() may be a public function. + 9 │ }; + ╰──── + +Error: Found 1 errors +Lint failed", + "stdout": "", +} +`; + +exports[`lint [lint.extra] extra rules on glob-matched files 1`] = ` +{ + "exitCode": 1, + "stderr": " × [ERROR]: no-bool-switch + ╭─[/lint-extra/src/restricted/B.mo:3:5] + 2 │ public func anotherBoolSwitch(b : Bool) : Bool { + 3 │ ╭─▶ switch (b) { + 4 │ │ case true { false }; + 5 │ │ case false { true }; + 6 │ ├─▶ }; + · ╰──── Don't switch on boolean values, use if instead + 7 │ }; + ╰──── + + × [ERROR]: no-bool-switch + ╭─[/lint-extra/src/restricted/Restricted.mo:3:5] + 2 │ public func boolSwitch(b : Bool) : Bool { + 3 │ ╭─▶ switch (b) { + 4 │ │ case false { false }; + 5 │ │ case true { true }; + 6 │ ├─▶ }; + · ╰──── Don't switch on boolean values, use if instead + 7 │ }; + ╰──── + +Error: Found 2 errors +Lint failed", + "stdout": "", +} +`; + +exports[`lint [lint.extra] extra rules on glob-matched files 2`] = ` +{ + "exitCode": 0, + "stderr": "[lint.extra] no files matched glob 'src/restricted/*.mo', skipping", + "stdout": "✓ Lint succeeded", +} +`; + exports[`lint error 1`] = ` { "exitCode": 1, @@ -15,8 +173,8 @@ exports[`lint error 1`] = ` ╰──── Error: Found 1 errors -Lint failed with exit code 1", - "stdout": "lint Running lintoko: +Lint failed", + "stdout": "lint Running lintoko (base): /lintoko/0.7.0/lintoko ["--verbose","--rules","lints","/lint/src/Ok.mo","/lint/src/NoBoolSwitch.mo"] DEBUG file input: /lint/src/Ok.mo @@ -43,8 +201,8 @@ exports[`lint error 2`] = ` ╰──── Error: Found 1 errors -Lint failed with exit code 1", - "stdout": "lint Running lintoko: +Lint failed", + "stdout": "lint Running lintoko (base): /lintoko/0.7.0/lintoko ["--verbose","--rules","lints","/lint/src/NoBoolSwitch.mo"] DEBUG file input: /lint/src/NoBoolSwitch.mo @@ -66,7 +224,7 @@ exports[`lint ok 1`] = ` { "exitCode": 0, "stderr": "", - "stdout": "lint Running lintoko: + "stdout": "lint Running lintoko (base): /lintoko/0.7.0/lintoko ["--verbose","--rules","lints","/lint/src/Ok.mo"] DEBUG file input: /lint/src/Ok.mo diff --git a/cli/tests/lint-extra-edge-cases/mops.toml b/cli/tests/lint-extra-edge-cases/mops.toml new file mode 100644 index 00000000..b418ffb9 --- /dev/null +++ b/cli/tests/lint-extra-edge-cases/mops.toml @@ -0,0 +1,8 @@ +[toolchain] +lintoko = "0.7.0" + +[lint.extra] +"src/Clean.mo" = ["../lint/lints"] +"src/also-missing/*.mo" = [] +"src/nonexistent/*.mo" = ["../lint/lints"] +"src/*.mo" = ["nonexistent-rules"] diff --git a/cli/tests/lint-extra-edge-cases/src/Clean.mo b/cli/tests/lint-extra-edge-cases/src/Clean.mo new file mode 100644 index 00000000..2dc14cf6 --- /dev/null +++ b/cli/tests/lint-extra-edge-cases/src/Clean.mo @@ -0,0 +1,5 @@ +module { + public func greet(name : Text) : Text { + "Hello, " # name # "!"; + }; +}; diff --git a/cli/tests/lint-extra-example-rules/lint/migration-only/migration-only.toml b/cli/tests/lint-extra-example-rules/lint/migration-only/migration-only.toml new file mode 100644 index 00000000..20223614 --- /dev/null +++ b/cli/tests/lint-extra-example-rules/lint/migration-only/migration-only.toml @@ -0,0 +1,9 @@ +name = "migration-only" +description = "Only migration() may be a public function." +query = """ +(dec_field + "public" + (func_dec + (identifier) @name + (#not-eq? @name "migration")) @error) +""" diff --git a/cli/tests/lint-extra-example-rules/lint/no-types/no-types.toml b/cli/tests/lint-extra-example-rules/lint/no-types/no-types.toml new file mode 100644 index 00000000..e6bdd862 --- /dev/null +++ b/cli/tests/lint-extra-example-rules/lint/no-types/no-types.toml @@ -0,0 +1,5 @@ +name = "no-types" +description = "File must not contain type declarations. Move types to a separate Types module." +query = """ +(typ_dec) @error +""" diff --git a/cli/tests/lint-extra-example-rules/lint/types-only/types-only.toml b/cli/tests/lint-extra-example-rules/lint/types-only/types-only.toml new file mode 100644 index 00000000..aa604d65 --- /dev/null +++ b/cli/tests/lint-extra-example-rules/lint/types-only/types-only.toml @@ -0,0 +1,6 @@ +name = "types-only" +description = "File must contain only type declarations. No functions, classes, or variable bindings." +query = """ +(dec_field (_) @error) +(dec_field (typ_dec) @filter) +""" diff --git a/cli/tests/lint-extra-example-rules/mops.toml b/cli/tests/lint-extra-example-rules/mops.toml new file mode 100644 index 00000000..4f355e1c --- /dev/null +++ b/cli/tests/lint-extra-example-rules/mops.toml @@ -0,0 +1,7 @@ +[toolchain] +lintoko = "0.7.0" + +[lint.extra] +"src/Main.mo" = ["lint/no-types"] +"src/Types.mo" = ["lint/types-only"] +"src/Migration.mo" = ["lint/migration-only"] diff --git a/cli/tests/lint-extra-example-rules/src/Main.mo b/cli/tests/lint-extra-example-rules/src/Main.mo new file mode 100644 index 00000000..def22777 --- /dev/null +++ b/cli/tests/lint-extra-example-rules/src/Main.mo @@ -0,0 +1,10 @@ +actor { + public func greet(name : Text) : async Text { + "Hello, " # name # "!"; + }; + + public type User = { + name : Text; + age : Nat; + }; +}; diff --git a/cli/tests/lint-extra-example-rules/src/Migration.mo b/cli/tests/lint-extra-example-rules/src/Migration.mo new file mode 100644 index 00000000..7409ec9e --- /dev/null +++ b/cli/tests/lint-extra-example-rules/src/Migration.mo @@ -0,0 +1,9 @@ +module { + public func migration(old : {}) : {} { + {}; + }; + + public func notAllowed(old : {}) : {} { + {}; + }; +}; diff --git a/cli/tests/lint-extra-example-rules/src/Types.mo b/cli/tests/lint-extra-example-rules/src/Types.mo new file mode 100644 index 00000000..78f88aa3 --- /dev/null +++ b/cli/tests/lint-extra-example-rules/src/Types.mo @@ -0,0 +1,10 @@ +module { + public type User = { + name : Text; + age : Nat; + }; + + public func helper() : Text { + "oops"; + }; +}; diff --git a/cli/tests/lint-extra-with-base/mops.toml b/cli/tests/lint-extra-with-base/mops.toml new file mode 100644 index 00000000..b201c1c2 --- /dev/null +++ b/cli/tests/lint-extra-with-base/mops.toml @@ -0,0 +1,8 @@ +[toolchain] +lintoko = "0.7.0" + +[lint] +rules = ["../lint/lints"] + +[lint.extra] +"src/Restricted.mo" = ["../lint/lints"] diff --git a/cli/tests/lint-extra-with-base/src/BadBase.mo b/cli/tests/lint-extra-with-base/src/BadBase.mo new file mode 100644 index 00000000..7d9f20e5 --- /dev/null +++ b/cli/tests/lint-extra-with-base/src/BadBase.mo @@ -0,0 +1,8 @@ +module { + public func boolSwitch(b : Bool) : Bool { + switch (b) { + case false { false }; + case true { true }; + }; + }; +}; diff --git a/cli/tests/lint-extra-with-base/src/Ok.mo b/cli/tests/lint-extra-with-base/src/Ok.mo new file mode 100644 index 00000000..2dc14cf6 --- /dev/null +++ b/cli/tests/lint-extra-with-base/src/Ok.mo @@ -0,0 +1,5 @@ +module { + public func greet(name : Text) : Text { + "Hello, " # name # "!"; + }; +}; diff --git a/cli/tests/lint-extra-with-base/src/Restricted.mo b/cli/tests/lint-extra-with-base/src/Restricted.mo new file mode 100644 index 00000000..2dc14cf6 --- /dev/null +++ b/cli/tests/lint-extra-with-base/src/Restricted.mo @@ -0,0 +1,5 @@ +module { + public func greet(name : Text) : Text { + "Hello, " # name # "!"; + }; +}; diff --git a/cli/tests/lint-extra-with-cli-rules/empty-rules/.gitkeep b/cli/tests/lint-extra-with-cli-rules/empty-rules/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cli/tests/lint-extra-with-cli-rules/mops.toml b/cli/tests/lint-extra-with-cli-rules/mops.toml new file mode 100644 index 00000000..edf66056 --- /dev/null +++ b/cli/tests/lint-extra-with-cli-rules/mops.toml @@ -0,0 +1,5 @@ +[toolchain] +lintoko = "0.7.0" + +[lint.extra] +"src/Restricted.mo" = ["../lint/lints", "rules-b"] diff --git a/cli/tests/lint-extra-with-cli-rules/rules-b/no-bool-switch-2.toml b/cli/tests/lint-extra-with-cli-rules/rules-b/no-bool-switch-2.toml new file mode 100644 index 00000000..2e578472 --- /dev/null +++ b/cli/tests/lint-extra-with-cli-rules/rules-b/no-bool-switch-2.toml @@ -0,0 +1,9 @@ +name = "no-bool-switch-2" +description = "Duplicate detection: don't switch on boolean values" +query = """ +(switch_exp + (case [ + (lit_pat (bool_literal)) + (tup_pat . (lit_pat (bool_literal)) @trailing) + ])) @error +""" diff --git a/cli/tests/lint-extra-with-cli-rules/src/Ok.mo b/cli/tests/lint-extra-with-cli-rules/src/Ok.mo new file mode 100644 index 00000000..2dc14cf6 --- /dev/null +++ b/cli/tests/lint-extra-with-cli-rules/src/Ok.mo @@ -0,0 +1,5 @@ +module { + public func greet(name : Text) : Text { + "Hello, " # name # "!"; + }; +}; diff --git a/cli/tests/lint-extra-with-cli-rules/src/Restricted.mo b/cli/tests/lint-extra-with-cli-rules/src/Restricted.mo new file mode 100644 index 00000000..7d9f20e5 --- /dev/null +++ b/cli/tests/lint-extra-with-cli-rules/src/Restricted.mo @@ -0,0 +1,8 @@ +module { + public func boolSwitch(b : Bool) : Bool { + switch (b) { + case false { false }; + case true { true }; + }; + }; +}; diff --git a/cli/tests/lint-extra/mops.toml b/cli/tests/lint-extra/mops.toml new file mode 100644 index 00000000..2c8f5f9f --- /dev/null +++ b/cli/tests/lint-extra/mops.toml @@ -0,0 +1,5 @@ +[toolchain] +lintoko = "0.7.0" + +[lint.extra] +"src/restricted/*.mo" = ["../lint/lints"] diff --git a/cli/tests/lint-extra/src/Ok.mo b/cli/tests/lint-extra/src/Ok.mo new file mode 100644 index 00000000..2dc14cf6 --- /dev/null +++ b/cli/tests/lint-extra/src/Ok.mo @@ -0,0 +1,5 @@ +module { + public func greet(name : Text) : Text { + "Hello, " # name # "!"; + }; +}; diff --git a/cli/tests/lint-extra/src/restricted/B.mo b/cli/tests/lint-extra/src/restricted/B.mo new file mode 100644 index 00000000..070ee6e2 --- /dev/null +++ b/cli/tests/lint-extra/src/restricted/B.mo @@ -0,0 +1,8 @@ +module { + public func anotherBoolSwitch(b : Bool) : Bool { + switch (b) { + case true { false }; + case false { true }; + }; + }; +}; diff --git a/cli/tests/lint-extra/src/restricted/Restricted.mo b/cli/tests/lint-extra/src/restricted/Restricted.mo new file mode 100644 index 00000000..7d9f20e5 --- /dev/null +++ b/cli/tests/lint-extra/src/restricted/Restricted.mo @@ -0,0 +1,8 @@ +module { + public func boolSwitch(b : Bool) : Bool { + switch (b) { + case false { false }; + case true { true }; + }; + }; +}; diff --git a/cli/tests/lint.test.ts b/cli/tests/lint.test.ts index a1c58dcc..af434af1 100644 --- a/cli/tests/lint.test.ts +++ b/cli/tests/lint.test.ts @@ -44,4 +44,46 @@ describe("lint", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toMatch(/not found in dependencies/); }); + + describe("[lint.extra]", () => { + test("extra rules on glob-matched files", async () => { + // src/restricted/*.mo has violations, Ok.mo does not. + // Extra rules apply only to the glob match → fails on restricted/ files. + // Filter "Ok" narrows scope so extra is skipped → passes. + const cwd = path.join(import.meta.dirname, "lint-extra"); + await cliSnapshot(["lint"], { cwd }, 1); + await cliSnapshot(["lint", "Ok"], { cwd }, 0); + }); + + test("edge cases: pass, empty value, no-match, missing dir", async () => { + // Single fixture with 4 entries processed in order: + // 1. Clean.mo + valid rules → passes + // 2. empty array → warns and skips + // 3. non-matching glob → warns and skips + // 4. missing rule dir → errors + const cwd = path.join(import.meta.dirname, "lint-extra-edge-cases"); + await cliSnapshot(["lint"], { cwd }, 1); + }); + + test("base rules still run alongside extra rules", async () => { + const cwd = path.join(import.meta.dirname, "lint-extra-with-base"); + await cliSnapshot(["lint"], { cwd }, 1); + }); + + test("--rules CLI flag does not affect extra runs, multi-rules", async () => { + // --rules overrides base with an empty dir (no base violations). + // Extra runs independently with two rule dirs → Restricted.mo fails. + const cwd = path.join(import.meta.dirname, "lint-extra-with-cli-rules"); + await cliSnapshot( + ["lint", "--rules", "empty-rules", "--verbose"], + { cwd }, + 1, + ); + }); + + test("example rules: no-types, types-only, migration-only", async () => { + const cwd = path.join(import.meta.dirname, "lint-extra-example-rules"); + await cliSnapshot(["lint"], { cwd }, 1); + }); + }); }); diff --git a/cli/types.ts b/cli/types.ts index 374aa564..cbbf92aa 100644 --- a/cli/types.ts +++ b/cli/types.ts @@ -31,6 +31,7 @@ export type Config = { args?: string[]; rules?: string[]; extends?: string[] | true; + extra?: Record; }; }; diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 1a59d793..b1d7d2ef 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -170,6 +170,24 @@ rules = ["my-rules"] extends = ["some-pkg"] ``` +### [lint.extra] + +Map file globs to additional rule directories. Each entry runs a separate `lintoko` invocation on the matched files, **in addition** to the base rules that always apply to all files. + +| Key (glob) | Value (string array) | +| ---------- | --------------------------------------------- | +| File glob | Array of rule directory paths to apply | + +Example: +```toml +[lint.extra] +"src/main.mo" = ["lint/no-types"] +"src/Types.mo" = ["lint/types-only"] +"migrations/*.mo" = ["lint/migration-only", "lint/no-types"] +``` + +Globs that match no files are skipped with a warning. All runs (base and extra) execute even when earlier runs find errors, so you see every failure in a single pass. The `--rules` CLI flag does not affect `[lint.extra]` entries. + ## [requirements] diff --git a/docs/docs/cli/4-dev/07-mops-lint.md b/docs/docs/cli/4-dev/07-mops-lint.md index 8999ba9b..3f82a8bd 100644 --- a/docs/docs/cli/4-dev/07-mops-lint.md +++ b/docs/docs/cli/4-dev/07-mops-lint.md @@ -97,13 +97,28 @@ Rules from `[lint] extends` are always included on top, regardless of this setti ### `args` -Extra flags forwarded to `lintoko`: +Extra flags forwarded to `lintoko` for all invocations (base and `[lint.extra]` runs): ```toml [lint] args = ["--severity", "warning"] ``` +### `extra` + +Apply additional lint rules to specific files or directories. Each key is a glob pattern matched against project files, and the value is an array of rule directories. These extra rules run **in addition to** the base rules — they never replace them. + +```toml +[lint.extra] +"src/main.mo" = ["lint/no-types"] +"src/Types.mo" = ["lint/types-only"] +"migrations/*.mo" = ["lint/migration-only", "lint/no-types"] +``` + +Each entry triggers a separate `lintoko` invocation on the matched files. All runs (base and extra) execute even when earlier runs find errors, so you see every lint failure in a single pass. If any invocation fails, `mops lint` fails. Globs that match no files are skipped with a warning. + +The `--rules` CLI flag only overrides the **base** rule directories — `[lint.extra]` entries always run independently. + ### Combining options ```toml @@ -111,10 +126,13 @@ args = ["--severity", "warning"] extends = ["base"] rules = ["my-extra-rules"] args = ["--severity", "warning"] + +[lint.extra] +"src/Types.mo" = ["lint/types-only"] ``` :::tip -The `--rules` CLI flag overrides all configured rule directories (including `[lint] rules`, `extends`, and the default `lint/`/`lints/`). Use it for one-off overrides without changing `mops.toml`. +The `--rules` CLI flag overrides all configured rule directories (including `[lint] rules`, `extends`, and the default `lint/`/`lints/`). Use it for one-off overrides without changing `mops.toml`. It does not affect `[lint.extra]` entries. ::: ## Publishing rules with a package