ic-mops 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/bundle/cli.tgz +0 -0
  3. package/cli.ts +11 -1
  4. package/commands/build.ts +127 -92
  5. package/commands/check-stable.ts +9 -10
  6. package/commands/info.ts +103 -0
  7. package/commands/lint.ts +132 -42
  8. package/commands/toolchain/moc.ts +5 -5
  9. package/dist/cli.js +9 -0
  10. package/dist/commands/build.js +102 -69
  11. package/dist/commands/check-stable.js +9 -10
  12. package/dist/commands/info.d.ts +4 -0
  13. package/dist/commands/info.js +75 -0
  14. package/dist/commands/lint.js +84 -37
  15. package/dist/commands/toolchain/moc.js +5 -5
  16. package/dist/package.json +3 -1
  17. package/dist/templates/mops-publish.yml +1 -1
  18. package/dist/templates/mops-test.yml +1 -1
  19. package/dist/tests/build.test.js +17 -0
  20. package/dist/tests/check-stable.test.js +11 -0
  21. package/dist/tests/lint.test.js +33 -0
  22. package/dist/types.d.ts +1 -0
  23. package/dist/wasm/pkg/nodejs/wasm_bg.wasm +0 -0
  24. package/dist/wasm/pkg/web/wasm_bg.wasm +0 -0
  25. package/package.json +3 -1
  26. package/templates/mops-publish.yml +1 -1
  27. package/templates/mops-test.yml +1 -1
  28. package/tests/__snapshots__/lint.test.ts.snap +163 -5
  29. package/tests/build.test.ts +17 -0
  30. package/tests/check-stable/actor-idl/mops.toml +8 -0
  31. package/tests/check-stable/actor-idl/new.mo +6 -0
  32. package/tests/check-stable/actor-idl/old.mo +5 -0
  33. package/tests/check-stable/actor-idl/system-idl/aaaaa-aa.did +3 -0
  34. package/tests/check-stable.test.ts +12 -0
  35. package/tests/lint-extra/mops.toml +5 -0
  36. package/tests/lint-extra/src/Ok.mo +5 -0
  37. package/tests/lint-extra/src/restricted/B.mo +8 -0
  38. package/tests/lint-extra/src/restricted/Restricted.mo +8 -0
  39. package/tests/lint-extra-edge-cases/mops.toml +8 -0
  40. package/tests/lint-extra-edge-cases/src/Clean.mo +5 -0
  41. package/tests/lint-extra-example-rules/lint/migration-only/migration-only.toml +9 -0
  42. package/tests/lint-extra-example-rules/lint/no-types/no-types.toml +5 -0
  43. package/tests/lint-extra-example-rules/lint/types-only/types-only.toml +6 -0
  44. package/tests/lint-extra-example-rules/mops.toml +7 -0
  45. package/tests/lint-extra-example-rules/src/Main.mo +10 -0
  46. package/tests/lint-extra-example-rules/src/Migration.mo +9 -0
  47. package/tests/lint-extra-example-rules/src/Types.mo +10 -0
  48. package/tests/lint-extra-with-base/mops.toml +8 -0
  49. package/tests/lint-extra-with-base/src/BadBase.mo +8 -0
  50. package/tests/lint-extra-with-base/src/Ok.mo +5 -0
  51. package/tests/lint-extra-with-base/src/Restricted.mo +5 -0
  52. package/tests/lint-extra-with-cli-rules/empty-rules/.gitkeep +0 -0
  53. package/tests/lint-extra-with-cli-rules/mops.toml +5 -0
  54. package/tests/lint-extra-with-cli-rules/rules-b/no-bool-switch-2.toml +9 -0
  55. package/tests/lint-extra-with-cli-rules/src/Ok.mo +5 -0
  56. package/tests/lint-extra-with-cli-rules/src/Restricted.mo +8 -0
  57. package/tests/lint.test.ts +42 -0
  58. package/types.ts +1 -0
  59. package/wasm/pkg/nodejs/wasm_bg.wasm +0 -0
  60. package/wasm/pkg/web/wasm_bg.wasm +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## Next
4
4
 
