ic-mops 2.5.1 → 2.6.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 (42) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/bundle/cli.tgz +0 -0
  3. package/cli.ts +1 -1
  4. package/commands/check.ts +20 -2
  5. package/commands/docs-coverage.ts +26 -21
  6. package/commands/lint.ts +107 -10
  7. package/commands/publish.ts +16 -3
  8. package/commands/self.ts +1 -1
  9. package/dist/cli.js +1 -1
  10. package/dist/commands/check.js +14 -2
  11. package/dist/commands/docs-coverage.js +22 -22
  12. package/dist/commands/lint.d.ts +3 -0
  13. package/dist/commands/lint.js +75 -10
  14. package/dist/commands/publish.js +11 -3
  15. package/dist/commands/self.js +1 -1
  16. package/dist/package.json +1 -1
  17. package/dist/release-cli.js +2 -2
  18. package/dist/tests/check.test.js +24 -0
  19. package/dist/tests/lint.test.js +28 -2
  20. package/dist/types.d.ts +2 -0
  21. package/package.json +1 -1
  22. package/release-cli.ts +2 -2
  23. package/tests/check/with-lint-fail/NoBoolSwitch.mo +8 -0
  24. package/tests/check/with-lint-fail/lints/no-bool-switch.toml +9 -0
  25. package/tests/check/with-lint-fail/mops.toml +9 -0
  26. package/tests/check/with-lint-pass/Ok.mo +5 -0
  27. package/tests/check/with-lint-pass/lints/no-bool-switch.toml +9 -0
  28. package/tests/check/with-lint-pass/mops.toml +9 -0
  29. package/tests/check.test.ts +28 -0
  30. package/tests/lint-config-rules/extra-rules/no-bool-switch.toml +9 -0
  31. package/tests/lint-config-rules/mops.toml +5 -0
  32. package/tests/lint-config-rules/src/NoBoolSwitch.mo +8 -0
  33. package/tests/lint-extends/mops.toml +8 -0
  34. package/tests/lint-extends/my-pkg/mops.toml +3 -0
  35. package/tests/lint-extends/my-pkg/rules/no-bool-switch.toml +9 -0
  36. package/tests/lint-extends/src/NoBoolSwitch.mo +8 -0
  37. package/tests/lint-extends-all/mops.toml +8 -0
  38. package/tests/lint-extends-all/src/NoBoolSwitch.mo +8 -0
  39. package/tests/lint-extends-ignored/mops.toml +8 -0
  40. package/tests/lint-extends-ignored/src/NoBoolSwitch.mo +8 -0
  41. package/tests/lint.test.ts +32 -2
  42. package/types.ts +2 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Next
4
4
 
5
+ ## 2.6.0
6
+
7
+ - Packages can ship lintoko rules for consumers in a `rules/` directory (distinct from `lint/`/`lints/` which check the package itself); `rules/*.toml` files are included automatically when running `mops publish`
8
+ - Add `[lint] extends` in `mops.toml` to pull in `rules/` from installed dependencies: `extends = ["pkg"]` for named packages or `extends = true` for all
9
+ - Add `[lint] rules` in `mops.toml` to override the default `lint/`/`lints/` rule directories with custom paths
10
+ - `mops check` now runs `mops lint` after a successful type-check when `lintoko` is pinned in `[toolchain]`; lint is scoped to explicitly passed files when given, otherwise covers all `.mo` files; `--fix` propagates to both steps
11
+ - Raise package file limit from 300 to 1000; `mops publish` now fails fast with a clear error if the limit is exceeded
12
+ - Fix `mops docs coverage` crashing with out-of-memory on packages with many source files (replaced JSDOM with a lightweight adoc parser)
13
+
5
14
  ## 2.5.1
6
15
  - Fix `mops test` and `mops watch` breaking when dependency paths contain spaces
7
16
  - Fix `mops sync` incorrectly reporting version-pinned dependencies as missing/unused
package/bundle/cli.tgz CHANGED
Binary file
package/cli.ts CHANGED
@@ -322,7 +322,7 @@ program
322
322
  program
323
323
  .command("check [files...]")
324
324
  .description(
325
- "Check Motoko files for syntax errors and type issues. If no files are specified, checks all canister entrypoints from mops.toml. Also runs stable compatibility checks for canisters with [check-stable] configured",
325
+ "Check Motoko files for syntax errors and type issues. If no files are specified, checks all canister entrypoints from mops.toml. Also runs stable compatibility checks for canisters with [check-stable] configured, and runs linting if lintoko is configured in [toolchain] and rule directories are present",
326
326
  )
327
327
  .option("--verbose", "Verbose console output")
