Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7a86d97
Set up 'mops check'
rvanasa Feb 5, 2026
cb4ef40
Merge branch 'main' into mops-check
rvanasa Feb 12, 2026
fd76d93
Set up autofix logic
rvanasa Feb 13, 2026
0ff22e0
Merge branch 'mops-check' of https://github.com/dfinity/mops into mop…
rvanasa Feb 13, 2026
e326569
Format
rvanasa Feb 17, 2026
a968812
Improve tests and add '--warnings' flag
rvanasa Feb 17, 2026
409b896
Refactor tests
rvanasa Feb 18, 2026
c19f4d3
Update M0223 test case
rvanasa Feb 18, 2026
54c0b89
Merge branch 'main' into mops-check
Kamirus Mar 2, 2026
6ac097a
[cli] Integrate mops toolchain for build process + build test without…
Kamirus Mar 2, 2026
8650c2a
use json suggestsions in autofix
Kamirus Mar 2, 2026
d2311fb
separate tests
Kamirus Mar 2, 2026
038d7a0
integrate lint-staged for pre-commit checks
Kamirus Mar 2, 2026
ffd6568
Update package dependencies and refactor autofix logic to utilize vsc…
Kamirus Mar 2, 2026
0b46c80
Refactor check requirements and autofix logic to utilize updated vers…
Kamirus Mar 3, 2026
296da30
Refactor diagnostics logging in `check` command and normalize file pa…
Kamirus Mar 3, 2026
8751edf
fixpoint up 10 iterations
Kamirus Mar 3, 2026
d58fdc7
Explain that the check command runs transitively on all imported file…
Kamirus Mar 3, 2026
0f1fbca
Refactor `testCheckFix` to simplify fixture copying and remove conten…
Kamirus Mar 3, 2026
8818d83
Refactor `supportsAllLibsFlag` to remove mocPath parameter and update…
Kamirus Mar 3, 2026
73f9dfd
count all diagnostics in tests + fix nonfixable error
Kamirus Mar 3, 2026
ebfcf25
Enhance `check` command output to display detailed fix information, i…
Kamirus Mar 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Mops CLI Changelog

## Next
- Add `mops check --fix` subcommand (for Motoko files) with autofix logic
- Add `mops check` subcommand for type-checking Motoko files
- Warn for `dfx` projects instead of requiring `mops toolchain init`
- Allow specifying toolchain file paths in `mops.toml`
Expand Down
11 changes: 3 additions & 8 deletions cli/check-requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,14 @@ import chalk from "chalk";

import { getDependencyType, getRootDir, readConfig } from "./mops.js";
import { resolvePackages } from "./resolve-packages.js";
import { getMocVersion } from "./helpers/get-moc-version.js";
import { getMocSemVer } from "./helpers/get-moc-version.js";
import { getPackageId } from "./helpers/get-package-id.js";

