ic-mops 2.14.1 → 2.15.1

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 (77) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bundle/cli.tgz +0 -0
  3. package/cli.ts +149 -4
  4. package/commands/bench-replica.ts +2 -1
  5. package/commands/bench.ts +32 -9
  6. package/commands/build.ts +30 -77
  7. package/commands/check-stable.ts +4 -0
  8. package/commands/check.ts +26 -22
  9. package/commands/deployed.ts +162 -0
  10. package/commands/generate.ts +201 -0
  11. package/commands/lint.ts +6 -3
  12. package/dist/cli.js +76 -4
  13. package/dist/commands/bench-replica.js +3 -1
  14. package/dist/commands/bench.js +21 -4
  15. package/dist/commands/build.d.ts +6 -0
  16. package/dist/commands/build.js +26 -52
  17. package/dist/commands/check-stable.d.ts +2 -0
  18. package/dist/commands/check-stable.js +2 -2
  19. package/dist/commands/check.d.ts +2 -0
  20. package/dist/commands/check.js +20 -21
  21. package/dist/commands/deployed.d.ts +10 -0
  22. package/dist/commands/deployed.js +97 -0
  23. package/dist/commands/generate.d.ts +6 -0
  24. package/dist/commands/generate.js +124 -0
  25. package/dist/commands/lint.d.ts +2 -0
  26. package/dist/commands/lint.js +1 -1
  27. package/dist/helpers/autofix-motoko.d.ts +2 -0
  28. package/dist/helpers/autofix-motoko.js +71 -51
  29. package/dist/helpers/migrations.d.ts +1 -1
  30. package/dist/helpers/migrations.js +8 -4
  31. package/dist/helpers/moc-args.d.ts +25 -0
  32. package/dist/helpers/moc-args.js +58 -0
  33. package/dist/helpers/pocket-ic-client.d.ts +2 -2
  34. package/dist/helpers/pocket-ic-client.js +8 -4
  35. package/dist/package.json +6 -6
  36. package/dist/tests/check-fix-utf8.test.d.ts +1 -0
  37. package/dist/tests/check-fix-utf8.test.js +38 -0
  38. package/dist/tests/check-fix.test.js +14 -2
  39. package/dist/tests/check.test.js +21 -6
  40. package/dist/tests/deployed.test.d.ts +1 -0
  41. package/dist/tests/deployed.test.js +173 -0
  42. package/dist/tests/generate.test.d.ts +1 -0
  43. package/dist/tests/generate.test.js +107 -0
  44. package/dist/tests/helpers.d.ts +8 -0
  45. package/dist/tests/helpers.js +28 -3
  46. package/dist/tests/migrate.test.js +5 -19
  47. package/dist/types.d.ts +3 -0
  48. package/helpers/autofix-motoko.ts +96 -66
  49. package/helpers/migrations.ts +8 -3
  50. package/helpers/moc-args.ts +123 -0
  51. package/helpers/pocket-ic-client.ts +11 -5
  52. package/package.json +6 -6
  53. package/tests/__snapshots__/check-fix-utf8.test.ts.snap +27 -0
  54. package/tests/__snapshots__/check.test.ts.snap +4 -17
  55. package/tests/__snapshots__/deployed.test.ts.snap +10 -0
  56. package/tests/__snapshots__/generate.test.ts.snap +39 -0
  57. package/tests/check/fix-utf8/mops.toml +5 -0
  58. package/tests/check/fix-utf8/multibyte.mo +15 -0
  59. package/tests/check-fix-utf8.test.ts +51 -0
  60. package/tests/check-fix.test.ts +26 -2
  61. package/tests/check.test.ts +24 -11
  62. package/tests/deployed/basic/main.mo +4 -0
  63. package/tests/deployed/basic/mops.toml +8 -0
  64. package/tests/deployed/multi/bar.mo +1 -0
  65. package/tests/deployed/multi/foo.mo +1 -0
  66. package/tests/deployed/multi/mops.toml +11 -0
  67. package/tests/deployed.test.ts +249 -0
  68. package/tests/generate/basic/candid/bar.did +4 -0
  69. package/tests/generate/basic/mops.toml +12 -0
  70. package/tests/generate/basic/src/Bar.mo +5 -0
  71. package/tests/generate/basic/src/Foo.mo +5 -0
  72. package/tests/generate/error/mops.toml +8 -0
  73. package/tests/generate/error/src/Broken.mo +5 -0
  74. package/tests/generate.test.ts +140 -0
  75. package/tests/helpers.ts +31 -3
  76. package/tests/migrate.test.ts +7 -22
  77. package/types.ts +3 -0