328
328
  .addOption(
package/commands/check.ts CHANGED
@@ -3,7 +3,12 @@ import { existsSync } from "node:fs";
3
3
  import chalk from "chalk";
4
4
  import { execa } from "execa";
5
5
  import { cliError } from "../error.js";
6
- import { getGlobalMocArgs, readConfig, resolveConfigPath } from "../mops.js";
6
+ import {
7
+ getGlobalMocArgs,
8
+ getRootDir,
9
+ readConfig,
10
+ resolveConfigPath,
11
+ } from "../mops.js";
7
12
  import { autofixMotoko } from "../helpers/autofix-motoko.js";
8
13
  import { getMocSemVer } from "../helpers/get-moc-version.js";
9
14
  import {
@@ -13,6 +18,7 @@ import {
13
18
  import { runStableCheck } from "./check-stable.js";
14
19
  import { sourcesArgs } from "./sources.js";
15
20
  import { toolchain } from "./toolchain/index.js";
21
+ import { collectLintRules, lint } from "./lint.js";
16
22
 
17
23
  const MOC_ALL_LIBS_MIN_VERSION = "1.3.0";
18
24
 
@@ -31,7 +37,8 @@ export async function check(
31
37
  files: string | string[],
32
38
  options: Partial<CheckOptions> = {},
33
39
  ): Promise<void> {
34
- let fileList = Array.isArray(files) ? files : files ? [files] : [];
40
+ const explicitFiles = Array.isArray(files) ? files : files ? [files] : [];
41
+ let fileList = [...explicitFiles];
35
42
 
36
43
  const config = readConfig();
37
44
 
@@ -166,4 +173,15 @@ export async function check(
166
173
  options: { verbose: options.verbose, extraArgs: options.extraArgs },
167
174
  });
168
175
  }
176
+
177
+ if (config.toolchain?.lintoko) {
178
+ const rootDir = getRootDir();
179
+ const lintRules = await collectLintRules(config, rootDir);
180
+ await lint(undefined, {
181
+ verbose: options.verbose,
182
+ fix: options.fix,
183
+ rules: lintRules,
184
+ files: explicitFiles.length > 0 ? explicitFiles : undefined,
185
+ });
186
+ }
169
187
  }
@@ -1,7 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import chalk from "chalk";
3
3
  import { globSync } from "glob";
4
- import { JSDOM } from "jsdom";
5
4
  import { docs } from "./docs.js";
6
5
 
7
6
  export type DocsCoverageReporter =
@@ -30,12 +29,12 @@ export async function docsCoverage(options: Partial<DocsCoverageOptions> = {}) {
30
29
  await docs({
31
30
  source,
32
31
  output: docsDir,
33
- format: "html",
32
+ format: "adoc",
34
33
  silent: true,
35
34
  });
36
35
 
37
- let files = globSync(`${docsDir}/**/*.html`, {
38
- ignore: [`${docsDir}/**/index.html`],
36
+ let files = globSync(`${docsDir}/**/*.adoc`, {
37
+ ignore: [`${docsDir}/**/*.test.adoc`, `${docsDir}/test/**/*`],
39
38
  });
40
39
  let coverages = [];
41
40
 
@@ -88,35 +87,41 @@ export async function docsCoverage(options: Partial<DocsCoverageOptions> = {}) {
88
87
  }
89
88
 
90
89
  function docFileCoverage(file: string) {
91
- let dom = new JSDOM(readFileSync(file, "utf-8"));
90
+ let content = readFileSync(file, "utf-8");
92
91
 
93
- let module = dom.window.document.querySelector("h1")?.textContent || "";
92
+ // Module name is on the line after the [[module.*]] anchor
93
+ let module =
94
+ content.match(/^\[\[module\.[^\]]+\]\]\n= (.+)$/m)?.[1]?.trim() || "";
94
95
  let moduleFile = `${module}.mo`;
95
96
 
96
- let items = [...dom.window.document.querySelectorAll("h4")].map((h4) => {
97
- let id = h4.getAttribute("id")?.replace("type.", "");
98
- let type = h4.className
99
- .replace("-declaration", "")
100
- .replace("function", "func");
101
- let definition = h4.textContent;
102
- let comment = h4.parentElement?.querySelector("p + p")?.textContent;
97
+ // Split into per-declaration sections at every [[id]] that is NOT [[module.*]]
98
+ let sections = content.split(/^(?=\[\[(?!module\.))/m).slice(1);
99
+
100
+ let items = sections.map((section) => {
101
+ let rawId = section.match(/^\[\[([^\]]+)\]\]/)?.[1] ?? "";
102
+ let id = rawId.replace(/^type\./, "");
103
+ // mo-doc anchors types as [[type.X]]; classes/values have no prefix → "func"
104
+ let type = rawId.startsWith("type.") ? "type" : "func";
105
+ let definition = section.match(/^== (.+)$/m)?.[1]?.trim() ?? "";
106
+
107
+ // Text after the closing ---- is the doc comment (empty when undocumented).
108
+ // slice(2).join preserves any ---- that appears inside the comment itself.
109
+ let parts = section.split(/^----$/m);
110
+ let comment = parts.slice(2).join("----").trim();
111
+
103
112
  return {
104
113
  file: moduleFile,
105
114
  id,
106
115
  type,
107
116
  definition,
108
117
  comment,
109
- covered: (comment || "").length >= 5,
118
+ covered: comment.length >= 5,
110
119
  };
111
120
  });
112
121
 
113
- let coverage = 0;
114
- if (!items.length) {
115
- coverage = 100;
116
- } else {
117
- coverage =
118
- (items.filter((item) => item.covered).length / items.length) * 100;
119
- }
122
+ let coverage = !items.length
123
+ ? 100
124
+ : (items.filter((item) => item.covered).length / items.length) * 100;
120
125
 
121
126
  return { file: moduleFile, coverage, items };
122
127
  }
package/commands/lint.ts CHANGED
@@ -3,15 +3,103 @@ import { execa } from "execa";
3
3
  import { globSync } from "glob";
4
4
  import path from "node:path";
5
5
  import { cliError } from "../error.js";
6
- import { getRootDir, readConfig } from "../mops.js";
6
+ import {
7
+ formatDir,
8
+ formatGithubDir,
9
+ getDependencyType,
10
+ getRootDir,
11
+ readConfig,
12
+ } from "../mops.js";
13
+ import { resolvePackages } from "../resolve-packages.js";
7
14
  import { toolchain } from "./toolchain/index.js";
8
15
  import { MOTOKO_GLOB_CONFIG } from "../constants.js";
9
16
  import { existsSync } from "node:fs";
17
+ import { Config } from "../types.js";
18
+
19
+ async function resolveDepRules(
20
+ config: Config,
21
+ rootDir: string,
22
+ ): Promise<string[]> {
23
+ const ext = config.lint?.extends;
24
+ if (!ext) {
25
+ return [];
26
+ }
27
+
28
+ const resolvedPackages = await resolvePackages();
29
+ const rules: string[] = [];
30
+ const matched = new Set<string>();
31
+ const hasRules = new Set<string>();
32
+
33
+ for (const [name, version] of Object.entries(resolvedPackages)) {
34
+ if (ext !== true && !ext.includes(name)) {
35
+ continue;
36
+ }
37
+ matched.add(name);
38
+
39
+ const depType = getDependencyType(version);
40
+ let pkgDir: string;
41
+ if (depType === "local") {
42
+ pkgDir = version;
43
+ } else if (depType === "github") {
44
+ pkgDir = formatGithubDir(name, version);
45
+ } else {
46
+ pkgDir = formatDir(name, version);
47
+ }
48
+
49
+ const rulesDir = path.join(pkgDir, "rules");
50
+ if (existsSync(rulesDir)) {
51
+ rules.push(path.relative(rootDir, rulesDir));
52
+ hasRules.add(name);
53
+ }
54
+ }
55
+
56
+ if (Array.isArray(ext)) {
57
+ const unresolved = ext.filter((n) => !matched.has(n));
58
+ if (unresolved.length > 0) {
59
+ console.warn(
60
+ chalk.yellow(
61
+ `[lint] extends: package(s) not found in dependencies: ${unresolved.join(", ")}`,
62
+ ),
63
+ );
64
+ }
65
+ const noRulesDir = ext.filter((n) => matched.has(n) && !hasRules.has(n));
66
+ if (noRulesDir.length > 0) {
67
+ console.warn(
68
+ chalk.yellow(
69
+ `[lint] extends: package(s) have no rules/ directory: ${noRulesDir.join(", ")}`,
70
+ ),
71
+ );
72
+ }
73
+ }
74
+
75
+ return rules;
76
+ }
77
+
78
+ export async function collectLintRules(
79
+ config: Config,
80
+ rootDir: string,
81
+ ): Promise<string[]> {
82
+ const configRules = config.lint?.rules ?? [];
83
+ for (const d of configRules) {
84
+ if (!existsSync(path.join(rootDir, d))) {
85
+ cliError(
86
+ `[lint] rules: directory '${d}' not found. Check your mops.toml [lint] config.`,
87
+ );
88
+ }
89
+ }
90
+ const localRules =
91
+ configRules.length > 0
92
+ ? configRules
93
+ : ["lint", "lints"].filter((d) => existsSync(path.join(rootDir, d)));
94
+ const depRules = await resolveDepRules(config, rootDir);
95
+ return [...localRules, ...depRules];
96
+ }
10
97
 
11
98
  export interface LintOptions {
12
99
  verbose: boolean;
13
100
  fix: boolean;
14
101
  rules?: string[];
102
+ files?: string[];
15
103
  extraArgs: string[];
16
104
  }
17
105
 
@@ -25,13 +113,22 @@ export async function lint(
25
113
  ? await toolchain.bin("lintoko")
26
114
  : "lintoko";
27
115
 
28
- let globStr = filter ? `**/*${filter}*.mo` : "**/*.mo";
29
- let filesToLint = globSync(path.join(rootDir, globStr), {
30
- ...MOTOKO_GLOB_CONFIG,
31
- cwd: rootDir,
32
- });
33
- if (filesToLint.length === 0) {
34
- cliError(`No files found for filter '${filter}'`);
116
+ let filesToLint: string[];
117
+ if (options.files && options.files.length > 0) {
118
+ filesToLint = options.files;
119
+ } else {
120
+ let globStr = filter ? `**/*${filter}*.mo` : "**/*.mo";
121
+ filesToLint = globSync(path.join(rootDir, globStr), {
122
+ ...MOTOKO_GLOB_CONFIG,
123
+ cwd: rootDir,
124
+ });
125
+ if (filesToLint.length === 0) {
126
+ cliError(
127
+ filter
128
+ ? `No files found for filter '${filter}'`
129
+ : "No .mo files found in the project",
130
+ );
131
+ }
35
132
  }
36
133
 
37
134
  let args: string[] = [];
@@ -42,9 +139,9 @@ export async function lint(
42
139
  args.push("--fix");
43
140
  }
44
141
  const rules =
45
- options.rules && options.rules.length > 0
142
+ options.rules !== undefined
46
143
  ? options.rules
47
- : ["lint", "lints"].filter((d) => existsSync(path.join(rootDir, d)));
144
+ : await collectLintRules(config, rootDir);
48
145
  rules.forEach((rule) => args.push("--rules", rule));
49
146
 
50
147
  if (config.lint?.args) {
@@ -271,6 +271,7 @@ export async function publish(
271
271
  "README.md",
272
272
  "LICENSE",
273
273
  "NOTICE",
274
+ "rules/*.toml",
274
275
  "!.mops/**",
275
276
  "!test/**",
276
277
  "!tests/**",
@@ -331,6 +332,16 @@ export async function publish(
331
332
  }
332
333
  }
333
334
 
335
+ // pre-flight file count check (must match MAX_PACKAGE_FILES in PackagePublisher.mo)
336
+ const FILE_LIMIT = 1000;
337
+ if (files.length > FILE_LIMIT) {
338
+ console.log(
339
+ chalk.red("Error: ") +
340
+ `Too many files (${files.length}). Maximum is ${FILE_LIMIT}.`,
341
+ );
342
+ process.exit(1);
343
+ }
344
+
334
345
  // parse changelog
335
346
  console.log("Parsing CHANGELOG.md...");
336
347
  let changelog = parseChangelog(config.package.version);
@@ -513,6 +524,8 @@ function parseChangelog(version: string): string {
513
524
  return changelog || "";
514
525
  }
515
526
 
527
+ type GitHubRelease = { message?: string; body?: string };
528
+
516
529
  async function fetchGitHubReleaseNotes(
517
530
  repo: string,
518
531
  version: string,
@@ -521,13 +534,13 @@ async function fetchGitHubReleaseNotes(
521
534
  let res = await fetch(
522
535
  `https://api.github.com/repos${repoPath}/releases/tags/${version}`,
523
536
  );
524
- let release = await res.json();
537
+ let release = (await res.json()) as GitHubRelease;
525
538
 
526
539
  if (release.message === "Not Found") {
527
540
  res = await fetch(
528
541
  `https://api.github.com/repos${repoPath}/releases/tags/v${version}`,
529
542
  );
530
- release = await res.json();
543
+ release = (await res.json()) as GitHubRelease;
531
544
 
532
545
  if (release.message === "Not Found") {
533
546
  console.log(
@@ -539,5 +552,5 @@ async function fetchGitHubReleaseNotes(
539
552
  }
540
553
  }
541
554
 
542
- return release.body;
555
+ return release.body ?? "";
543
556
  }
package/commands/self.ts CHANGED
@@ -29,7 +29,7 @@ function detectPackageManager() {
29
29
 
30
30
  export async function getLatestVersion() {
31
31
  let res = await fetch(url + "/tags/latest");
32
- return res.text();
32
+ return (await res.text()).trim();
33
33
  }
34
34
 
35
35
  export async function update() {
package/dist/cli.js CHANGED
@@ -255,7 +255,7 @@ program
255
255
  // check
256
256
  program
257
257
  .command("check [files...]")
258
- .description("Check Motoko files for syntax errors and type issues. If no files are specified, checks all canister entrypoints from mops.toml. Also runs stable compatibility checks for canisters with [check-stable] configured")
258
+ .description("Check Motoko files for syntax errors and type issues. If no files are specified, checks all canister entrypoints from mops.toml. Also runs stable compatibility checks for canisters with [check-stable] configured, and runs linting if lintoko is configured in [toolchain] and rule directories are present")
259
259
  .option("--verbose", "Verbose console output")
260
260
  .addOption(new Option("--fix", "Apply autofixes to all files, including transitively imported ones"))
261
261
  .allowUnknownOption(true)
@@ -3,20 +3,22 @@ import { existsSync } from "node:fs";
3
3
  import chalk from "chalk";
4
4
  import { execa } from "execa";
5
5
  import { cliError } from "../error.js";
6
- import { getGlobalMocArgs, readConfig, resolveConfigPath } from "../mops.js";
6
+ import { getGlobalMocArgs, getRootDir, readConfig, resolveConfigPath, } from "../mops.js";
7
7
  import { autofixMotoko } from "../helpers/autofix-motoko.js";
8
8
  import { getMocSemVer } from "../helpers/get-moc-version.js";
9
9
  import { resolveCanisterConfigs, resolveCanisterEntrypoints, } from "../helpers/resolve-canisters.js";
10
10
  import { runStableCheck } from "./check-stable.js";
11
11
  import { sourcesArgs } from "./sources.js";
12
12
  import { toolchain } from "./toolchain/index.js";
13
+ import { collectLintRules, lint } from "./lint.js";
13
14
  const MOC_ALL_LIBS_MIN_VERSION = "1.3.0";
14
15
  function supportsAllLibsFlag() {
15
16
  const version = getMocSemVer();
16
17
  return version ? version.compare(MOC_ALL_LIBS_MIN_VERSION) >= 0 : false;
17
18
  }
18
19
  export async function check(files, options = {}) {
19
- let fileList = Array.isArray(files) ? files : files ? [files] : [];
20
+ const explicitFiles = Array.isArray(files) ? files : files ? [files] : [];
21
+ let fileList = [...explicitFiles];
20
22
  const config = readConfig();
21
23
  if (fileList.length === 0) {
22
24
  fileList = resolveCanisterEntrypoints(config).map(resolveConfigPath);
@@ -115,4 +117,14 @@ export async function check(files, options = {}) {
115
117
  options: { verbose: options.verbose, extraArgs: options.extraArgs },
116
118
  });
117
119
  }
120
+ if (config.toolchain?.lintoko) {
121
+ const rootDir = getRootDir();
122
+ const lintRules = await collectLintRules(config, rootDir);
123
+ await lint(undefined, {
124
+ verbose: options.verbose,
125
+ fix: options.fix,
126
+ rules: lintRules,
127
+ files: explicitFiles.length > 0 ? explicitFiles : undefined,
128
+ });
129
+ }
118
130
  }
@@ -1,7 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import chalk from "chalk";
3
3
  import { globSync } from "glob";
4
- import { JSDOM } from "jsdom";
5
4
  import { docs } from "./docs.js";
6
5
  export async function docsCoverage(options = {}) {
7
6
  let docsDir = ".mops/.docs";
@@ -14,11 +13,11 @@ export async function docsCoverage(options = {}) {
14
13
  await docs({
15
14
  source,
16
15
  output: docsDir,
17
- format: "html",
16
+ format: "adoc",
18
17
  silent: true,
19
18
  });
20
- let files = globSync(`${docsDir}/**/*.html`, {
21
- ignore: [`${docsDir}/**/index.html`],
19
+ let files = globSync(`${docsDir}/**/*.adoc`, {
20
+ ignore: [`${docsDir}/**/*.test.adoc`, `${docsDir}/test/**/*`],
22
21
  });
23
22
  let coverages = [];
24
23
  for (let file of files) {
@@ -58,33 +57,34 @@ export async function docsCoverage(options = {}) {
58
57
  return totalCoverage;
59
58
  }
60
59
  function docFileCoverage(file) {
61
- let dom = new JSDOM(readFileSync(file, "utf-8"));
62
- let module = dom.window.document.querySelector("h1")?.textContent || "";
60
+ let content = readFileSync(file, "utf-8");
61
+ // Module name is on the line after the [[module.*]] anchor
62
+ let module = content.match(/^\[\[module\.[^\]]+\]\]\n= (.+)$/m)?.[1]?.trim() || "";
63
63
  let moduleFile = `${module}.mo`;
64
- let items = [...dom.window.document.querySelectorAll("h4")].map((h4) => {
65
- let id = h4.getAttribute("id")?.replace("type.", "");
66
- let type = h4.className
67
- .replace("-declaration", "")
68
- .replace("function", "func");
69
- let definition = h4.textContent;
70
- let comment = h4.parentElement?.querySelector("p + p")?.textContent;
64
+ // Split into per-declaration sections at every [[id]] that is NOT [[module.*]]
65
+ let sections = content.split(/^(?=\[\[(?!module\.))/m).slice(1);
66
+ let items = sections.map((section) => {
67
+ let rawId = section.match(/^\[\[([^\]]+)\]\]/)?.[1] ?? "";
68
+ let id = rawId.replace(/^type\./, "");
69
+ // mo-doc anchors types as [[type.X]]; classes/values have no prefix → "func"
70
+ let type = rawId.startsWith("type.") ? "type" : "func";
71
+ let definition = section.match(/^== (.+)$/m)?.[1]?.trim() ?? "";
72
+ // Text after the closing ---- is the doc comment (empty when undocumented).
73
+ // slice(2).join preserves any ---- that appears inside the comment itself.
74
+ let parts = section.split(/^----$/m);
75
+ let comment = parts.slice(2).join("----").trim();
71
76
  return {
72
77
  file: moduleFile,
73
78
  id,
74
79
  type,
75
80
  definition,
76
81
  comment,
77
- covered: (comment || "").length >= 5,
82
+ covered: comment.length >= 5,
78
83
  };
79
84
  });
80
- let coverage = 0;
81
- if (!items.length) {
82
- coverage = 100;
83
- }
84
- else {
85
- coverage =
86
- (items.filter((item) => item.covered).length / items.length) * 100;
87
- }
85
+ let coverage = !items.length
86
+ ? 100
87
+ : (items.filter((item) => item.covered).length / items.length) * 100;
88
88
  return { file: moduleFile, coverage, items };
89
89
  }
90
90
  function colorizeCoverage(coverage) {
@@ -1,7 +1,10 @@
1
+ import { Config } from "../types.js";
2
+ export declare function collectLintRules(config: Config, rootDir: string): Promise<string[]>;
1
3
  export interface LintOptions {
2
4
  verbose: boolean;
3
5
  fix: boolean;
4
6
  rules?: string[];
7
+ files?: string[];
5
8
  extraArgs: string[];
6
9
  }
7
10
  export declare function lint(filter: string | undefined, options: Partial<LintOptions>): Promise<void>;
@@ -3,23 +3,88 @@ import { execa } from "execa";
3
3
  import { globSync } from "glob";
4
4
  import path from "node:path";
5
5
  import { cliError } from "../error.js";
6
- import { getRootDir, readConfig } from "../mops.js";
6
+ import { formatDir, formatGithubDir, getDependencyType, getRootDir, readConfig, } from "../mops.js";
7
+ import { resolvePackages } from "../resolve-packages.js";
7
8
  import { toolchain } from "./toolchain/index.js";
8
9
  import { MOTOKO_GLOB_CONFIG } from "../constants.js";
9
10
  import { existsSync } from "node:fs";
11
+ async function resolveDepRules(config, rootDir) {
12
+ const ext = config.lint?.extends;
13
+ if (!ext) {
14
+ return [];
15
+ }
16
+ const resolvedPackages = await resolvePackages();
17
+ const rules = [];
18
+ const matched = new Set();
19
+ const hasRules = new Set();
20
+ for (const [name, version] of Object.entries(resolvedPackages)) {
21
+ if (ext !== true && !ext.includes(name)) {
22
+ continue;
23
+ }
24
+ matched.add(name);
25
+ const depType = getDependencyType(version);
26
+ let pkgDir;
27
+ if (depType === "local") {
28
+ pkgDir = version;
29
+ }
30
+ else if (depType === "github") {
31
+ pkgDir = formatGithubDir(name, version);
32
+ }
33
+ else {
34
+ pkgDir = formatDir(name, version);
35
+ }
36
+ const rulesDir = path.join(pkgDir, "rules");
37
+ if (existsSync(rulesDir)) {
38
+ rules.push(path.relative(rootDir, rulesDir));
39
+ hasRules.add(name);
40
+ }
41
+ }
42
+ if (Array.isArray(ext)) {
43
+ const unresolved = ext.filter((n) => !matched.has(n));
44
+ if (unresolved.length > 0) {
45
+ console.warn(chalk.yellow(`[lint] extends: package(s) not found in dependencies: ${unresolved.join(", ")}`));
46
+ }
47
+ const noRulesDir = ext.filter((n) => matched.has(n) && !hasRules.has(n));
48
+ if (noRulesDir.length > 0) {
49
+ console.warn(chalk.yellow(`[lint] extends: package(s) have no rules/ directory: ${noRulesDir.join(", ")}`));
50
+ }
51
+ }
52
+ return rules;
53
+ }
54
+ export async function collectLintRules(config, rootDir) {
55
+ const configRules = config.lint?.rules ?? [];
56
+ for (const d of configRules) {
57
+ if (!existsSync(path.join(rootDir, d))) {
58
+ cliError(`[lint] rules: directory '${d}' not found. Check your mops.toml [lint] config.`);
59
+ }
60
+ }
61
+ const localRules = configRules.length > 0
62
+ ? configRules
63
+ : ["lint", "lints"].filter((d) => existsSync(path.join(rootDir, d)));
64
+ const depRules = await resolveDepRules(config, rootDir);
65
+ return [...localRules, ...depRules];
66
+ }
10
67
  export async function lint(filter, options) {
11
68
  let config = readConfig();
12
69
  let rootDir = getRootDir();
13
70
  let lintokoBinPath = config.toolchain?.lintoko
14
71
  ? await toolchain.bin("lintoko")
15
72
  : "lintoko";
16
- let globStr = filter ? `**/*${filter}*.mo` : "**/*.mo";
17
- let filesToLint = globSync(path.join(rootDir, globStr), {
18
- ...MOTOKO_GLOB_CONFIG,
19
- cwd: rootDir,
20
- });
21
- if (filesToLint.length === 0) {
22
- cliError(`No files found for filter '${filter}'`);
73
+ let filesToLint;
74
+ if (options.files && options.files.length > 0) {
75
+ filesToLint = options.files;
76
+ }
77
+ else {
78
+ let globStr = filter ? `**/*${filter}*.mo` : "**/*.mo";
79
+ filesToLint = globSync(path.join(rootDir, globStr), {
80
+ ...MOTOKO_GLOB_CONFIG,
81
+ cwd: rootDir,
82
+ });
83
+ if (filesToLint.length === 0) {
84
+ cliError(filter
85
+ ? `No files found for filter '${filter}'`
86
+ : "No .mo files found in the project");
87
+ }
23
88
  }
24
89
  let args = [];
25
90
  if (options.verbose) {
@@ -28,9 +93,9 @@ export async function lint(filter, options) {
28
93
  if (options.fix) {
29
94
  args.push("--fix");
30
95
  }
31
- const rules = options.rules && options.rules.length > 0
96
+ const rules = options.rules !== undefined
32
97
  ? options.rules
33
- : ["lint", "lints"].filter((d) => existsSync(path.join(rootDir, d)));
98
+ : await collectLintRules(config, rootDir);
34
99
  rules.forEach((rule) => args.push("--rules", rule));
35
100
  if (config.lint?.args) {
36
101
  if (typeof config.lint.args === "string") {
@@ -210,6 +210,7 @@ export async function publish(options = {}) {
210
210
  "README.md",
211
211
  "LICENSE",
212
212
  "NOTICE",
213
+ "rules/*.toml",
213
214
  "!.mops/**",
214
215
  "!test/**",
215
216
  "!tests/**",
@@ -261,6 +262,13 @@ export async function publish(options = {}) {
261
262
  process.exit(1);
262
263
  }
263
264
  }
265
+ // pre-flight file count check (must match MAX_PACKAGE_FILES in PackagePublisher.mo)
266
+ const FILE_LIMIT = 1000;
267
+ if (files.length > FILE_LIMIT) {
268
+ console.log(chalk.red("Error: ") +
269
+ `Too many files (${files.length}). Maximum is ${FILE_LIMIT}.`);
270
+ process.exit(1);
271
+ }
264
272
  // parse changelog
265
273
  console.log("Parsing CHANGELOG.md...");
266
274
  let changelog = parseChangelog(config.package.version);
@@ -402,14 +410,14 @@ function parseChangelog(version) {
402
410
  async function fetchGitHubReleaseNotes(repo, version) {
403
411
  let repoPath = new URL(repo).pathname;
404
412
  let res = await fetch(`https://api.github.com/repos${repoPath}/releases/tags/${version}`);
405
- let release = await res.json();
413
+ let release = (await res.json());
406
414
  if (release.message === "Not Found") {
407
415
  res = await fetch(`https://api.github.com/repos${repoPath}/releases/tags/v${version}`);
408
- release = await res.json();
416
+ release = (await res.json());
409
417
  if (release.message === "Not Found") {
410
418
  console.log(chalk.yellow(`No GitHub release found with name ${version} or v${version}`));
411
419
  return "";
412
420
  }
413
421
  }
414
- return release.body;
422
+ return release.body ?? "";
415
423
  }
@@ -27,7 +27,7 @@ function detectPackageManager() {
27
27
  }
28
28
  export async function getLatestVersion() {
29
29
  let res = await fetch(url + "/tags/latest");
30
- return res.text();
30
+ return (await res.text()).trim();
31
31
  }
32
32
  export async function update() {
33
33
  let latest = await getLatestVersion();
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "bin/mops.js",
@@ -29,7 +29,7 @@ fs.cpSync(path.resolve(__dirname, `../cli-releases/versions/${version}.tgz`), pa
29
29
  fs.writeFileSync(path.resolve(__dirname, `../cli-releases/tags/${tag}`), version);
30
30
  console.log(`Release '${version}' created with tag '${tag}'`);
31
31
  if (!fs.existsSync(path.resolve(__dirname, "../cli-releases/releases.json"))) {
32
- fs.writeFileSync(path.resolve(__dirname, "../cli-releases/releases.json"), JSON.stringify({ tags: {}, versions: {} }, null, 2));
32
+ fs.writeFileSync(path.resolve(__dirname, "../cli-releases/releases.json"), JSON.stringify({ tags: {}, versions: {} }, null, 2) + "\n");
33
33
  }
34
34
  let releases = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../cli-releases/releases.json"), "utf8"));
35
35
  releases.tags[tag] = version;
@@ -41,4 +41,4 @@ releases.versions[version] = {
41
41
  url: `https://cli.mops.one/versions/${version}.tgz`,
42
42
  hash,
43
43
  };
44
- fs.writeFileSync(path.resolve(__dirname, "../cli-releases/releases.json"), JSON.stringify(releases, null, 2));
44
+ fs.writeFileSync(path.resolve(__dirname, "../cli-releases/releases.json"), JSON.stringify(releases, null, 2) + "\n");
@@ -94,4 +94,28 @@ describe("check", () => {
94
94
  expect(result.stderr).toMatch(/error/i);
95
95
  expect(result.stdout).not.toMatch(/Stable compatibility/);
96
96
  });
97
+ test("lint runs after moc check and passes", async () => {
98
+ const cwd = path.join(import.meta.dirname, "check/with-lint-pass");
99
+ const result = await cli(["check"], { cwd });
100
+ expect(result.exitCode).toBe(0);
101
+ expect(result.stdout).toMatch(/✓ Lint succeeded/);
102
+ });
103
+ test("check fails when lint finds errors", async () => {
104
+ const cwd = path.join(import.meta.dirname, "check/with-lint-fail");
105
+ const result = await cli(["check"], { cwd });
106
+ expect(result.exitCode).toBe(1);
107
+ expect(result.stderr).toMatch(/no-bool-switch/);
108
+ });
109
+ test("lint is skipped when lintoko not configured and no rules exist", async () => {
110
+ const cwd = path.join(import.meta.dirname, "check/canisters");
111
+ const result = await cli(["check"], { cwd });
112
+ expect(result.exitCode).toBe(0);
113
+ expect(result.stdout).not.toMatch(/Lint/);
114
+ });
115
+ test("--fix flag reaches lint step", async () => {
116
+ const cwd = path.join(import.meta.dirname, "check/with-lint-pass");
117
+ const result = await cli(["check", "--fix"], { cwd });
118
+ expect(result.exitCode).toBe(0);
119
+ expect(result.stdout).toMatch(/✓ Lint fixes applied/);
120
+ });
97
121
  });
@@ -1,6 +1,6 @@
1
- import { describe, test } from "@jest/globals";
1
+ import { describe, expect, test } from "@jest/globals";
2
2
  import path from "path";
3
- import { cliSnapshot } from "./helpers";
3
+ import { cli, cliSnapshot } from "./helpers";
4
4
  describe("lint", () => {
5
5
  test("ok", async () => {
6
6
  const cwd = path.join(import.meta.dirname, "lint");
@@ -12,4 +12,30 @@ describe("lint", () => {
12
12
  await cliSnapshot(["lint", "NoBoolSwitch", "--verbose"], { cwd }, 1);
13
13
  await cliSnapshot(["lint", "DoesNotExist"], { cwd }, 1);
14
14
  });
15
+ test("[lint] rules - additional config rules directory is used", async () => {
16
+ const cwd = path.join(import.meta.dirname, "lint-config-rules");
17
+ const result = await cli(["lint"], { cwd });
18
+ expect(result.exitCode).toBe(1);
19
+ expect(result.stderr).toMatch(/no-bool-switch/);
20
+ });
21
+ test("[lint] extends - picks up rules/ from named dependency", async () => {
22
+ const cwd = path.join(import.meta.dirname, "lint-extends");
23
+ const result = await cli(["lint"], { cwd });
24
+ expect(result.exitCode).toBe(1);
25
+ expect(result.stderr).toMatch(/no-bool-switch/);
26
+ });
27
+ test("[lint] extends true - picks up rules/ from all dependencies", async () => {
28
+ const cwd = path.join(import.meta.dirname, "lint-extends-all");
29
+ const result = await cli(["lint"], { cwd });
30
+ expect(result.exitCode).toBe(1);
31
+ expect(result.stderr).toMatch(/no-bool-switch/);
32
+ });
33
+ test("[lint] extends - dep not in extends list is ignored", async () => {
34
+ // my-pkg has rules/ but extends only lists "other-pkg" (which doesn't exist),
35
+ // so no dep rules are loaded and NoBoolSwitch.mo passes with exit 0.
36
+ const cwd = path.join(import.meta.dirname, "lint-extends-ignored");
37
+ const result = await cli(["lint"], { cwd });
38
+ expect(result.exitCode).toBe(0);
39
+ expect(result.stderr).toMatch(/not found in dependencies/);
40
+ });
15
41
  });
package/dist/types.d.ts CHANGED
@@ -29,6 +29,8 @@ export type Config = {
29
29
  };
30
30
  lint?: {
31
31
  args?: string[];
32
+ rules?: string[];
33
+ extends?: string[] | true;
32
34
  };
33
35
  };
34
36
  export type CanisterConfig = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "dist/bin/mops.js",
package/release-cli.ts CHANGED
@@ -79,7 +79,7 @@ type Releases = {
79
79
  if (!fs.existsSync(path.resolve(__dirname, "../cli-releases/releases.json"))) {
80
80
  fs.writeFileSync(
81
81
  path.resolve(__dirname, "../cli-releases/releases.json"),
82
- JSON.stringify({ tags: {}, versions: {} }, null, 2),
82
+ JSON.stringify({ tags: {}, versions: {} }, null, 2) + "\n",
83
83
  );
84
84
  }
85
85
 
@@ -102,5 +102,5 @@ releases.versions[version] = {
102
102
 
103
103
  fs.writeFileSync(
104
104
  path.resolve(__dirname, "../cli-releases/releases.json"),
105
- JSON.stringify(releases, null, 2),
105
+ JSON.stringify(releases, null, 2) + "\n",
106
106
  );
@@ -0,0 +1,8 @@
1
+ module {
2
+ public func boolSwitch(b : Bool) : Bool {
3
+ switch (b) {
4
+ case false { false };
5
+ case true { true };
6
+ };
7
+ };
8
+ };
@@ -0,0 +1,9 @@
1
+ name = "no-bool-switch"
2
+ description = "Don't switch on boolean values, use if instead"
3
+ query = """
4
+ (switch_exp
5
+ (case [
6
+ (lit_pat (bool_literal))
7
+ (tup_pat . (lit_pat (bool_literal)) @trailing)
8
+ ])) @error
9
+ """
@@ -0,0 +1,9 @@
1
+ [toolchain]
2
+ moc = "1.3.0"
3
+ lintoko = "0.7.0"
4
+
5
+ [moc]
6
+ args = ["--default-persistent-actors"]
7
+
8
+ [canisters.backend]
9
+ main = "NoBoolSwitch.mo"
@@ -0,0 +1,5 @@
1
+ module {
2
+ public func greet(name : Text) : Text {
3
+ "Hello, " # name # "!";
4
+ };
5
+ };
@@ -0,0 +1,9 @@
1
+ name = "no-bool-switch"
2
+ description = "Don't switch on boolean values, use if instead"
3
+ query = """
4
+ (switch_exp
5
+ (case [
6
+ (lit_pat (bool_literal))
7
+ (tup_pat . (lit_pat (bool_literal)) @trailing)
8
+ ])) @error
9
+ """
@@ -0,0 +1,9 @@
1
+ [toolchain]
2
+ moc = "1.3.0"
3
+ lintoko = "0.7.0"
4
+
5
+ [moc]
6
+ args = ["--default-persistent-actors"]
7
+
8
+ [canisters.backend]
9
+ main = "Ok.mo"
@@ -121,4 +121,32 @@ describe("check", () => {
121
121
  expect(result.stderr).toMatch(/error/i);
122
122
  expect(result.stdout).not.toMatch(/Stable compatibility/);
123
123
  });
124
+
125
+ test("lint runs after moc check and passes", async () => {
126
+ const cwd = path.join(import.meta.dirname, "check/with-lint-pass");
127
+ const result = await cli(["check"], { cwd });
128
+ expect(result.exitCode).toBe(0);
129
+ expect(result.stdout).toMatch(/✓ Lint succeeded/);
130
+ });
131
+
132
+ test("check fails when lint finds errors", async () => {
133
+ const cwd = path.join(import.meta.dirname, "check/with-lint-fail");
134
+ const result = await cli(["check"], { cwd });
135
+ expect(result.exitCode).toBe(1);
136
+ expect(result.stderr).toMatch(/no-bool-switch/);
137
+ });
138
+
139
+ test("lint is skipped when lintoko not configured and no rules exist", async () => {
140
+ const cwd = path.join(import.meta.dirname, "check/canisters");
141
+ const result = await cli(["check"], { cwd });
142
+ expect(result.exitCode).toBe(0);
143
+ expect(result.stdout).not.toMatch(/Lint/);
144
+ });
145
+
146
+ test("--fix flag reaches lint step", async () => {
147
+ const cwd = path.join(import.meta.dirname, "check/with-lint-pass");
148
+ const result = await cli(["check", "--fix"], { cwd });
149
+ expect(result.exitCode).toBe(0);
150
+ expect(result.stdout).toMatch(/✓ Lint fixes applied/);
151
+ });
124
152
  });
@@ -0,0 +1,9 @@
1
+ name = "no-bool-switch"
2
+ description = "Don't switch on boolean values, use if instead"
3
+ query = """
4
+ (switch_exp
5
+ (case [
6
+ (lit_pat (bool_literal))
7
+ (tup_pat . (lit_pat (bool_literal)) @trailing)
8
+ ])) @error
9
+ """
@@ -0,0 +1,5 @@
1
+ [toolchain]
2
+ lintoko = "0.7.0"
3
+
4
+ [lint]
5
+ rules = ["extra-rules"]
@@ -0,0 +1,8 @@
1
+ module {
2
+ public func boolSwitch(b : Bool) : Bool {
3
+ switch (b) {
4
+ case false { false };
5
+ case true { true };
6
+ };
7
+ };
8
+ };
@@ -0,0 +1,8 @@
1
+ [dependencies]
2
+ my-pkg = "./my-pkg"
3
+
4
+ [toolchain]
5
+ lintoko = "0.7.0"
6
+
7
+ [lint]
8
+ extends = ["my-pkg"]
@@ -0,0 +1,3 @@
1
+ [package]
2
+ name = "my-pkg"
3
+ version = "0.1.0"
@@ -0,0 +1,9 @@
1
+ name = "no-bool-switch"
2
+ description = "Don't switch on boolean values, use if instead"
3
+ query = """
4
+ (switch_exp
5
+ (case [
6
+ (lit_pat (bool_literal))
7
+ (tup_pat . (lit_pat (bool_literal)) @trailing)
8
+ ])) @error
9
+ """
@@ -0,0 +1,8 @@
1
+ module {
2
+ public func boolSwitch(b : Bool) : Bool {
3
+ switch (b) {
4
+ case false { false };
5
+ case true { true };
6
+ };
7
+ };
8
+ };
@@ -0,0 +1,8 @@
1
+ [dependencies]
2
+ my-pkg = "../lint-extends/my-pkg"
3
+
4
+ [toolchain]
5
+ lintoko = "0.7.0"
6
+
7
+ [lint]
8
+ extends = true
@@ -0,0 +1,8 @@
1
+ module {
2
+ public func boolSwitch(b : Bool) : Bool {
3
+ switch (b) {
4
+ case false { false };
5
+ case true { true };
6
+ };
7
+ };
8
+ };
@@ -0,0 +1,8 @@
1
+ [dependencies]
2
+ my-pkg = "../lint-extends/my-pkg"
3
+
4
+ [toolchain]
5
+ lintoko = "0.7.0"
6
+
7
+ [lint]
8
+ extends = ["other-pkg"]
@@ -0,0 +1,8 @@
1
+ module {
2
+ public func boolSwitch(b : Bool) : Bool {
3
+ switch (b) {
4
+ case false { false };
5
+ case true { true };
6
+ };
7
+ };
8
+ };
@@ -1,6 +1,6 @@
1
- import { describe, test } from "@jest/globals";
1
+ import { describe, expect, test } from "@jest/globals";
2
2
  import path from "path";
3
- import { cliSnapshot } from "./helpers";
3
+ import { cli, cliSnapshot } from "./helpers";
4
4
 
5
5
  describe("lint", () => {
6
6
  test("ok", async () => {
@@ -14,4 +14,34 @@ describe("lint", () => {
14
14
  await cliSnapshot(["lint", "NoBoolSwitch", "--verbose"], { cwd }, 1);
15
15
  await cliSnapshot(["lint", "DoesNotExist"], { cwd }, 1);
16
16
  });
17
+
18
+ test("[lint] rules - additional config rules directory is used", async () => {
19
+ const cwd = path.join(import.meta.dirname, "lint-config-rules");
20
+ const result = await cli(["lint"], { cwd });
21
+ expect(result.exitCode).toBe(1);
22
+ expect(result.stderr).toMatch(/no-bool-switch/);
23
+ });
24
+
25
+ test("[lint] extends - picks up rules/ from named dependency", async () => {
26
+ const cwd = path.join(import.meta.dirname, "lint-extends");
27
+ const result = await cli(["lint"], { cwd });
28
+ expect(result.exitCode).toBe(1);
29
+ expect(result.stderr).toMatch(/no-bool-switch/);
30
+ });
31
+
32
+ test("[lint] extends true - picks up rules/ from all dependencies", async () => {
33
+ const cwd = path.join(import.meta.dirname, "lint-extends-all");
34
+ const result = await cli(["lint"], { cwd });
35
+ expect(result.exitCode).toBe(1);
36
+ expect(result.stderr).toMatch(/no-bool-switch/);
37
+ });
38
+
39
+ test("[lint] extends - dep not in extends list is ignored", async () => {
40
+ // my-pkg has rules/ but extends only lists "other-pkg" (which doesn't exist),
41
+ // so no dep rules are loaded and NoBoolSwitch.mo passes with exit 0.
42
+ const cwd = path.join(import.meta.dirname, "lint-extends-ignored");
43
+ const result = await cli(["lint"], { cwd });
44
+ expect(result.exitCode).toBe(0);
45
+ expect(result.stderr).toMatch(/not found in dependencies/);
46
+ });
17
47
  });
package/types.ts CHANGED
@@ -29,6 +29,8 @@ export type Config = {
29
29
  };
30
30
  lint?: {
31
31
  args?: string[];
32
+ rules?: string[];
33
+ extends?: string[] | true;
32
34
  };
33
35
  };
34
36