5
+ ## 2.9.0
6
+ - Add `mops info <pkg>` command to show detailed package metadata from the registry
7
+ - Add `[lint.extra]` config for applying additional lint rules to specific files via glob patterns
8
+
9
+ ## 2.8.1
10
+
11
+ - Fix `mops check-stable` failing when `[moc] args` contains flags with relative paths (e.g. `--actor-idl=system-idl`)
12
+
5
13
  ## 2.8.0
6
14
 
7
15
  - `mops build` now generates a `.most` (Motoko stable types) file alongside `.wasm` and `.did` for each canister; the `.most` file can be passed directly to `mops check-stable` to verify upgrade compatibility
package/bundle/cli.tgz CHANGED
Binary file
package/cli.ts CHANGED
@@ -8,7 +8,7 @@ import { getNetwork } from "./api/network.js";
8
8
  import { cacheSize, cleanCache, show } from "./cache.js";
9
9
  import { add } from "./commands/add.js";
10
10
  import { bench } from "./commands/bench.js";
11
- import { build, DEFAULT_BUILD_OUTPUT_DIR } from "./commands/build.js";
11
+ import { build } from "./commands/build.js";
12
12
  import { bump } from "./commands/bump.js";
13
13
  import { check } from "./commands/check.js";
14
14
  import { checkCandid } from "./commands/check-candid.js";
@@ -16,6 +16,7 @@ import { checkStable } from "./commands/check-stable.js";
16
16
  import { docsCoverage } from "./commands/docs-coverage.js";
17
17
  import { docs } from "./commands/docs.js";
18
18
  import { format } from "./commands/format.js";
19
+ import { info } from "./commands/info.js";
19
20
  import { init } from "./commands/init.js";
20
21
  import { lint } from "./commands/lint.js";
21
22
  import { installAll } from "./commands/install/install-all.js";
@@ -279,6 +280,15 @@ program
279
280
  await search(text);
280
281
  });
281
282
 
283
+ // info
284
+ program
285
+ .command("info <pkg>")
286
+ .description("Show detailed information about a package from the registry")
287
+ .option("--versions", "List all published versions, one per line")
288
+ .action(async (pkg: string, options) => {
289
+ await info(pkg, options);
290
+ });
291
+
282
292
  // cache
283
293
  program
284
294
  .command("cache")
package/commands/build.ts CHANGED
@@ -3,6 +3,7 @@ import { execa } from "execa";
3
3
  import { exists } from "fs-extra";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
+ import { lock, unlockSync } from "proper-lockfile";
6
7
  import { cliError } from "../error.js";
7
8
  import { isCandidCompatible } from "../helpers/is-candid-compatible.js";
8
9
  import { resolveCanisterConfigs } from "../helpers/resolve-canisters.js";