@@ -1,7 +1,8 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
- import { resolve } from "node:path";
2
+ import { relative, resolve } from "node:path";
3
+ import chalk from "chalk";
3
4
  import { execa } from "execa";
4
- import { TextDocument, } from "vscode-languageserver-textdocument";
5
+ import { TextDocument } from "vscode-languageserver-textdocument";
5
6
  export function parseDiagnostics(stdout) {
6
7
  if (!stdout) {
7
8
  return [];
@@ -24,25 +25,14 @@ function extractDiagnosticFixes(diagnostics) {
24
25
  for (const diag of diagnostics) {
25
26
  const editsByFile = new Map();
26
27
  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);
28
+ if (span.suggestion_applicability !== "MachineApplicable" ||
29
+ span.suggested_replacement === null) {
30
+ continue;
45
31
  }
32
+ const file = resolve(span.file);
33
+ const edits = editsByFile.get(file) ?? [];
34
+ edits.push({ span, newText: span.suggested_replacement });
35
+ editsByFile.set(file, edits);
46
36
  }
47
37
  for (const [file, edits] of editsByFile) {
48
38
  const existing = result.get(file) ?? [];
@@ -52,32 +42,47 @@ function extractDiagnosticFixes(diagnostics) {
52
42
  }
53
43
  return result;
54
44
  }
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
45
  /**
64
- * Applies diagnostic fixes to a document, processing each diagnostic as
46
+ * Applies diagnostic fixes to a file's contents, processing each diagnostic as
65
47
  * an atomic unit. If any edit from a diagnostic overlaps with an already-accepted
66
48
  * edit, the entire diagnostic is skipped (picked up in subsequent iterations).
67
49
  * Based on vscode-languageserver-textdocument's TextDocument.applyEdits.
50
+ *
51
+ * Edits prefer `byte_start`/`byte_end` (byte-accurate on any UTF-8 source);
52
+ * falls back to line+column for older moc, which mis-applies on non-ASCII
53
+ * lines since moc's `column_*` are bytes but LSP expects UTF-16 code units.
68
54
  */
69
- function applyDiagnosticFixes(doc, fixes) {
55
+ function applyDiagnosticFixes(content, fixes) {
56
+ let buf = null;
57
+ const byteToOffset = (byteOffset) => {
58
+ buf ??= Buffer.from(content, "utf8");
59
+ return buf.subarray(0, byteOffset).toString("utf8").length;
60
+ };
61
+ let doc = null;
62
+ const toOffsetEdit = ({ span, newText }) => {
63
+ if (span.byte_start != null && span.byte_end != null) {
64
+ const [s, e] = span.byte_start <= span.byte_end
65
+ ? [span.byte_start, span.byte_end]
66
+ : [span.byte_end, span.byte_start];
67
+ return { start: byteToOffset(s), end: byteToOffset(e), newText };
68
+ }
69
+ doc ??= TextDocument.create("inmemory://autofix", "motoko", 0, content);
70
+ const start = doc.offsetAt({
71
+ line: span.line_start - 1,
72
+ character: span.column_start - 1,
73
+ });
74
+ const end = doc.offsetAt({
75
+ line: span.line_end - 1,
76
+ character: span.column_end - 1,
77
+ });
78
+ return start <= end
79
+ ? { start, end, newText }
80
+ : { start: end, end: start, newText };
81
+ };
70
82
  const acceptedEdits = [];
71
83
  const appliedCodes = [];
72
84
  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
- });
85
+ const offsets = fix.edits.map(toOffsetEdit);
81
86
  const overlaps = offsets.some((o) => acceptedEdits.some((a) => o.start < a.end && o.end > a.start));
82
87
  if (overlaps) {
83
88
  continue;
@@ -86,7 +91,6 @@ function applyDiagnosticFixes(doc, fixes) {
86
91
  appliedCodes.push(fix.code);
87
92
  }
88
93
  acceptedEdits.sort((a, b) => a.start - b.start);
89
- const text = doc.getText();
90
94
  const spans = [];
91
95
  let lastOffset = 0;
92
96
  for (const edit of acceptedEdits) {
@@ -94,41 +98,57 @@ function applyDiagnosticFixes(doc, fixes) {
94
98
  continue;
95
99
  }
96
100
  if (edit.start > lastOffset) {
97
- spans.push(text.substring(lastOffset, edit.start));
101
+ spans.push(content.substring(lastOffset, edit.start));
98
102
  }
99
103
  if (edit.newText.length) {
100
104
  spans.push(edit.newText);
101
105
  }
102
106
  lastOffset = edit.end;
103
107
  }
104
- spans.push(text.substring(lastOffset));
108
+ spans.push(content.substring(lastOffset));
105
109
  return { text: spans.join(""), appliedCodes };
106
110
  }
107
111
  const MAX_FIX_ITERATIONS = 10;
108
112
  export async function autofixMotoko(mocPath, files, mocArgs) {
109
113
  const fixedFilesCodes = new Map();
114
+ // Frozen migration files are often chmod'd read-only; warn once and skip
115
+ // them rather than aborting the whole run on EACCES/EPERM.
116
+ const readOnlySkipped = new Set();
110
117
  for (let iteration = 0; iteration < MAX_FIX_ITERATIONS; iteration++) {
111
118
  const fixesByFile = new Map();
112
- // Single invocation: moc dedups shared imports across all files.
113
- const result = await execa(mocPath, [...files, ...mocArgs, "--error-format=json"], { stdio: "pipe", reject: false });
114
- const diagnostics = parseDiagnostics(result.stdout);
115
- for (const [targetFile, fixes] of extractDiagnosticFixes(diagnostics)) {
116
- const existing = fixesByFile.get(targetFile) ?? [];
117
- existing.push(...fixes);
118
- fixesByFile.set(targetFile, existing);
119
+ for (const file of files) {
120
+ const result = await execa(mocPath, [file, ...mocArgs, "--error-format=json"], { stdio: "pipe", reject: false });
121
+ const diagnostics = parseDiagnostics(result.stdout);
122
+ for (const [targetFile, fixes] of extractDiagnosticFixes(diagnostics)) {
123
+ const existing = fixesByFile.get(targetFile) ?? [];
124
+ existing.push(...fixes);
125
+ fixesByFile.set(targetFile, existing);
126
+ }
119
127
  }
120
128
  if (fixesByFile.size === 0) {
121
129
  break;
122
130
  }
123
131
  let progress = false;
124
132
  for (const [file, fixes] of fixesByFile) {
133
+ if (readOnlySkipped.has(file)) {
134
+ continue;
135
+ }
125
136
  const original = await readFile(file, "utf-8");
126
- const doc = TextDocument.create(`file://${file}`, "motoko", 0, original);
127
- const { text: result, appliedCodes } = applyDiagnosticFixes(doc, fixes);
137
+ const { text: result, appliedCodes } = applyDiagnosticFixes(original, fixes);
128
138
  if (result === original) {
129
139
  continue;
130
140
  }
131
- await writeFile(file, result, "utf-8");
141
+ try {
142
+ await writeFile(file, result, "utf-8");
143
+ }
144
+ catch (err) {
145
+ if (err?.code === "EACCES" || err?.code === "EPERM") {
146
+ readOnlySkipped.add(file);
147
+ console.warn(chalk.yellow(`Skipped read-only file ${relative(process.cwd(), file)} (${appliedCodes.length} fix(es) not applied)`));
148
+ continue;
149
+ }
150
+ throw err;
151
+ }
132
152
  progress = true;
133
153
  const existing = fixedFilesCodes.get(file) ?? [];
134
154
  existing.push(...appliedCodes);
@@ -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
+ }
@@ -1,5 +1,5 @@
1
- import { PocketIc, PocketIcServer } from "pic-ic";
2
- import { PocketIc as PocketIcModern, PocketIcServer as PocketIcServerModern, type StartServerOptions } from "pic-js-mops";
1
+ import type { PocketIc, PocketIcServer } from "pic-ic";
2
+ import type { PocketIc as PocketIcModern, PocketIcServer as PocketIcServerModern, StartServerOptions } from "pic-js-mops";
3
3
  export type AnyPocketIcServer = PocketIcServer | PocketIcServerModern;
4
4
  export type AnyPocketIc = PocketIc | PocketIcModern;
5
5
  export type AnySetupCanister = PocketIc["setupCanister"] & PocketIcModern["setupCanister"];
@@ -1,18 +1,22 @@
1
1
  import semver from "semver";
2
- import { PocketIc, PocketIcServer } from "pic-ic";
3
- import { PocketIc as PocketIcModern, PocketIcServer as PocketIcServerModern, } from "pic-js-mops";
4
2
  import { readConfig } from "../mops.js";
5
3
  function isLegacy() {
6
4
  let version = readConfig().toolchain?.["pocket-ic"];
7
5
  return !!version && !!semver.valid(version) && semver.lt(version, "9.0.0");
8
6
  }
9
7
  export async function startPocketIc(options) {
8
+ // Imported lazily so commands that never start a replica don't load the
9
+ // PocketIC client. `pic-js-mops` ships ESM without `type: module`, which a
10
+ // static import fails to resolve under tsx (local dev); a dynamic import
11
+ // resolves it on every platform.
10
12
  if (isLegacy()) {
13
+ const { PocketIc, PocketIcServer } = await import("pic-ic");
11
14
  let server = await PocketIcServer.start(options);
12
15
  let client = await PocketIc.create(server.getUrl());
13
16
  return { server, client };
14
17
  }
15
- let server = await PocketIcServerModern.start(options);
16
- let client = await PocketIcModern.create(server.getUrl());
18
+ const { PocketIc, PocketIcServer } = await import("pic-js-mops");
19
+ let server = await PocketIcServer.start(options);
20
+ let client = await PocketIc.create(server.getUrl());
17
21
  return { server, client };
18
22
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.14.1",
3
+ "version": "2.15.1",
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
+ });
@@ -1,5 +1,5 @@
1
1
  import { beforeAll, describe, expect, test } from "@jest/globals";
2
- import { cpSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { chmodSync, cpSync, readdirSync, readFileSync, unlinkSync, } from "node:fs";
3
3
  import { mkdir, writeFile } from "node:fs/promises";
4
4
  import path from "path";
5
5
  import { lock } from "proper-lockfile";
@@ -19,7 +19,9 @@ describe("check --fix", () => {
19
19
  const diagnosticFlags = [warningFlags, "--error-format=json"];
20
20
  beforeAll(() => {
21
21
  for (const file of readdirSync(runDir).filter((f) => f.endsWith(".mo"))) {
22
- unlinkSync(path.join(runDir, file));
22
+ const p = path.join(runDir, file);
23
+ chmodSync(p, 0o644);
24
+ unlinkSync(p);
23
25
  }
24
26
  });
25
27
  function copyFixture(file) {
@@ -86,6 +88,16 @@ describe("check --fix", () => {
86
88
  expect(fixResult.stdout).toContain("1 fix applied");
87
89
  expect(readFileSync(runFilePath, "utf-8")).not.toContain("<Nat>");
88
90
  });
91
+ test("read-only file is skipped, not crashed", async () => {
92
+ const runFilePath = copyFixture("M0223.mo");
93
+ const before = readFileSync(runFilePath, "utf-8");
94
+ chmodSync(runFilePath, 0o444);
95
+ const result = await cli(["check", runFilePath, "--fix", "--", warningFlags], { cwd: fixDir });
96
+ expect(result.exitCode).toBe(0);
97
+ expect(result.stderr).toMatch(/Skipped read-only file/);
98
+ // File left untouched since the fix couldn't be written.
99
+ expect(readFileSync(runFilePath, "utf-8")).toBe(before);
100
+ });
89
101
  test("verbose", async () => {
90
102
  const result = await cli(["check", "Ok.mo", "--fix", "--verbose"], {
91
103
  cwd: fixDir,
@@ -12,12 +12,6 @@ describe("check", () => {
12
12
  await cliSnapshot(["check", "Error.mo"], { cwd }, 1);
13
13
  await cliSnapshot(["check", "Ok.mo", "Error.mo"], { cwd }, 1);
14
14
  });
15
- // The verbose snapshot shows a single "moc ... [both files]" line, proving the
16
- // whole set is checked in one invocation rather than one moc call per file.
17
- test("multiple files in a single invocation", async () => {
18
- const cwd = path.join(import.meta.dirname, "check/success");
19
- await cliSnapshot(["check", "Ok.mo", "Warning.mo", "--verbose"], { cwd }, 0);
20
- });
21
15
  test("warning", async () => {
22
16
  const cwd = path.join(import.meta.dirname, "check/success");
23
17
  const result = await cliSnapshot(["check", "Warning.mo"], { cwd }, 0);
@@ -143,4 +137,25 @@ describe("check", () => {
143
137
  expect(result.exitCode).toBe(0);
144
138
  expect(result.stdout).toMatch(/✓ Lint fixes applied/);
145
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
+ });
146
161
  });
@@ -0,0 +1 @@
1
+ export {};