ic-mops 2.14.0 → 2.15.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 (67) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/bundle/cli.tgz +0 -0
  3. package/cli.ts +143 -3
  4. package/commands/build.ts +30 -77
  5. package/commands/check-stable.ts +4 -0
  6. package/commands/check.ts +4 -0
  7. package/commands/deployed.ts +162 -0
  8. package/commands/generate.ts +201 -0
  9. package/commands/lint.ts +6 -3
  10. package/dist/cli.js +75 -3
  11. package/dist/commands/build.d.ts +6 -0
  12. package/dist/commands/build.js +26 -52
  13. package/dist/commands/check-stable.d.ts +2 -0
  14. package/dist/commands/check-stable.js +2 -2
  15. package/dist/commands/check.d.ts +2 -0
  16. package/dist/commands/check.js +2 -1
  17. package/dist/commands/deployed.d.ts +10 -0
  18. package/dist/commands/deployed.js +97 -0
  19. package/dist/commands/generate.d.ts +6 -0
  20. package/dist/commands/generate.js +124 -0
  21. package/dist/commands/lint.d.ts +2 -0
  22. package/dist/commands/lint.js +1 -1
  23. package/dist/helpers/autofix-motoko.d.ts +2 -0
  24. package/dist/helpers/autofix-motoko.js +44 -42
  25. package/dist/helpers/migrations.d.ts +1 -1
  26. package/dist/helpers/migrations.js +8 -4
  27. package/dist/helpers/moc-args.d.ts +25 -0
  28. package/dist/helpers/moc-args.js +58 -0
  29. package/dist/package.json +6 -6
  30. package/dist/tests/check-fix-utf8.test.d.ts +1 -0
  31. package/dist/tests/check-fix-utf8.test.js +38 -0
  32. package/dist/tests/check.test.js +21 -0
  33. package/dist/tests/deployed.test.d.ts +1 -0
  34. package/dist/tests/deployed.test.js +173 -0
  35. package/dist/tests/generate.test.d.ts +1 -0
  36. package/dist/tests/generate.test.js +107 -0
  37. package/dist/tests/helpers.d.ts +8 -0
  38. package/dist/tests/helpers.js +28 -3
  39. package/dist/tests/migrate.test.js +5 -19
  40. package/dist/types.d.ts +3 -0
  41. package/helpers/autofix-motoko.ts +60 -52
  42. package/helpers/migrations.ts +8 -3
  43. package/helpers/moc-args.ts +123 -0
  44. package/package.json +6 -6
  45. package/tests/__snapshots__/check-fix-utf8.test.ts.snap +27 -0
  46. package/tests/__snapshots__/deployed.test.ts.snap +10 -0
  47. package/tests/__snapshots__/generate.test.ts.snap +39 -0
  48. package/tests/check/fix-utf8/mops.toml +5 -0
  49. package/tests/check/fix-utf8/multibyte.mo +15 -0
  50. package/tests/check-fix-utf8.test.ts +51 -0
  51. package/tests/check.test.ts +24 -0
  52. package/tests/deployed/basic/main.mo +4 -0
  53. package/tests/deployed/basic/mops.toml +8 -0
  54. package/tests/deployed/multi/bar.mo +1 -0
  55. package/tests/deployed/multi/foo.mo +1 -0
  56. package/tests/deployed/multi/mops.toml +11 -0
  57. package/tests/deployed.test.ts +249 -0
  58. package/tests/generate/basic/candid/bar.did +4 -0
  59. package/tests/generate/basic/mops.toml +12 -0
  60. package/tests/generate/basic/src/Bar.mo +5 -0
  61. package/tests/generate/basic/src/Foo.mo +5 -0
  62. package/tests/generate/error/mops.toml +8 -0
  63. package/tests/generate/error/src/Broken.mo +5 -0
  64. package/tests/generate.test.ts +140 -0
  65. package/tests/helpers.ts +31 -3
  66. package/tests/migrate.test.ts +7 -22
  67. package/types.ts +3 -0