export async function checkRequirements({ verbose = false } = {}) {
let config = readConfig();
let mocVersion = config.toolchain?.moc;
if (!mocVersion) {
mocVersion = getMocVersion(false);
}
if (!mocVersion) {
let installedMoc = getMocSemVer();
if (!installedMoc) {
return;
}
let installedMoc = new SemVer(mocVersion);
let highestRequiredMoc = new SemVer("0.0.0");
let highestRequiredMocPkgId = "";
let rootDir = getRootDir();
Expand Down
10 changes: 9 additions & 1 deletion cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,16 @@ program
// check
program
.command("check <files...>")
.description("Check Motoko files for syntax errors and type issues.")
.description(
"Check Motoko entrypoint files for syntax errors and type issues (including transitively imported files)",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we should just have mops check, but it is ok for now to explicitly provide the entrypoint.
It is important to note that this command will also check all transitively imported files.

)
.option("--verbose", "Verbose console output")
.addOption(
new Option(
"--fix",
"Apply autofixes to all files, including transitively imported ones",
),
)
.allowUnknownOption(true)
.action(async (files, options) => {
checkConfigFile(true);
Expand Down
4 changes: 2 additions & 2 deletions cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { exists } from "fs-extra";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { cliError } from "../error.js";
import { getMocPath } from "../helpers/get-moc-path.js";
import { isCandidCompatible } from "../helpers/is-candid-compatible.js";
import { CustomSection, getWasmBindings } from "../wasm.js";
import { readConfig } from "../mops.js";
import { CanisterConfig } from "../types.js";
import { sourcesArgs } from "./sources.js";
import { toolchain } from "./toolchain/index.js";

export interface BuildOptions {
outputDir: string;
Expand All @@ -28,7 +28,7 @@ export async function build(
}

let outputDir = options.outputDir ?? DEFAULT_BUILD_OUTPUT_DIR;
let mocPath = getMocPath();
let mocPath = await toolchain.bin("moc", { fallback: true });
let canisters: Record<string, CanisterConfig> = {};
let config = readConfig();
if (config.canisters) {
Expand Down
75 changes: 70 additions & 5 deletions cli/commands/check.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { relative } from "node:path";
import chalk from "chalk";
import { execa } from "execa";
import { cliError } from "../error.js";
import { getMocPath } from "../helpers/get-moc-path.js";
import { autofixMotoko } from "../helpers/autofix-motoko.js";
import { getMocSemVer } from "../helpers/get-moc-version.js";
import { sourcesArgs } from "./sources.js";
import { toolchain } from "./toolchain/index.js";

const MOC_ALL_LIBS_MIN_VERSION = "1.3.0";

function supportsAllLibsFlag(): boolean {
const version = getMocSemVer();
return version ? version.compare(MOC_ALL_LIBS_MIN_VERSION) >= 0 : false;
}

export interface CheckOptions {
verbose: boolean;
fix: boolean;
extraArgs: string[];
}

Expand All @@ -19,9 +30,61 @@ export async function check(
cliError("No Motoko files specified for checking");
}

let mocPath = getMocPath();
let sources = await sourcesArgs();
const mocArgs = ["--check", ...sources.flat(), ...(options.extraArgs ?? [])];
const mocPath = await toolchain.bin("moc", { fallback: true });
const sources = await sourcesArgs();

// --all-libs enables richer diagnostics with edit suggestions from moc (requires moc >= 1.3.0)
const allLibs = supportsAllLibsFlag();

if (!allLibs) {
console.log(
chalk.yellow(
`moc < ${MOC_ALL_LIBS_MIN_VERSION}: some diagnostic hints may be missing`,
),
);
} else if (options.verbose) {
console.log(
chalk.blue("check"),
chalk.gray("Using --all-libs for richer diagnostics"),
);
}

const mocArgs = [
"--check",
...(allLibs ? ["--all-libs"] : []),
...sources.flat(),
...(options.extraArgs ?? []),
];

if (options.fix) {
if (options.verbose) {
console.log(chalk.blue("check"), chalk.gray("Attempting to fix files"));
}

const fixResult = await autofixMotoko(mocPath, fileList, mocArgs);
if (fixResult) {
for (const [file, codes] of fixResult.fixedFiles) {
const unique = [...new Set(codes)].sort();
const n = codes.length;
const rel = relative(process.cwd(), file);
console.log(
chalk.green(
`Fixed ${rel} (${n} ${n === 1 ? "fix" : "fixes"}: ${unique.join(", ")})`,
),
);
}
const fileCount = fixResult.fixedFiles.size;
console.log(
chalk.green(
`\n✓ ${fixResult.totalFixCount} ${fixResult.totalFixCount === 1 ? "fix" : "fixes"} applied to ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
),
);
} else {
if (options.verbose) {
console.log(chalk.yellow("No fixes were needed"));
}
}
}

for (const file of fileList) {
try {
Expand All @@ -42,7 +105,9 @@ export async function check(
);
}

console.log(chalk.green(`✓ ${file}`));
if (!options.fix) {
console.log(chalk.green(`✓ ${file}`));
}
} catch (err: any) {
cliError(
`Error while checking ${file}${err?.message ? `\n${err.message}` : ""}`,
Expand Down
170 changes: 170 additions & 0 deletions cli/helpers/autofix-motoko.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { execa } from "execa";
import {
TextDocument,
type TextEdit,
} from "vscode-languageserver-textdocument";

interface Fix {
file: string;
code: string;
edit: TextEdit;
}

interface MocSpan {
file: string;
line_start: number;
column_start: number;
line_end: number;
column_end: number;
is_primary: boolean;
label: string | null;
suggested_replacement: string | null;
suggestion_applicability: string | null;
}

export interface MocDiagnostic {
message: string;
code: string;
level: string;
spans: MocSpan[];
notes: string[];
}

export function parseDiagnostics(stdout: string): MocDiagnostic[] {
return stdout
.split("\n")
.filter((l) => l.trim())
.map((l) => {
try {
return JSON.parse(l) as MocDiagnostic;
} catch {
return null;
}
})
.filter((d) => d !== null);
}

function extractFixes(diagnostics: MocDiagnostic[]): Fix[] {
const fixes: Fix[] = [];
for (const diag of diagnostics) {
for (const span of diag.spans) {
if (
span.suggestion_applicability === "MachineApplicable" &&
span.suggested_replacement !== null
) {
fixes.push({
file: span.file,
code: diag.code,
edit: {
range: {
start: {
line: span.line_start - 1,
character: span.column_start - 1,
},
end: {
line: span.line_end - 1,
character: span.column_end - 1,
},
},
newText: span.suggested_replacement,
},
});
}
}
}
return fixes;
}

const MAX_FIX_ITERATIONS = 10;

export interface AutofixResult {
/** Map of file path → diagnostic codes fixed in that file */
fixedFiles: Map<string, string[]>;
totalFixCount: number;
}

export async function autofixMotoko(
mocPath: string,
files: string[],
mocArgs: string[],
): Promise<AutofixResult | null> {
const fixedFilesCodes = new Map<string, string[]>();

for (let iteration = 0; iteration < MAX_FIX_ITERATIONS; iteration++) {
const allFixes: Fix[] = [];

for (const file of files) {
const result = await execa(
mocPath,
[file, "--error-format=json", ...mocArgs],
{ stdio: "pipe", reject: false },
);

const diagnostics = parseDiagnostics(result.stdout);
allFixes.push(...extractFixes(diagnostics));
}

if (allFixes.length === 0) {
break;
}

const fixesByFile = new Map<string, Fix[]>();
for (const fix of allFixes) {
const normalizedPath = resolve(fix.file);
const existing = fixesByFile.get(normalizedPath) ?? [];
existing.push(fix);
fixesByFile.set(normalizedPath, existing);
}

let progress = false;

for (const [file, fixes] of fixesByFile) {
const original = await readFile(file, "utf-8");
const doc = TextDocument.create(`file://${file}`, "motoko", 0, original);

let result: string;
try {
result = TextDocument.applyEdits(
doc,
fixes.map((f) => f.edit),
);
} catch (err) {
console.warn(`Warning: could not apply fixes to ${file}: ${err}`);
continue;
}

if (result === original) {
continue;
}

await writeFile(file, result, "utf-8");
progress = true;

const existing = fixedFilesCodes.get(file) ?? [];
for (const fix of fixes) {
existing.push(fix.code);
}
fixedFilesCodes.set(file, existing);
}

if (!progress) {
break;
}
}

if (fixedFilesCodes.size === 0) {
return null;
}

let totalFixCount = 0;
for (const codes of fixedFilesCodes.values()) {
totalFixCount += codes.length;
}

return {
fixedFiles: fixedFilesCodes,
totalFixCount,
};
}
13 changes: 12 additions & 1 deletion cli/helpers/get-moc-version.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { execFileSync } from "node:child_process";
import { type SemVer, parse } from "semver";
import { readConfig } from "../mops.js";
import { getMocPath } from "./get-moc-path.js";

export function getMocSemVer(): SemVer | null {
return parse(getMocVersion(false));
}

export function getMocVersion(throwOnError = false): string {
let mocPath = getMocPath(false);
let configVersion = readConfig().toolchain?.moc;
if (configVersion) {
return configVersion;
}

const mocPath = getMocPath(false);
if (!mocPath) {
return "";
}
Expand Down
9 changes: 8 additions & 1 deletion cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@
"stream-to-promise": "3.0.0",
"string-width": "7.2.0",
"tar": "7.5.6",
"terminal-size": "4.0.0"
"terminal-size": "4.0.0",
"vscode-languageserver-textdocument": "1.0.12"
},
"devDependencies": {
"@tsconfig/strictest": "2.0.5",
Expand Down
Loading
Loading