From ae77736ebbb74a7dd4477bd7f7ab6fae05b7d542 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Fri, 26 Jun 2026 11:18:23 +0200 Subject: [PATCH 1/3] feat: support lintoko in [requirements] for package consumers Packages shipping lintoko rules can declare a minimum lintoko version so consumers get a clear warning when their toolchain is too old. Co-authored-by: Cursor --- backend/main/types.mo | 4 +- backend/main/utils/validateConfig.mo | 5 +- cli/CHANGELOG.md | 2 + cli/check-requirements.ts | 84 +++++++++++++++--------- cli/helpers/get-lintoko-version.ts | 28 ++++++++ cli/mops.ts | 4 +- cli/tests/requirements-lintoko/mops.toml | 5 ++ cli/tests/requirements.test.ts | 66 +++++++++++++++++++ cli/types.ts | 1 + docs/docs/09-mops.toml.md | 3 +- 10 files changed, 162 insertions(+), 40 deletions(-) create mode 100644 cli/helpers/get-lintoko-version.ts create mode 100644 cli/tests/requirements-lintoko/mops.toml create mode 100644 cli/tests/requirements.test.ts diff --git a/backend/main/types.mo b/backend/main/types.mo index 6a3a779f..4cc2b0e3 100644 --- a/backend/main/types.mo +++ b/backend/main/types.mo @@ -81,11 +81,11 @@ module { // legacy for backward compatibility public type PackageConfigV3_Publishing = PackageConfigV2 and { - requirements : ?[Requirement]; // max 1 item + requirements : ?[Requirement]; // moc, lintoko }; public type PackageConfigV3 = PackageConfigV2 and { - requirements : [Requirement]; // max 1 item + requirements : [Requirement]; // moc, lintoko }; public type PackageSummary = { diff --git a/backend/main/utils/validateConfig.mo b/backend/main/utils/validateConfig.mo index b5746822..9dfa1616 100644 --- a/backend/main/utils/validateConfig.mo +++ b/backend/main/utils/validateConfig.mo @@ -219,11 +219,8 @@ module { return depValid; }; }; - if (config.requirements.size() > 1) { - return #err("invalid config: max requirements is 1"); - }; for (req in config.requirements.vals()) { - if (req.name != "moc") { + if (req.name != "moc" and req.name != "lintoko") { return #err("invalid config: unknown requirement '" # req.name # "'"); }; let versionValid = Semver.validate(req.value); diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 56242f39..2fe2b225 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next +- `[requirements].lintoko` declares a minimum lintoko version for package consumers. `mops install` (and `mops add`, `mops toolchain use`) warn when the project's lintoko is below a dependency's requirement, same as `moc` (#597). + ## 2.15.2 - `mops check-stable` (and the stable check inside `mops check`) reports when `[canisters..migrations].check-limit` is set but more migrations are pending than the limit allows. If the compatibility check failed, the check-limit diagnostic replaces the misleading `moc` error; if it passed anyway, a warning is shown. Compares the deployed `.most` baseline against the local chain; use `--no-check-limit` to suppress. diff --git a/cli/check-requirements.ts b/cli/check-requirements.ts index 5c2b14df..988fdc2c 100644 --- a/cli/check-requirements.ts +++ b/cli/check-requirements.ts @@ -5,47 +5,69 @@ import chalk from "chalk"; import { getDependencyType, getRootDir, readConfig } from "./mops.js"; import { resolvePackages } from "./resolve-packages.js"; import { getMocSemVer } from "./helpers/get-moc-version.js"; +import { getLintokoSemVer } from "./helpers/get-lintoko-version.js"; import { getPackageId } from "./helpers/get-package-id.js"; +import type { Requirements } from "./types.js"; + +type ToolRequirement = keyof Requirements; + +const TOOL_REQUIREMENTS: { + tool: ToolRequirement; + getInstalled: () => SemVer | null; +}[] = [ + { tool: "moc", getInstalled: getMocSemVer }, + { tool: "lintoko", getInstalled: getLintokoSemVer }, +]; export async function checkRequirements({ verbose = false } = {}) { - let installedMoc = getMocSemVer(); - if (!installedMoc) { - return; - } - let highestRequiredMoc = new SemVer("0.0.0"); - let highestRequiredMocPkgId = ""; let rootDir = getRootDir(); - let resolvedPackages = await resolvePackages(); - for (let [name, version] of Object.entries(resolvedPackages)) { - if (getDependencyType(version) === "mops") { - let pkgId = getPackageId(name, version); - let depConfig = readConfig( - path.join(rootDir, ".mops", pkgId, "mops.toml"), - ); - let moc = depConfig.requirements?.moc; - - if (moc) { - let requiredMoc = new SemVer(moc); - if (highestRequiredMoc.compare(requiredMoc) < 0) { - highestRequiredMoc = requiredMoc; - highestRequiredMocPkgId = pkgId; + + for (let { tool, getInstalled } of TOOL_REQUIREMENTS) { + let installed = getInstalled(); + if (!installed) { + continue; + } + + let highestRequired = new SemVer("0.0.0"); + let highestRequiredPkgId = ""; + + for (let [name, version] of Object.entries(resolvedPackages)) { + if (getDependencyType(version) === "mops") { + let pkgId = getPackageId(name, version); + let depConfig = readConfig( + path.join(rootDir, ".mops", pkgId, "mops.toml"), + ); + let required = depConfig.requirements?.[tool]; + + if (required) { + let requiredVersion = new SemVer(required); + if (highestRequired.compare(requiredVersion) < 0) { + highestRequired = requiredVersion; + highestRequiredPkgId = pkgId; + } + verbose && _check(tool, pkgId, installed, requiredVersion); } - verbose && _check(pkgId, installedMoc, requiredMoc); } } - } - verbose || _check(highestRequiredMocPkgId, installedMoc, highestRequiredMoc); + verbose || _check(tool, highestRequiredPkgId, installed, highestRequired); + } } -function _check(pkgId: string, installedMoc: SemVer, requiredMoc: SemVer) { - let comp = installedMoc.compare(requiredMoc); - if (comp < 0) { - console.log( - chalk.yellow(`moc version does not meet the requirements of ${pkgId}`), - ); - console.log(chalk.yellow(` Required: >= ${requiredMoc.format()}`)); - console.log(chalk.yellow(` Installed: ${installedMoc.format()}`)); +function _check( + tool: ToolRequirement, + pkgId: string, + installed: SemVer, + required: SemVer, +) { + if (!pkgId || installed.compare(required) >= 0) { + return; } + + console.log( + chalk.yellow(`${tool} version does not meet the requirements of ${pkgId}`), + ); + console.log(chalk.yellow(` Required: >= ${required.format()}`)); + console.log(chalk.yellow(` Installed: ${installed.format()}`)); } diff --git a/cli/helpers/get-lintoko-version.ts b/cli/helpers/get-lintoko-version.ts new file mode 100644 index 00000000..935f58e5 --- /dev/null +++ b/cli/helpers/get-lintoko-version.ts @@ -0,0 +1,28 @@ +import { execFileSync } from "node:child_process"; +import { type SemVer, parse } from "semver"; +import { readConfig } from "../mops.js"; + +export function getLintokoSemVer(): SemVer | null { + return parse(getLintokoVersion(false)); +} + +export function getLintokoVersion(throwOnError = false): string { + let configVersion = readConfig().toolchain?.lintoko; + if (configVersion) { + return configVersion; + } + + try { + let match = execFileSync("lintoko", ["--version"]) + .toString() + .trim() + .match(/lintoko ([^\s]+)/); + return match?.[1] || ""; + } catch (e) { + if (throwOnError) { + console.error(e); + throw new Error("lintoko not found"); + } + return ""; + } +} diff --git a/cli/mops.ts b/cli/mops.ts index 2b973025..4e035a29 100644 --- a/cli/mops.ts +++ b/cli/mops.ts @@ -205,9 +205,9 @@ export function readConfig(configFile = getClosestConfigFile()): Config { let config: Config = { ...toml }; Object.entries(config.requirements || {}).forEach(([name, value]) => { - if (name === "moc") { + if (name === "moc" || name === "lintoko") { config.requirements = config.requirements || {}; - config.requirements.moc = value; + config.requirements[name] = value; } }); diff --git a/cli/tests/requirements-lintoko/mops.toml b/cli/tests/requirements-lintoko/mops.toml new file mode 100644 index 00000000..4c016783 --- /dev/null +++ b/cli/tests/requirements-lintoko/mops.toml @@ -0,0 +1,5 @@ +[dependencies] +my-pkg = "0.1.0" + +[toolchain] +lintoko = "0.7.0" diff --git a/cli/tests/requirements.test.ts b/cli/tests/requirements.test.ts new file mode 100644 index 00000000..fe360471 --- /dev/null +++ b/cli/tests/requirements.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test, beforeEach, afterEach } from "@jest/globals"; +import { cp, mkdir, rm, writeFile } from "node:fs/promises"; +import path from "path"; +import { bytesToHex } from "@noble/hashes/utils"; +import { sha256 } from "@noble/hashes/sha256"; +import { cli } from "./helpers"; + +describe("requirements", () => { + const fixtureDir = path.join(import.meta.dirname, "requirements-lintoko"); + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join( + import.meta.dirname, + `_tmp_requirements_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + ); + await cp(fixtureDir, tempDir, { recursive: true }); + + const depDir = path.join(tempDir, ".mops", "my-pkg@0.1.0"); + await mkdir(depDir, { recursive: true }); + await writeFile( + path.join(depDir, "mops.toml"), + `[package] +name = "my-pkg" +version = "0.1.0" + +[requirements] +lintoko = "0.10.0" +`, + ); + + const mopsTomlDepsHash = bytesToHex( + sha256(JSON.stringify({ "my-pkg": "0.1.0" })), + ); + await writeFile( + path.join(tempDir, "mops.lock"), + JSON.stringify({ + version: 3, + mopsTomlDepsHash, + deps: { "my-pkg": "0.1.0" }, + hashes: { + "my-pkg@0.1.0": { + "mops.toml": + "0000000000000000000000000000000000000000000000000000000000000000", + }, + }, + }), + ); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("lintoko requirement warns when installed version is too old", async () => { + const result = await cli(["toolchain", "use", "lintoko", "0.7.0"], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch( + /lintoko version does not meet the requirements of my-pkg@0\.1\.0/, + ); + expect(result.stdout).toMatch(/Required: >= 0\.10\.0/); + expect(result.stdout).toMatch(/Installed:\s+0\.7\.0/); + }); +}); diff --git a/cli/types.ts b/cli/types.ts index ebbb08bb..861dd948 100644 --- a/cli/types.ts +++ b/cli/types.ts @@ -78,6 +78,7 @@ export type Tool = "moc" | "wasmtime" | "pocket-ic" | "lintoko"; export type Requirements = { moc?: string; + lintoko?: string; }; // export type Format = { diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 7387238c..25150747 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -252,11 +252,12 @@ Globs that match no files are skipped with a warning. All runs (base and extra) When a user installs your package(as a transitive dependency too), Mops will check if the requirements are met and display a warning if they are not. -Use only if your package will not work with older versions of the `moc`. +Use when your package will not work with older versions of the `moc` compiler or `lintoko` linter (e.g. rules that depend on lintoko features from a specific release). | Field | Description | | -------------------- | ------------------------------------------------ | | moc | Motoko compiler version (e.g. `0.11.0` which means `>=0.11.0`) | +| lintoko | Lintoko linter version (e.g. `0.10.0` which means `>=0.10.0`) | ## Advanced Configuration From 25926a4c763d439240c4983033ae4032e4b0d59e Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Fri, 26 Jun 2026 11:21:40 +0200 Subject: [PATCH 2/3] fix: run checkRequirements after toolchain update for all tools Co-authored-by: Cursor --- cli/commands/toolchain/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cli/commands/toolchain/index.ts b/cli/commands/toolchain/index.ts index b80ba127..544408f2 100644 --- a/cli/commands/toolchain/index.ts +++ b/cli/commands/toolchain/index.ts @@ -337,9 +337,7 @@ async function update(tool?: Tool) { config.toolchain[tool] = version; writeConfig(config); - if (tool === "moc") { - await checkRequirements(); - } + await checkRequirements(); if (oldVersion === version) { console.log(`Latest ${tool} ${version} is already installed`); From 70d643b8d9f242af4344e6dae0530b7a1d8479ba Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Fri, 26 Jun 2026 11:47:13 +0200 Subject: [PATCH 3/3] fix: resolve lintoko version from toolchain only, not PATH Unlike moc, lintoko has no dfx fallback. Requirements checks should reflect the project's pinned toolchain, not whatever happens to be on PATH. Co-authored-by: Cursor --- cli/helpers/get-lintoko-version.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cli/helpers/get-lintoko-version.ts b/cli/helpers/get-lintoko-version.ts index 935f58e5..8073aeeb 100644 --- a/cli/helpers/get-lintoko-version.ts +++ b/cli/helpers/get-lintoko-version.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import { type SemVer, parse } from "semver"; +import { FILE_PATH_REGEX } from "../constants.js"; import { readConfig } from "../mops.js"; export function getLintokoSemVer(): SemVer | null { @@ -8,12 +9,15 @@ export function getLintokoSemVer(): SemVer | null { export function getLintokoVersion(throwOnError = false): string { let configVersion = readConfig().toolchain?.lintoko; - if (configVersion) { + if (!configVersion) { + return ""; + } + if (!configVersion.match(FILE_PATH_REGEX)) { return configVersion; } try { - let match = execFileSync("lintoko", ["--version"]) + let match = execFileSync(configVersion, ["--version"]) .toString() .trim() .match(/lintoko ([^\s]+)/);