@@ -70,115 +71,149 @@ export async function build(
70
71
  motokoPath = resolveConfigPath(motokoPath);
71
72
  const wasmPath = join(outputDir, `${canisterName}.wasm`);
72
73
  const mostPath = join(outputDir, `${canisterName}.most`);
73
- let args = [
74
- "-c",
75
- "--idl",
76
- "--stable-types",
77
- "-o",
78
- wasmPath,
79
- motokoPath,
80
- ...(await sourcesArgs()).flat(),
81
- ...getGlobalMocArgs(config),
82
- ];
83
- args.push(
84
- ...collectExtraArgs(config, canister, canisterName, options.extraArgs),
85
- );
86
-
87
- const isPublicCandid = true; // always true for now to reduce corner cases
88
- const candidVisibility = isPublicCandid ? "icp:public" : "icp:private";
89
- if (isPublicCandid) {
90
- args.push("--public-metadata", "candid:service");
91
- args.push("--public-metadata", "candid:args");
74
+
75
+ // per-canister lock to prevent parallel builds of the same canister from clobbering output files
76
+ const lockTarget = join(outputDir, `.${canisterName}.buildlock`);
77
+ await writeFile(lockTarget, "", { flag: "a" });
78
+
79
+ let release: (() => Promise<void>) | undefined;
80
+ try {
81
+ release = await lock(lockTarget, {
82
+ stale: 300_000,
83
+ retries: { retries: 60, minTimeout: 500, maxTimeout: 5_000 },
84
+ });
85
+ } catch {
86
+ cliError(
87
+ `Failed to acquire build lock for canister ${canisterName} — another build may be stuck`,
88
+ );
92
89
  }
90
+
91
+ // proper-lockfile registers its own signal-exit handler, but it doesn't reliably
92
+ // fire on process.exit(). This manual handler covers that gap. Double-unlock is
93
+ // harmless (the second call throws and is caught).
94
+ const exitCleanup = () => {
95
+ try {
96
+ unlockSync(lockTarget);
97
+ } catch {}
98
+ };
99
+ process.on("exit", exitCleanup);
100
+
93
101
  try {
94
- if (options.verbose) {
95
- console.log(chalk.gray(mocPath, JSON.stringify(args)));
102
+ let args = [
103
+ "-c",
104
+ "--idl",
105
+ "--stable-types",
106
+ "-o",
107
+ wasmPath,
108
+ motokoPath,
109
+ ...(await sourcesArgs()).flat(),
110
+ ...getGlobalMocArgs(config),
111
+ ];
112
+ args.push(
113
+ ...collectExtraArgs(config, canister, canisterName, options.extraArgs),
114
+ );
115
+
116
+ const isPublicCandid = true; // always true for now to reduce corner cases
117
+ const candidVisibility = isPublicCandid ? "icp:public" : "icp:private";
118
+ if (isPublicCandid) {
119
+ args.push("--public-metadata", "candid:service");
120
+ args.push("--public-metadata", "candid:args");
96
121
  }
97
- const result = await execa(mocPath, args, {
98
- stdio: options.verbose ? "inherit" : "pipe",
99
- reject: false,
100
- });
122
+ try {
123
+ if (options.verbose) {
124
+ console.log(chalk.gray(mocPath, JSON.stringify(args)));
125
+ }
126
+ const result = await execa(mocPath, args, {
127
+ stdio: options.verbose ? "inherit" : "pipe",
128
+ reject: false,
129
+ });
101
130
 
102
- if (result.exitCode !== 0) {
103
- if (!options.verbose) {
104
- if (result.stderr) {
105
- console.error(chalk.red(result.stderr));
106
- }
107
- if (result.stdout?.trim()) {
108
- console.error(chalk.yellow("Build output:"));
109
- console.error(result.stdout);
131
+ if (result.exitCode !== 0) {
132
+ if (!options.verbose) {
133
+ if (result.stderr) {
134
+ console.error(chalk.red(result.stderr));
135
+ }
136
+ if (result.stdout?.trim()) {
137
+ console.error(chalk.yellow("Build output:"));
138
+ console.error(result.stdout);
139
+ }
110
140
  }
141
+ cliError(
142
+ `Build failed for canister ${canisterName} (exit code: ${result.exitCode})`,
143
+ );
111
144
  }
112
- cliError(
113
- `Build failed for canister ${canisterName} (exit code: ${result.exitCode})`,
114
- );
115
- }
116
-
117
- if (options.verbose && result.stdout && result.stdout.trim()) {
118
- console.log(result.stdout);
119
- }
120
145
 
121
- options.verbose &&
122
- console.log(chalk.gray(`Stable types written to ${mostPath}`));
146
+ if (options.verbose && result.stdout && result.stdout.trim()) {
147
+ console.log(result.stdout);
148
+ }
123
149
 
124
- const generatedDidPath = join(outputDir, `${canisterName}.did`);
125
- const resolvedCandidPath = canister.candid
126
- ? resolveConfigPath(canister.candid)
127
- : null;
150
+ options.verbose &&
151
+ console.log(chalk.gray(`Stable types written to ${mostPath}`));
128
152
 
129
- if (resolvedCandidPath) {
130
- try {
131
- const compatible = await isCandidCompatible(
132
- generatedDidPath,
133
- resolvedCandidPath,
134
- );
153
+ const generatedDidPath = join(outputDir, `${canisterName}.did`);
154
+ const resolvedCandidPath = canister.candid
155
+ ? resolveConfigPath(canister.candid)
156
+ : null;
135
157
 
136
- if (!compatible) {
137
- cliError(
138
- `Candid compatibility check failed for canister ${canisterName}`,
158
+ if (resolvedCandidPath) {
159
+ try {
160
+ const compatible = await isCandidCompatible(
161
+ generatedDidPath,
162
+ resolvedCandidPath,
139
163
  );
140
- }
141
164
 
142
- if (options.verbose) {
143
- console.log(
144
- chalk.gray(
145
- `Candid compatibility check passed for canister ${canisterName}`,
146
- ),
165
+ if (!compatible) {
166
+ cliError(
167
+ `Candid compatibility check failed for canister ${canisterName}`,
168
+ );
169
+ }
170
+
171
+ if (options.verbose) {
172
+ console.log(
173
+ chalk.gray(
174
+ `Candid compatibility check passed for canister ${canisterName}`,
175
+ ),
176
+ );
177
+ }
178
+ } catch (err: any) {
179
+ cliError(
180
+ `Error during Candid compatibility check for canister ${canisterName}${err?.message ? `\n${err.message}` : ""}`,
147
181
  );
148
182
  }
149
- } catch (err: any) {
150
- cliError(
151
- `Error during Candid compatibility check for canister ${canisterName}${err?.message ? `\n${err.message}` : ""}`,
152
- );
153
183
  }
154
- }
155
184
 
156
- options.verbose &&
157
- console.log(chalk.gray(`Adding metadata to ${wasmPath}`));
158
- const candidPath = resolvedCandidPath ?? generatedDidPath;
159
- const candidText = await readFile(candidPath, "utf-8");
160
- const customSections: CustomSection[] = [
161
- { name: `${candidVisibility} candid:service`, data: candidText },
162
- ];
163
- if (canister.initArg) {
164
- customSections.push({
165
- name: `${candidVisibility} candid:args`,
166
- data: canister.initArg,
167
- });
168
- }
169
- const wasmBytes = await readFile(wasmPath);
170
- const newWasm = getWasmBindings().add_custom_sections(
171
- wasmBytes,
172
- customSections,
173
- );
174
- await writeFile(wasmPath, newWasm);
175
- } catch (err: any) {
176
- if (err.message?.includes("Build failed for canister")) {
177
- throw err;
185
+ options.verbose &&
186
+ console.log(chalk.gray(`Adding metadata to ${wasmPath}`));
187
+ const candidPath = resolvedCandidPath ?? generatedDidPath;
188
+ const candidText = await readFile(candidPath, "utf-8");
189
+ const customSections: CustomSection[] = [
190
+ { name: `${candidVisibility} candid:service`, data: candidText },
191
+ ];
192
+ if (canister.initArg) {
193
+ customSections.push({
194
+ name: `${candidVisibility} candid:args`,
195
+ data: canister.initArg,
196
+ });
197
+ }
198
+ const wasmBytes = await readFile(wasmPath);
199
+ const newWasm = getWasmBindings().add_custom_sections(
200
+ wasmBytes,
201
+ customSections,
202
+ );
203
+ await writeFile(wasmPath, newWasm);
204
+ } catch (err: any) {
205
+ if (err.message?.includes("Build failed for canister")) {
206
+ throw err;
207
+ }
208
+ cliError(
209
+ `Error while compiling canister ${canisterName}${err?.message ? `\n${err.message}` : ""}`,
210
+ );
178
211
  }
179
- cliError(
180
- `Error while compiling canister ${canisterName}${err?.message ? `\n${err.message}` : ""}`,
181
- );
212
+ } finally {
213
+ process.removeListener("exit", exitCleanup);
214
+ try {
215
+ await release?.();
216
+ } catch {}
182
217
  }
183
218
  }
184
219
 
@@ -1,6 +1,6 @@
1
- import { basename, join, relative, resolve } from "node:path";
1
+ import { basename, join } from "node:path";
2
2
  import { existsSync, mkdirSync } from "node:fs";
3
- import { rename, rm } from "node:fs/promises";
3
+ import { rm } from "node:fs/promises";
4
4
  import chalk from "chalk";
5
5
  import { execa } from "execa";
6
6
  import { cliError } from "../error.js";
@@ -62,8 +62,7 @@ export async function runStableCheck(
62
62
  options = {},
63
63
  } = params;
64
64
 
65
- const checkStableDir = resolve(CHECK_STABLE_DIR);
66
- const sources = (await sourcesArgs({ cwd: checkStableDir })).flat();
65
+ const sources = (await sourcesArgs()).flat();
67
66
  const isOldMostFile = oldFile.endsWith(".most");
68
67
 
69
68
  if (!existsSync(oldFile)) {
@@ -137,10 +136,13 @@ async function generateStableTypes(
137
136
  globalMocArgs: string[],
138
137
  options: Partial<CheckStableOptions>,
139
138
  ): Promise<string> {
140
- const relFile = relative(resolve(CHECK_STABLE_DIR), resolve(moFile));
139
+ const base = basename(outputPath, ".most");
140
+ const wasmPath = join(CHECK_STABLE_DIR, base + ".wasm");
141
141
  const args = [
142
142
  "--stable-types",
143
- relFile,
143
+ "-o",
144
+ wasmPath,
145
+ moFile,
144
146
  ...sources,
145
147
  ...globalMocArgs,
146
148
  ...(options.extraArgs ?? []),
@@ -155,7 +157,6 @@ async function generateStableTypes(
155
157
  }
156
158
 
157
159
  const result = await execa(mocPath, args, {
158
- cwd: CHECK_STABLE_DIR,
159
160
  stdio: "pipe",
160
161
  reject: false,
161
162
  });
@@ -169,9 +170,7 @@ async function generateStableTypes(
169
170
  );
170
171
  }
171
172
 
172
- const base = basename(moFile, ".mo");
173
- await rename(join(CHECK_STABLE_DIR, base + ".most"), outputPath);
174
- await rm(join(CHECK_STABLE_DIR, base + ".wasm"), { force: true });
173
+ await rm(wasmPath, { force: true });
175
174
 
176
175
  return outputPath;
177
176
  }
@@ -0,0 +1,103 @@
1
+ import process from "node:process";
2
+ import chalk from "chalk";
3
+ import { mainActor } from "../api/actors.js";
4
+ import { resolveVersion } from "../api/resolveVersion.js";
5
+ import type { PackageDetails } from "../declarations/main/main.did.js";
6
+
7
+ function label(text: string): string {
8
+ return chalk.bold(text.padEnd(16));
9
+ }
10
+
11
+ export interface InfoOptions {
12
+ versions?: boolean;
13
+ }
14
+
15
+ export async function info(pkgArg: string, options: InfoOptions = {}) {
16
+ let [name, versionArg] = pkgArg.split("@") as [string, string | undefined];
17
+ let actor = await mainActor();
18
+
19
+ let version: string;
20
+ try {
21
+ version = await resolveVersion(name, versionArg ?? "");
22
+ } catch (err) {
23
+ let message = err instanceof Error ? err.message : String(err);
24
+ console.error(chalk.red("Error: ") + message);
25
+ process.exit(1);
26
+ }
27
+
28
+ let res = await actor.getPackageDetails(name, version);
29
+ if ("err" in res) {
30
+ console.error(chalk.red("Error: ") + res.err);
31
+ process.exit(1);
32
+ }
33
+
34
+ let d: PackageDetails = res.ok;
35
+ let c = d.config;
36
+
37
+ // d.versions is in ascending order (oldest first)
38
+ if (options.versions) {
39
+ for (let ver of d.versions) {
40
+ console.log(ver);
41
+ }
42
+ return;
43
+ }
44
+
45
+ console.log("");
46
+ console.log(
47
+ `${chalk.green.bold(c.name)}${chalk.gray("@")}${chalk.yellow(c.version)}`,
48
+ );
49
+
50
+ if (c.description) {
51
+ console.log(chalk.dim(c.description));
52
+ }
53
+
54
+ if (c.version !== d.highestVersion) {
55
+ console.log(chalk.yellow(`latest: ${d.highestVersion}`));
56
+ }
57
+
58
+ console.log("");
59
+
60
+ if (c.license) {
61
+ console.log(`${label("license")}${c.license}`);
62
+ }
63
+ if (c.repository) {
64
+ console.log(`${label("repository")}${chalk.cyan(c.repository)}`);
65
+ }
66
+ if (c.homepage) {
67
+ console.log(`${label("homepage")}${chalk.cyan(c.homepage)}`);
68
+ }
69
+ if (c.documentation) {
70
+ console.log(`${label("documentation")}${chalk.cyan(c.documentation)}`);
71
+ }
72
+
73
+ if (c.dependencies.length > 0) {
74
+ console.log("");
75
+ console.log(
76
+ `${label("dependencies")}${c.dependencies.map((dep) => `${dep.name}${chalk.gray("@")}${dep.version || dep.repo}`).join(", ")}`,
77
+ );
78
+ }
79
+ if (c.devDependencies.length > 0) {
80
+ console.log(
81
+ `${label("dev-deps")}${c.devDependencies.map((dep) => `${dep.name}${chalk.gray("@")}${dep.version || dep.repo}`).join(", ")}`,
82
+ );
83
+ }
84
+
85
+ if (c.keywords.length > 0) {
86
+ console.log("");
87
+ console.log(
88
+ `${label("keywords")}${c.keywords.map((k) => chalk.yellow(k)).join(", ")}`,
89
+ );
90
+ }
91
+
92
+ if (d.versions.length > 0) {
93
+ let versionsDisplay = d.versions.slice(-10).reverse().join(", ");
94
+ let extra =
95
+ d.versions.length > 10
96
+ ? ` ${chalk.gray(`(+${d.versions.length - 10} more)`)}`
97
+ : "";
98
+ console.log("");
99
+ console.log(`${label("versions")}${versionsDisplay}${extra}`);
100
+ }
101
+
102
+ console.log("");
103
+ }
package/commands/lint.ts CHANGED
@@ -103,6 +103,62 @@ export interface LintOptions {
103
103
  extraArgs: string[];
104
104
  }
105
105
 
106
+ function buildCommonArgs(
107
+ options: Partial<LintOptions>,
108
+ config: Config,
109
+ ): string[] {
110
+ const args: string[] = [];
111
+ if (options.verbose) {
112
+ args.push("--verbose");
113
+ }
114
+ if (options.fix) {
115
+ args.push("--fix");
116
+ }
117
+ if (config.lint?.args) {
118
+ if (typeof config.lint.args === "string") {
119
+ cliError(
120
+ `[lint] config 'args' should be an array of strings in mops.toml config file`,
121
+ );
122
+ }
123
+ args.push(...config.lint.args);
124
+ }
125
+ if (options.extraArgs && options.extraArgs.length > 0) {
126
+ args.push(...options.extraArgs);
127
+ }
128
+ return args;
129
+ }
130
+
131
+ async function runLintoko(
132
+ lintokoBinPath: string,
133
+ rootDir: string,
134
+ args: string[],
135
+ options: Partial<LintOptions>,
136
+ label: string,
137
+ ): Promise<boolean> {
138
+ try {
139
+ if (options.verbose) {
140
+ console.log(
141
+ chalk.blue("lint"),
142
+ chalk.gray(`Running lintoko (${label}):`),
143
+ );
144
+ console.log(chalk.gray(lintokoBinPath));
145
+ console.log(chalk.gray(JSON.stringify(args)));
146
+ }
147
+
148
+ const result = await execa(lintokoBinPath, args, {
149
+ cwd: rootDir,
150
+ stdio: "inherit",
151
+ reject: false,
152
+ });
153
+
154
+ return result.exitCode === 0;
155
+ } catch (err: any) {
156
+ cliError(
157
+ `Error while running lintoko${err?.message ? `\n${err.message}` : ""}`,
158
+ );
159
+ }
160
+ }
161
+
106
162
  export async function lint(
107
163
  filter: string | undefined,
108
164
  options: Partial<LintOptions>,
@@ -131,59 +187,93 @@ export async function lint(
131
187
  }
132
188
  }
133
189
 
134
- let args: string[] = [];
135
- if (options.verbose) {
136
- args.push("--verbose");
137
- }
138
- if (options.fix) {
139
- args.push("--fix");
140
- }
190
+ const commonArgs = buildCommonArgs(options, config);
191
+
192
+ // --- base run ---
193
+ const baseArgs: string[] = [...commonArgs];
141
194
  const rules =
142
195
  options.rules !== undefined
143
196
  ? options.rules
144
197
  : await collectLintRules(config, rootDir);
145
- rules.forEach((rule) => args.push("--rules", rule));
198
+ rules.forEach((rule) => baseArgs.push("--rules", rule));
199
+ baseArgs.push(...filesToLint);
146
200
 
147
- if (config.lint?.args) {
148
- if (typeof config.lint.args === "string") {
149
- cliError(
150
- `[lint] config 'args' should be an array of strings in mops.toml config file`,
151
- );
152
- }
153
- args.push(...config.lint.args);
154
- }
201
+ let failed = !(await runLintoko(
202
+ lintokoBinPath,
203
+ rootDir,
204
+ baseArgs,
205
+ options,
206
+ "base",
207
+ ));
155
208
 
156
- if (options.extraArgs && options.extraArgs.length > 0) {
157
- args.push(...options.extraArgs);
158
- }
209
+ // --- extra runs ---
210
+ const extraEntries = config.lint?.extra;
211
+ if (extraEntries) {
212
+ const isFiltered = filter || (options.files && options.files.length > 0);
213
+ const baseFileSet = isFiltered
214
+ ? new Set(filesToLint.map((f) => path.resolve(rootDir, f)))
215
+ : undefined;
159
216
 
160
- args.push(...filesToLint);
217
+ for (const [globPattern, ruleDirs] of Object.entries(extraEntries)) {
218
+ if (!Array.isArray(ruleDirs) || ruleDirs.length === 0) {
219
+ console.warn(
220
+ chalk.yellow(
221
+ `[lint.extra] skipping '${globPattern}': value must be a non-empty array of rule directories`,
222
+ ),
223
+ );
224
+ continue;
225
+ }
161
226
 
162
- try {
163
- if (options.verbose) {
164
- console.log(chalk.blue("lint"), chalk.gray("Running lintoko:"));
165
- console.log(chalk.gray(lintokoBinPath));
166
- console.log(chalk.gray(JSON.stringify(args)));
167
- }
227
+ for (const dir of ruleDirs) {
228
+ if (!existsSync(path.join(rootDir, dir))) {
229
+ cliError(
230
+ `[lint.extra] rule directory '${dir}' not found (referenced by glob '${globPattern}')`,
231
+ );
232
+ }
233
+ }
168
234
 
169
- const result = await execa(lintokoBinPath, args, {
170
- cwd: rootDir,
171
- stdio: "inherit",
172
- reject: false,
173
- });
235
+ let matchedFiles = globSync(path.join(rootDir, globPattern), {
236
+ ...MOTOKO_GLOB_CONFIG,
237
+ cwd: rootDir,
238
+ });
174
239
 
175
- if (result.exitCode !== 0) {
176
- cliError(`Lint failed with exit code ${result.exitCode}`);
177
- }
240
+ if (baseFileSet) {
241
+ matchedFiles = matchedFiles.filter((f) =>
242
+ baseFileSet.has(path.resolve(rootDir, f)),
243
+ );
244
+ }
178
245
 
179
- if (options.fix) {
180
- console.log(chalk.green("✓ Lint fixes applied"));
181
- } else {
182
- console.log(chalk.green("✓ Lint succeeded"));
246
+ if (matchedFiles.length === 0) {
247
+ console.warn(
248
+ chalk.yellow(
249
+ `[lint.extra] no files matched glob '${globPattern}', skipping`,
250
+ ),
251
+ );
252
+ continue;
253
+ }
254
+
255
+ const extraArgs: string[] = [...commonArgs];
256
+ for (const dir of ruleDirs) {
257
+ extraArgs.push("--rules", dir);
258
+ }
259
+ extraArgs.push(...matchedFiles);
260
+
261
+ const passed = await runLintoko(
262
+ lintokoBinPath,
263
+ rootDir,
264
+ extraArgs,
265
+ options,
266
+ `extra: ${globPattern}`,
267
+ );
268
+ failed ||= !passed;
183
269
  }
184
- } catch (err: any) {
185
- cliError(
186
- `Error while running lintoko${err?.message ? `\n${err.message}` : ""}`,
187
- );
270
+ }
271
+
272
+ if (failed) {
273
+ cliError("Lint failed");
274
+ } else if (options.fix) {
275
+ console.log(chalk.green("✓ Lint fixes applied"));
276
+ } else {
277
+ console.log(chalk.green("✓ Lint succeeded"));
188
278
  }
189
279
  }