@@ -0,0 +1,97 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { copyFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import chalk from "chalk";
5
+ import { cliError } from "../error.js";
6
+ import { filterCanisters, resolveCanisterConfigs, } from "../helpers/resolve-canisters.js";
7
+ import { getRootDir, readConfig, resolveConfigPath, writeConfig, } from "../mops.js";
8
+ import { resolveBuildOutputDir } from "./build.js";
9
+ export const DEFAULT_DEPLOYED_DIR = "deployed";
10
+ const EMPTY_ACTOR_MOST = "// Version: 1.0.0\nactor { };\n";
11
+ // `[deployed].dir` and CLI overrides are tracked in two forms:
12
+ // - `config`: relative to mops.toml, used for display + writing into mops.toml
13
+ // - `resolved`: relative to cwd, used for fs operations
14
+ function resolveDeployedDir(config, override) {
15
+ if (override) {
16
+ const configRel = path.relative(getRootDir(), path.resolve(override)) || ".";
17
+ return { config: configRel, resolved: override };
18
+ }
19
+ const configured = config.deployed?.dir ?? DEFAULT_DEPLOYED_DIR;
20
+ return { config: configured, resolved: resolveConfigPath(configured) };
21
+ }
22
+ function selectCanisters(canisterNames, config) {
23
+ if (canisterNames?.length === 0) {
24
+ cliError("No canisters specified");
25
+ }
26
+ const canisters = resolveCanisterConfigs(config);
27
+ if (!Object.keys(canisters).length) {
28
+ cliError(`No Motoko canisters found in mops.toml configuration`);
29
+ }
30
+ return filterCanisters(canisters, canisterNames);
31
+ }
32
+ function warnIfStablePathDiverges(name, stablePath, destMostRel) {
33
+ if (path.resolve(resolveConfigPath(stablePath)) ===
34
+ path.resolve(resolveConfigPath(destMostRel))) {
35
+ return;
36
+ }
37
+ console.warn(chalk.yellow(`WARN: [canisters.${name}.check-stable].path = "${stablePath}" ` +
38
+ `does not match where \`mops deployed\` writes ("${destMostRel}"). ` +
39
+ `\`mops check-stable\` won't see updates from \`mops deployed\`.`));
40
+ }
41
+ export async function deployed(canisterNames, options = {}) {
42
+ const config = readConfig();
43
+ const filtered = selectCanisters(canisterNames, config);
44
+ const buildDir = resolveBuildOutputDir(config, options.buildDir);
45
+ const deployedDir = resolveDeployedDir(config, options.dir);
46
+ mkdirSync(deployedDir.resolved, { recursive: true });
47
+ for (const [name, canister] of Object.entries(filtered)) {
48
+ const sourceMost = path.join(buildDir, `${name}.most`);
49
+ const destMostRel = path.join(deployedDir.config, `${name}.most`);
50
+ const destMost = path.join(deployedDir.resolved, `${name}.most`);
51
+ if (!existsSync(sourceMost)) {
52
+ cliError(`No built .most at ${sourceMost}. Run \`mops build ${name}\` first.`);
53
+ }
54
+ await copyFile(sourceMost, destMost);
55
+ console.log(chalk.green(`✓ ${sourceMost} → ${destMost}`));
56
+ const stablePath = canister["check-stable"]?.path;
57
+ if (stablePath) {
58
+ warnIfStablePathDiverges(name, stablePath, destMostRel);
59
+ }
60
+ }
61
+ }
62
+ export async function deployedInit(canisterNames, options = {}) {
63
+ const config = readConfig();
64
+ const filtered = selectCanisters(canisterNames, config);
65
+ const deployedDir = resolveDeployedDir(config, options.dir);
66
+ mkdirSync(deployedDir.resolved, { recursive: true });
67
+ let configChanged = false;
68
+ for (const [name, canister] of Object.entries(filtered)) {
69
+ const destMostRel = path.join(deployedDir.config, `${name}.most`);
70
+ const destMost = path.join(deployedDir.resolved, `${name}.most`);
71
+ if (!existsSync(destMost)) {
72
+ await writeFile(destMost, EMPTY_ACTOR_MOST);
73
+ console.log(chalk.green(`✓ Created baseline ${destMost}`));
74
+ }
75
+ const stablePath = canister["check-stable"]?.path;
76
+ if (!stablePath) {
77
+ const entry = config.canisters?.[name];
78
+ if (typeof entry === "string") {
79
+ config.canisters[name] = {
80
+ main: entry,
81
+ "check-stable": { path: destMostRel },
82
+ };
83
+ }
84
+ else if (entry) {
85
+ entry["check-stable"] = { path: destMostRel };
86
+ }
87
+ configChanged = true;
88
+ console.log(chalk.green(`✓ Set [canisters.${name}.check-stable].path = "${destMostRel}"`));
89
+ }
90
+ else {
91
+ warnIfStablePathDiverges(name, stablePath, destMostRel);
92
+ }
93
+ }
94
+ if (configChanged) {
95
+ writeConfig(config);
96
+ }
97
+ }
@@ -0,0 +1,6 @@
1
+ export interface GenerateCandidOptions {
2
+ output?: string;
3
+ verbose?: boolean;
4
+ extraArgs?: string[];
5
+ }
6
+ export declare function generateCandid(canisterNames: string[] | undefined, options: GenerateCandidOptions): Promise<void>;
@@ -0,0 +1,124 @@
1
+ import chalk from "chalk";
2
+ import { execa } from "execa";
3
+ import { mkdir } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { cliError } from "../error.js";
6
+ import { filterCanisters, resolveCanisterConfigs, } from "../helpers/resolve-canisters.js";
7
+ import { GENERATE_CANDID_MANAGED_FLAGS, prepareMocArgs, } from "../helpers/moc-args.js";
8
+ import { getRootDir, readConfig, resolveConfigPath, writeConfig, } from "../mops.js";
9
+ import { toolchain } from "./toolchain/index.js";
10
+ export async function generateCandid(canisterNames, options) {
11
+ if (canisterNames?.length === 0) {
12
+ cliError("No canisters specified");
13
+ }
14
+ const config = readConfig();
15
+ const canisters = resolveCanisterConfigs(config);
16
+ if (!Object.keys(canisters).length) {
17
+ cliError("No Motoko canisters found in mops.toml configuration");
18
+ }
19
+ const filtered = filterCanisters(canisters, canisterNames);
20
+ const filteredEntries = Object.entries(filtered);
21
+ if (options.output && filteredEntries.length > 1) {
22
+ cliError("--output / -o is only supported when generating for a single canister");
23
+ }
24
+ const mocPath = await toolchain.bin("moc", { fallback: true });
25
+ const rootDir = getRootDir();
26
+ let configChanged = false;
27
+ for (const [canisterName, canister] of filteredEntries) {
28
+ const dest = resolveDestination(canisterName, canister, canisters, options.output, rootDir);
29
+ console.log(chalk.blue("generate candid"), chalk.bold(canisterName), chalk.gray(`→ ${dest.fsPath}`));
30
+ const prepared = await prepareMocArgs(config, canister, canisterName, {
31
+ mode: "build",
32
+ managedFlags: GENERATE_CANDID_MANAGED_FLAGS,
33
+ commandName: "mops generate candid",
34
+ verbose: options.verbose,
35
+ extraArgs: options.extraArgs,
36
+ });
37
+ try {
38
+ await mkdir(path.dirname(dest.fsPath), { recursive: true });
39
+ const args = [
40
+ "--idl",
41
+ "-o",
42
+ dest.fsPath,
43
+ prepared.motokoPath,
44
+ ...prepared.args,
45
+ ];
46
+ if (options.verbose) {
47
+ console.log(chalk.gray(mocPath, JSON.stringify(args)));
48
+ }
49
+ const result = await execa(mocPath, args, {
50
+ stdio: options.verbose ? "inherit" : "pipe",
51
+ reject: false,
52
+ });
53
+ if (result.exitCode !== 0) {
54
+ if (!options.verbose) {
55
+ if (result.stderr) {
56
+ console.error(chalk.red(result.stderr));
57
+ }
58
+ if (result.stdout?.trim()) {
59
+ console.error(chalk.yellow("Output:"));
60
+ console.error(result.stdout);
61
+ }
62
+ }
63
+ cliError(`Failed to generate Candid for canister ${canisterName} (exit code: ${result.exitCode})`);
64
+ }
65
+ if (dest.configPath !== null) {
66
+ const c = (config.canisters ??= {});
67
+ const existing = c[canisterName];
68
+ const obj = typeof existing === "string"
69
+ ? { main: existing }
70
+ : { ...(existing ?? {}) };
71
+ obj.candid = dest.configPath;
72
+ c[canisterName] = obj;
73
+ configChanged = true;
74
+ }
75
+ }
76
+ finally {
77
+ await prepared.cleanup();
78
+ }
79
+ }
80
+ if (configChanged) {
81
+ writeConfig(config);
82
+ }
83
+ console.log(chalk.green(`\n✓ Generated Candid for ${filteredEntries.length} canister${filteredEntries.length === 1 ? "" : "s"}`));
84
+ }
85
+ function resolveDestination(canisterName, canister, allCanisters, outputFlag, rootDir) {
86
+ if (!canister.main) {
87
+ cliError(`No main file is specified for canister ${canisterName}`);
88
+ }
89
+ let fsPath;
90
+ let configPath;
91
+ if (outputFlag) {
92
+ fsPath = outputFlag;
93
+ configPath = null;
94
+ const outAbs = path.resolve(fsPath);
95
+ for (const [otherName, other] of Object.entries(allCanisters)) {
96
+ if (otherName === canisterName || !other.candid) {
97
+ continue;
98
+ }
99
+ if (path.resolve(rootDir, other.candid) === outAbs) {
100
+ console.warn(chalk.yellow(`Warning: --output path collides with [canisters.${otherName}].candid (${other.candid}). Sharing a .did between canisters is almost always a mistake.`));
101
+ }
102
+ }
103
+ }
104
+ else if (canister.candid) {
105
+ fsPath = resolveConfigPath(canister.candid);
106
+ configPath = null;
107
+ }
108
+ else {
109
+ // Default: <dirname(main)>/<canisterName>.did, forward slashes for the toml value
110
+ const mainDir = path.dirname(canister.main).replace(/\\/g, "/");
111
+ const projectRel = mainDir === "." || mainDir === ""
112
+ ? `${canisterName}.did`
113
+ : `${mainDir}/${canisterName}.did`;
114
+ fsPath = resolveConfigPath(projectRel);
115
+ configPath = projectRel;
116
+ }
117
+ const absPath = path.resolve(fsPath);
118
+ const dotMopsDir = path.resolve(rootDir, ".mops");
119
+ if (absPath === dotMopsDir || absPath.startsWith(dotMopsDir + path.sep)) {
120
+ cliError(`Refusing to write Candid file inside .mops/ (private build cache): ${fsPath}\n` +
121
+ "Choose a path outside .mops/ — it should be committable and readable by downstream tooling.");
122
+ }
123
+ return { fsPath, configPath };
124
+ }
@@ -6,5 +6,7 @@ export interface LintOptions {
6
6
  rules?: string[];
7
7
  files?: string[];
8
8
  extraArgs: string[];
9
+ /** Commander `--no-check-limit`: lint the full migration chain. */
10
+ noCheckLimit?: boolean;
9
11
  }
10
12
  export declare function lint(filter: string | undefined, options: Partial<LintOptions>): Promise<void>;
@@ -122,7 +122,7 @@ async function lintImpl(filter, options) {
122
122
  ? await toolchain.bin("lintoko")
123
123
  : "lintoko";
124
124
  const isExplicit = !!filter || !!(options.files && options.files.length > 0);
125
- const trimmedMigrations = isExplicit
125
+ const trimmedMigrations = isExplicit || options.noCheckLimit
126
126
  ? new Set()
127
127
  : getTrimmedMigrationFiles(config);
128
128
  let filesToLint;
@@ -1,5 +1,7 @@
1
1
  interface MocSpan {
2
2
  file: string;
3
+ byte_start?: number | null;
4
+ byte_end?: number | null;
3
5
  line_start: number;
4
6
  column_start: number;
5
7
  line_end: number;
@@ -1,7 +1,7 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { execa } from "execa";
4
- import { TextDocument, } from "vscode-languageserver-textdocument";
4
+ import { TextDocument } from "vscode-languageserver-textdocument";
5
5
  export function parseDiagnostics(stdout) {
6
6
  if (!stdout) {
7
7
  return [];
@@ -24,25 +24,14 @@ function extractDiagnosticFixes(diagnostics) {
24
24
  for (const diag of diagnostics) {
25
25
  const editsByFile = new Map();
26
26
  for (const span of diag.spans) {
27
- if (span.suggestion_applicability === "MachineApplicable" &&
28
- span.suggested_replacement !== null) {
29
- const file = resolve(span.file);
30
- const edits = editsByFile.get(file) ?? [];
31
- edits.push({
32
- range: {
33
- start: {
34
- line: span.line_start - 1,
35
- character: span.column_start - 1,
36
- },
37
- end: {
38
- line: span.line_end - 1,
39
- character: span.column_end - 1,
40
- },
41
- },
42
- newText: span.suggested_replacement,
43
- });
44
- editsByFile.set(file, edits);
27
+ if (span.suggestion_applicability !== "MachineApplicable" ||
28
+ span.suggested_replacement === null) {
29
+ continue;
45
30
  }
31
+ const file = resolve(span.file);
32
+ const edits = editsByFile.get(file) ?? [];
33
+ edits.push({ span, newText: span.suggested_replacement });
34
+ editsByFile.set(file, edits);
46
35
  }
47
36
  for (const [file, edits] of editsByFile) {
48
37
  const existing = result.get(file) ?? [];
@@ -52,32 +41,47 @@ function extractDiagnosticFixes(diagnostics) {
52
41
  }
53
42
  return result;
54
43
  }
55
- function normalizeRange(range) {
56
- const { start, end } = range;
57
- if (start.line > end.line ||
58
- (start.line === end.line && start.character > end.character)) {
59
- return { start: end, end: start };
60
- }
61
- return range;
62
- }
63
44
  /**
64
- * Applies diagnostic fixes to a document, processing each diagnostic as
45
+ * Applies diagnostic fixes to a file's contents, processing each diagnostic as
65
46
  * an atomic unit. If any edit from a diagnostic overlaps with an already-accepted
66
47
  * edit, the entire diagnostic is skipped (picked up in subsequent iterations).
67
48
  * Based on vscode-languageserver-textdocument's TextDocument.applyEdits.
49
+ *
50
+ * Edits prefer `byte_start`/`byte_end` (byte-accurate on any UTF-8 source);
51
+ * falls back to line+column for older moc, which mis-applies on non-ASCII
52
+ * lines since moc's `column_*` are bytes but LSP expects UTF-16 code units.
68
53
  */
69
- function applyDiagnosticFixes(doc, fixes) {
54
+ function applyDiagnosticFixes(content, fixes) {
55
+ let buf = null;
56
+ const byteToOffset = (byteOffset) => {
57
+ buf ??= Buffer.from(content, "utf8");
58
+ return buf.subarray(0, byteOffset).toString("utf8").length;
59
+ };
60
+ let doc = null;
61
+ const toOffsetEdit = ({ span, newText }) => {
62
+ if (span.byte_start != null && span.byte_end != null) {
63
+ const [s, e] = span.byte_start <= span.byte_end
64
+ ? [span.byte_start, span.byte_end]
65
+ : [span.byte_end, span.byte_start];
66
+ return { start: byteToOffset(s), end: byteToOffset(e), newText };
67
+ }
68
+ doc ??= TextDocument.create("inmemory://autofix", "motoko", 0, content);
69
+ const start = doc.offsetAt({
70
+ line: span.line_start - 1,
71
+ character: span.column_start - 1,
72
+ });
73
+ const end = doc.offsetAt({
74
+ line: span.line_end - 1,
75
+ character: span.column_end - 1,
76
+ });
77
+ return start <= end
78
+ ? { start, end, newText }
79
+ : { start: end, end: start, newText };
80
+ };
70
81
  const acceptedEdits = [];
71
82
  const appliedCodes = [];
72
83
  for (const fix of fixes) {
73
- const offsets = fix.edits.map((e) => {
74
- const range = normalizeRange(e.range);
75
- return {
76
- start: doc.offsetAt(range.start),
77
- end: doc.offsetAt(range.end),
78
- newText: e.newText,
79
- };
80
- });
84
+ const offsets = fix.edits.map(toOffsetEdit);
81
85
  const overlaps = offsets.some((o) => acceptedEdits.some((a) => o.start < a.end && o.end > a.start));
82
86
  if (overlaps) {
83
87
  continue;
@@ -86,7 +90,6 @@ function applyDiagnosticFixes(doc, fixes) {
86
90
  appliedCodes.push(fix.code);
87
91
  }
88
92
  acceptedEdits.sort((a, b) => a.start - b.start);
89
- const text = doc.getText();
90
93
  const spans = [];
91
94
  let lastOffset = 0;
92
95
  for (const edit of acceptedEdits) {
@@ -94,14 +97,14 @@ function applyDiagnosticFixes(doc, fixes) {
94
97
  continue;
95
98
  }
96
99
  if (edit.start > lastOffset) {
97
- spans.push(text.substring(lastOffset, edit.start));
100
+ spans.push(content.substring(lastOffset, edit.start));
98
101
  }
99
102
  if (edit.newText.length) {
100
103
  spans.push(edit.newText);
101
104
  }
102
105
  lastOffset = edit.end;
103
106
  }
104
- spans.push(text.substring(lastOffset));
107
+ spans.push(content.substring(lastOffset));
105
108
  return { text: spans.join(""), appliedCodes };
106
109
  }
107
110
  const MAX_FIX_ITERATIONS = 10;
@@ -124,8 +127,7 @@ export async function autofixMotoko(mocPath, files, mocArgs) {
124
127
  let progress = false;
125
128
  for (const [file, fixes] of fixesByFile) {
126
129
  const original = await readFile(file, "utf-8");
127
- const doc = TextDocument.create(`file://${file}`, "motoko", 0, original);
128
- const { text: result, appliedCodes } = applyDiagnosticFixes(doc, fixes);
130
+ const { text: result, appliedCodes } = applyDiagnosticFixes(original, fixes);
129
131
  if (result === original) {
130
132
  continue;
131
133
  }
@@ -7,7 +7,7 @@ export declare function getMigrationFiles(dir: string): string[];
7
7
  export declare function getNextMigrationFile(nextDir: string): string | null;
8
8
  export declare function validateNextMigrationOrder(chainDirOrFiles: string | string[], nextFile: string): void;
9
9
  export declare function validateMigrationsConfig(migrations: MigrationsConfig, canisterName: string): void;
10
- export declare function prepareMigrationArgs(migrations: MigrationsConfig | undefined, canisterName: string, mode: "check" | "build", verbose?: boolean): Promise<MigrationArgsResult>;
10
+ export declare function prepareMigrationArgs(migrations: MigrationsConfig | undefined, canisterName: string, mode: "check" | "build", verbose?: boolean, ignoreLimit?: boolean): Promise<MigrationArgsResult>;
11
11
  /**
12
12
  * Absolute paths of chain migration files that `mops lint` should skip,
13
13
  * mirroring the `check-limit` trimming applied to `moc` during `mops check`.
@@ -68,7 +68,7 @@ export function validateMigrationsConfig(migrations, canisterName) {
68
68
  * for moc) and `getTrimmedMigrationFiles` (which feeds `excludedChainFiles`
69
69
  * to lint).
70
70
  */
71
- function resolveMigrationChain(migrations, canisterName, mode) {
71
+ function resolveMigrationChain(migrations, canisterName, mode, ignoreLimit = false) {
72
72
  validateMigrationsConfig(migrations, canisterName);
73
73
  const chainDir = resolveConfigPath(migrations.chain);
74
74
  const nextDir = migrations.next
@@ -91,7 +91,11 @@ function resolveMigrationChain(migrations, canisterName, mode) {
91
91
  if (nextFile && nextDir) {
92
92
  all.push({ file: nextFile, dir: nextDir });
93
93
  }
94
- const limit = mode === "check" ? migrations["check-limit"] : migrations["build-limit"];
94
+ const limit = ignoreLimit
95
+ ? undefined
96
+ : mode === "check"
97
+ ? migrations["check-limit"]
98
+ : migrations["build-limit"];
95
99
  const isTrimming = limit !== undefined && limit < all.length;
96
100
  const included = isTrimming ? all.slice(-limit) : all;
97
101
  // Dropped entries are always a chain-only prefix (next sorts last).
@@ -100,11 +104,11 @@ function resolveMigrationChain(migrations, canisterName, mode) {
100
104
  .map((e) => resolve(e.dir, e.file));
101
105
  return { chainDir, nextDir, included, excludedChainFiles, isTrimming };
102
106
  }
103
- export async function prepareMigrationArgs(migrations, canisterName, mode, verbose) {
107
+ export async function prepareMigrationArgs(migrations, canisterName, mode, verbose, ignoreLimit = false) {
104
108
  if (!migrations) {
105
109
  return { migrationArgs: [], cleanup: async () => { } };
106
110
  }
107
- const { chainDir, nextDir, included, excludedChainFiles, isTrimming } = resolveMigrationChain(migrations, canisterName, mode);
111
+ const { chainDir, nextDir, included, excludedChainFiles, isTrimming } = resolveMigrationChain(migrations, canisterName, mode, ignoreLimit);
108
112
  const hasNext = included.some((e) => e.dir === nextDir);
109
113
  const needsTempDir = hasNext || isTrimming;
110
114
  if (!needsTempDir) {
@@ -0,0 +1,25 @@
1
+ import { CanisterConfig, Config } from "../types.js";
2
+ /** Single source of truth for "how moc is invoked for canister X" — keeps
3
+ * `mops build` and `mops generate candid` in lockstep so the auto-generated
4
+ * `.did` they each produce stays subtype-compatible with the curated one
5
+ * embedded by `mops build`. */
6
+ export interface PrepareMocArgsOptions {
7
+ /** Selects the migration trim limit (`build-limit` vs `check-limit`). */
8
+ mode: "check" | "build";
9
+ /** Per-flag hints used when warning about conflicting `args` entries. */
10
+ managedFlags: Record<string, string>;
11
+ /** Command label shown in the warning, e.g. "mops build". */
12
+ commandName: string;
13
+ verbose?: boolean;
14
+ extraArgs?: string[];
15
+ }
16
+ export interface PreparedMocArgs {
17
+ motokoPath: string;
18
+ /** sources + [moc].args + migration + [build].args + [canisters.x].args + CLI extras. */
19
+ args: string[];
20
+ /** Cleans up the migration staging dir; call from a `finally`. */
21
+ cleanup: () => Promise<void>;
22
+ }
23
+ export declare const BUILD_MANAGED_FLAGS: Record<string, string>;
24
+ export declare const GENERATE_CANDID_MANAGED_FLAGS: Record<string, string>;
25
+ export declare function prepareMocArgs(config: Config, canister: CanisterConfig, canisterName: string, options: PrepareMocArgsOptions): Promise<PreparedMocArgs>;
@@ -0,0 +1,58 @@
1
+ import chalk from "chalk";
2
+ import { cliError } from "../error.js";
3
+ import { getGlobalMocArgs, resolveConfigPath } from "../mops.js";
4
+ import { sourcesArgs } from "../commands/sources.js";
5
+ import { prepareMigrationArgs } from "./migrations.js";
6
+ import { validateCanisterArgs } from "./resolve-canisters.js";
7
+ export const BUILD_MANAGED_FLAGS = {
8
+ "-o": "use [build].outputDir in mops.toml or --output flag instead",
9
+ "-c": "this flag is always set by mops build",
10
+ "--idl": "this flag is always set by mops build",
11
+ "--stable-types": "this flag is always set by mops build",
12
+ "--public-metadata": "this flag is managed by mops build",
13
+ };
14
+ export const GENERATE_CANDID_MANAGED_FLAGS = {
15
+ "-o": "use the --output flag on `mops generate candid` instead",
16
+ "-c": "this flag is incompatible with mops generate candid (would also emit .wasm)",
17
+ "--idl": "this flag is always set by mops generate candid",
18
+ "--stable-types": "this flag is incompatible with mops generate candid (would also emit .most)",
19
+ };
20
+ export async function prepareMocArgs(config, canister, canisterName, options) {
21
+ if (!canister.main) {
22
+ cliError(`No main file is specified for canister ${canisterName}`);
23
+ }
24
+ const motokoPath = resolveConfigPath(canister.main);
25
+ const migration = await prepareMigrationArgs(canister.migrations, canisterName, options.mode, options.verbose);
26
+ const args = [
27
+ ...(await sourcesArgs()).flat(),
28
+ ...getGlobalMocArgs(config),
29
+ ...migration.migrationArgs,
30
+ ...collectExtraArgs(config, canister, canisterName, options.managedFlags, options.commandName, options.extraArgs),
31
+ ];
32
+ return { motokoPath, args, cleanup: migration.cleanup };
33
+ }
34
+ function collectExtraArgs(config, canister, canisterName, managedFlags, commandName, extraArgs) {
35
+ const args = [];
36
+ if (config.build?.args) {
37
+ if (typeof config.build.args === "string") {
38
+ cliError(`[build] config 'args' should be an array of strings in mops.toml config file`);
39
+ }
40
+ args.push(...config.build.args);
41
+ }
42
+ if (canister.args) {
43
+ validateCanisterArgs(canister, canisterName, config);
44
+ args.push(...canister.args);
45
+ }
46
+ if (extraArgs) {
47
+ args.push(...extraArgs);
48
+ }
49
+ const warned = new Set();
50
+ for (const arg of args) {
51
+ const hint = managedFlags[arg];
52
+ if (hint && !warned.has(arg)) {
53
+ warned.add(arg);
54
+ console.warn(chalk.yellow(`Warning: '${arg}' in args for canister ${canisterName} may conflict with ${commandName} — ${hint}`));
55
+ }
56
+ }
57
+ return args;
58
+ }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "bin/mops.js",
@@ -55,7 +55,7 @@
55
55
  "markdown-table": "3.0.4",
56
56
  "mdast-util-from-markdown": "2.0.2",
57
57
  "mdast-util-to-markdown": "2.1.2",
58
- "minimatch": "10.2.4",
58
+ "minimatch": "10.2.5",
59
59
  "ncp": "2.0.0",
60
60
  "octokit": "3.1.2",
61
61
  "pem-file": "1.0.1",
@@ -69,7 +69,7 @@
69
69
  "semver": "7.7.1",
70
70
  "stream-to-promise": "3.0.0",
71
71
  "string-width": "7.2.0",
72
- "tar": "7.5.11",
72
+ "tar": "7.5.16",
73
73
  "terminal-size": "4.0.0",
74
74
  "vscode-languageserver-textdocument": "1.0.12"
75
75
  },
@@ -85,13 +85,13 @@
85
85
  "@types/proper-lockfile": "4.1.4",
86
86
  "@types/semver": "7.5.8",
87
87
  "@types/stream-to-promise": "2.2.4",
88
- "@types/tar": "6.1.13",
89
- "esbuild": "0.23.1",
88
+ "@types/tar": "7.0.87",
89
+ "esbuild": "0.28.1",
90
90
  "eslint": "8.57.0",
91
91
  "npm-run-all": "4.1.5",
92
92
  "rexreplace": "7.1.13",
93
93
  "ts-jest": "29.4.5",
94
- "tsx": "4.19.2",
94
+ "tsx": "4.22.4",
95
95
  "typescript": "5.9.2",
96
96
  "wasm-pack": "0.15.0"
97
97
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test, beforeAll } from "@jest/globals";
2
+ import { cpSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
3
+ import path from "path";
4
+ import { cli, normalizePaths } from "./helpers";
5
+ // Regression: --fix must apply byte-accurately on UTF-8 source. The fixture
6
+ // pins a moc version that emits `byte_start`/`byte_end` so the byte path is
7
+ // exercised; without those fields the fixer over-deletes on multi-byte lines
8
+ // (e.g. `Char.toNat32(c)` after a `"京"` on the same line losing its `)`).
9
+ describe("check --fix (utf-8 source)", () => {
10
+ const fixDir = path.join(import.meta.dirname, "check/fix-utf8");
11
+ const runDir = path.join(fixDir, "run");
12
+ const warningFlags = "-W=M0236";
13
+ beforeAll(() => {
14
+ for (const file of readdirSync(runDir).filter((f) => f.endsWith(".mo"))) {
15
+ unlinkSync(path.join(runDir, file));
16
+ }
17
+ });
18
+ test("multi-byte characters do not corrupt the fix", async () => {
19
+ const src = "multibyte.mo";
20
+ const runFilePath = path.join(runDir, src);
21
+ cpSync(path.join(fixDir, src), runFilePath);
22
+ const fixResult = await cli(["check", runFilePath, "--fix", "--", warningFlags], { cwd: fixDir });
23
+ expect(normalizePaths(fixResult.stdout)).toMatchSnapshot("fix output");
24
+ const fixedContent = readFileSync(runFilePath, "utf-8");
25
+ expect(fixedContent).toMatchSnapshot("fixed file");
26
+ // Concrete byte-accuracy assertions: every Char.toNat32 call must be
27
+ // rewritten to dot notation, with the multi-byte literal before it intact
28
+ // and the trailing `)` preserved (the original regression: 京/💩 used to
29
+ // drop it).
30
+ expect(fixedContent).toContain('"A" # debug_show (c.toNat32());');
31
+ expect(fixedContent).toContain('"京" # debug_show (c.toNat32());');
32
+ expect(fixedContent).toContain('"💩" # debug_show (c.toNat32());');
33
+ expect(fixedContent).not.toMatch(/debug_show \(Char\.toNat32/);
34
+ // Verify no remaining M0236 warnings.
35
+ const afterResult = await cli(["check", runFilePath, "--", warningFlags, "--error-format=json"], { cwd: fixDir });
36
+ expect(afterResult.stdout).not.toContain('"code":"M0236"');
37
+ });
38
+ });
@@ -137,4 +137,25 @@ describe("check", () => {
137
137
  expect(result.exitCode).toBe(0);
138
138
  expect(result.stdout).toMatch(/✓ Lint fixes applied/);
139
139
  });
140
+ // The migrations-chain fixture has 4 migrations with check-limit=3, so the
141
+ // oldest is trimmed by default (staged dir + M0254 suppression). --no-check-limit
142
+ // must point moc at the real chain dir and drop the trimming side effects.
143
+ const migrationsChain = "check-stable/migrations-chain";
144
+ test("check-limit trims the migration chain by default", async () => {
145
+ const cwd = path.join(import.meta.dirname, migrationsChain);
146
+ const result = await cli(["check", "--verbose"], { cwd });
147
+ expect(result.exitCode).toBe(0);
148
+ expect(result.stdout).toMatch(/--enhanced-migration=[^"]*\.migrations-/);
149
+ expect(result.stdout).toMatch(/-A=M0254/);
150
+ });
151
+ test("--no-check-limit uses the full migration chain", async () => {
152
+ const cwd = path.join(import.meta.dirname, migrationsChain);
153
+ const result = await cli(["check", "--verbose", "--no-check-limit"], {
154
+ cwd,
155
+ });
156
+ expect(result.exitCode).toBe(0);
157
+ expect(result.stdout).toMatch(/--enhanced-migration=/);
158
+ expect(result.stdout).not.toMatch(/\.migrations-/);
159
+ expect(result.stdout).not.toMatch(/-A=M0254/);
160
+ });
140
161
  });
@@ -0,0 +1 @@
1
+ export {};