feat: MVP — move files, rewrite imports, atomically

- core: gitignore-aware index (ignore crate), forward/reverse import graph,
  relative-specifier math, pure-data move plans
- apply: rewrite-first + rename-last with fsync, temp+rename, full rollback
  (incl. stale-plan detection before any write); unified diff for dry-run
- cli: mv + check commands, human output, --json envelope with stable error
  codes (SOURCE_NOT_FOUND, TARGET_EXISTS, ...) and hints; exit codes 0/1/2
- parser/core split into submodules to keep files <=250 lines
- tests: 37 unit + 10 e2e + 2 doctests; fixtures tests/typescript/{basic,normal,complex}
- docs: SKILL.md synced (no --force in Phase 1), EXAMPLES.md
- plan: AI/agent support done; Phase 1.5 = Java niche
This commit is contained in:
loki5512344 2026-09-10 11:41:03 +02:00
parent 9d8f1e0bba
commit 55670030c3
Signed by: boba
GPG key ID: 253067914055423B
37 changed files with 1851 additions and 139 deletions

248
tests/cli.rs Normal file
View file

@ -0,0 +1,248 @@
//! End-to-end tests for the `jmove` CLI against the TypeScript fixtures.
//!
//! Each test copies a fixture tree into a fresh tempdir, runs the real
//! binary with `--root <tmp>` and asserts on exit codes, stdout/stderr and
//! the resulting files on disk.
use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use assert_cmd::assert::Assert;
use predicates::prelude::*;
use tempfile::TempDir;
/// Recursively copy `tests/typescript/<name>` into a tempdir and return it.
///
/// An empty `.git` marker is created in the copy: the `ignore` crate only
/// applies `.gitignore` rules inside a git repository by default, and the
/// `normal` fixture relies on its `node_modules/` rule being effective.
fn copy_fixture(name: &str) -> TempDir {
let tmp = TempDir::new().expect("tempdir");
let from = fixture_dir(name);
copy_dir(&from, tmp.path());
fs::create_dir(tmp.path().join(".git")).expect("git marker");
tmp
}
/// Source path of a TypeScript fixture tree inside the repository.
fn fixture_dir(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/typescript")
.join(name)
}
/// Recursive file/dir copy; plain `std::fs` only, no symlinks in fixtures.
fn copy_dir(from: &Path, to: &Path) {
fs::create_dir_all(to).expect("create dir");
for entry in fs::read_dir(from).expect("read dir") {
let entry = entry.expect("entry");
let target = to.join(entry.file_name());
if entry.file_type().expect("file type").is_dir() {
copy_dir(&entry.path(), &target);
} else {
fs::copy(entry.path(), target).expect("copy file");
}
}
}
/// Run `jmove --root <dir> <args…>` and return an assertable outcome.
fn jmove(root: &TempDir, args: &[&str]) -> Assert {
let mut cmd = Command::cargo_bin("jmove").expect("jmove binary");
cmd.arg("--root").arg(root.path()).args(args);
cmd.assert()
}
/// Read `path` as a string, panicking with the path on failure.
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()))
}
/// Build a path inside the tempdir root from a `/`-separated relative path.
fn in_root(root: &Path, rel: &str) -> PathBuf {
root.join(rel)
}
#[test]
fn mv_dry_run_prints_diff_and_leaves_disk_untouched() {
let tmp = copy_fixture("basic");
jmove(&tmp, &["mv", "lib/sum.ts", "utils/sum.ts", "--dry-run"])
.success()
.stdout(predicate::str::contains("./lib/sum"))
.stdout(predicate::str::contains("./utils/sum"));
assert!(
in_root(tmp.path(), "lib/sum.ts").is_file(),
"source untouched"
);
assert!(
!in_root(tmp.path(), "utils/sum.ts").exists(),
"target not created"
);
assert_eq!(
read(&in_root(tmp.path(), "app.ts")),
read(&fixture_dir("basic").join("app.ts")),
"importer untouched"
);
}
#[test]
fn mv_rewrites_importer_and_moves_the_file() {
let tmp = copy_fixture("basic");
jmove(&tmp, &["mv", "lib/sum.ts", "utils/sum.ts"])
.success()
.stdout(predicate::str::contains("moved lib/sum.ts -> utils/sum.ts"))
.stdout(predicate::str::contains("1 import"))
.stdout(predicate::str::contains("1 file"));
assert!(
!in_root(tmp.path(), "lib/sum.ts").exists(),
"source is gone"
);
assert!(
in_root(tmp.path(), "utils/sum.ts").is_file(),
"target exists"
);
assert!(
read(&in_root(tmp.path(), "app.ts")).contains("./utils/sum"),
"importer updated"
);
}
#[test]
fn mv_updates_barrel_and_never_touches_node_modules() {
let tmp = copy_fixture("normal");
jmove(&tmp, &["mv", "src/impl/core.ts", "src/impl/calc.ts"]).success();
assert!(
read(&in_root(tmp.path(), "src/impl/index.ts")).contains("./calc"),
"barrel re-export updated"
);
let app = read(&in_root(tmp.path(), "src/app.ts"));
assert!(!app.contains("./impl/core"), "side-effect import updated");
assert!(app.contains("./utils/logger"), "unrelated import preserved");
let vendored = read(&in_root(tmp.path(), "node_modules/legacy-lib/index.ts"));
assert!(
vendored.contains("../../src/impl/core"),
"ignored directories must never be rewritten"
);
jmove(&tmp, &["check"]).success();
}
#[test]
fn mv_rewrites_only_the_specifier_line_in_multi_line_imports() {
let tmp = copy_fixture("complex");
let view = in_root(tmp.path(), "src/ui/deep/nested/view.ts");
let before: Vec<String> = read(&view).lines().map(str::to_owned).collect();
jmove(&tmp, &["mv", "src/util/text.ts", "src/core/text.ts"]).success();
// Second importer at another depth: the messy `./util/../util/text`
// specifier resolves to the moved file and must be rewritten as well.
let config = read(&in_root(tmp.path(), "src/config.ts"));
assert!(
config.contains("./core/text") && !config.contains("../util"),
"messy relative specifier updated: {config}"
);
let after: Vec<String> = read(&view).lines().map(str::to_owned).collect();
assert_eq!(before.len(), after.len(), "import layout preserved");
assert_eq!(before[0], after[0], "opening line is byte-identical");
assert_eq!(before[1], after[1], "binding line is byte-identical");
assert_eq!(before[2], "} from \"../../../util/text\";");
assert_eq!(
after[2], "} from \"../../../core/text\";",
"only the specifier line changed"
);
}
#[test]
fn check_passes_on_clean_and_fails_on_broken_fixture() {
let clean = copy_fixture("basic");
jmove(&clean, &["check"])
.success()
.stdout(predicate::str::contains("no broken imports"));
let messy = copy_fixture("complex");
jmove(&messy, &["check"])
.code(2)
.stdout(predicate::str::contains(
"src/broken.ts:4: cannot resolve './gone'",
));
}
#[test]
fn check_json_reports_broken_import_payload() {
let tmp = copy_fixture("complex");
jmove(&tmp, &["check", "--json"]).code(2).stdout(
predicate::str::contains("\"status\": \"ok\"")
.and(predicate::str::contains("\"operation\": \"check\""))
.and(predicate::str::contains("\"file\": \"src/broken.ts\""))
.and(predicate::str::contains("\"line\": 4"))
.and(predicate::str::contains("\"import\": \"./gone\""))
.and(predicate::str::contains("\"reason\": \"file_not_found\""))
.and(predicate::str::contains("\"total\": 1")),
);
}
#[test]
fn mv_json_happy_path_reports_changed_files() {
let tmp = copy_fixture("basic");
jmove(&tmp, &["mv", "lib/sum.ts", "utils/sum.ts", "--json"])
.success()
.stdout(
predicate::str::contains("\"status\": \"ok\"")
.and(predicate::str::contains("\"operation\": \"mv\""))
.and(predicate::str::contains("\"source\": \"lib/sum.ts\""))
.and(predicate::str::contains("\"target\": \"utils/sum.ts\""))
.and(predicate::str::contains("\"path\": \"app.ts\""))
.and(predicate::str::contains("\"line\": 1"))
.and(predicate::str::contains("\"old\": \"./lib/sum\""))
.and(predicate::str::contains("\"new\": \"./utils/sum\""))
.and(predicate::str::contains("\"moved\": 1"))
.and(predicate::str::contains("\"updated_imports\": 1")),
);
assert!(in_root(tmp.path(), "utils/sum.ts").is_file());
}
#[test]
fn mv_json_dry_run_reports_preview_payload() {
let tmp = copy_fixture("basic");
jmove(
&tmp,
&["mv", "lib/sum.ts", "utils/sum.ts", "--dry-run", "--json"],
)
.success()
.stdout(
predicate::str::contains("\"status\": \"dry_run\"")
.and(predicate::str::contains("\"would_move\": \"lib/sum.ts\""))
.and(predicate::str::contains("\"would_update\": 1"))
.and(predicate::str::contains("\"affected_files\""))
.and(predicate::str::contains("\"app.ts\"")),
);
assert!(
in_root(tmp.path(), "lib/sum.ts").is_file(),
"dry-run writes nothing"
);
}
#[test]
fn mv_json_reports_target_exists_error_shape() {
let tmp = copy_fixture("basic");
jmove(&tmp, &["mv", "app.ts", "lib/sum.ts", "--json"])
.code(1)
.stdout(
predicate::str::contains("\"status\": \"error\"")
.and(predicate::str::contains("\"operation\": \"mv\""))
.and(predicate::str::contains("\"code\": \"TARGET_EXISTS\""))
.and(predicate::str::contains("\"hint\"")),
);
assert!(
read(&in_root(tmp.path(), "lib/sum.ts")).contains("export function sum"),
"rejected move changes nothing"
);
}
#[test]
fn mv_reports_source_not_found() {
let tmp = copy_fixture("basic");
jmove(&tmp, &["mv", "lib/nope.ts", "utils/nope.ts", "--json"])
.code(1)
.stdout(predicate::str::contains("\"code\": \"SOURCE_NOT_FOUND\""));
}

1
tests/go/README.md Normal file
View file

@ -0,0 +1 @@
> Planned Phase 3 fixture tree. `jmove` does not support Go yet; this folder intentionally holds only this note so nothing is dead code. See `docs/PLAN.md`.

1
tests/java/README.md Normal file
View file

@ -0,0 +1 @@
> Planned Phase 1.5 fixture tree. `jmove` does not support Java yet; this folder intentionally holds only this note so nothing is dead code. See `docs/PLAN.md`.

1
tests/python/README.md Normal file
View file

@ -0,0 +1 @@
> Planned Phase 3 fixture tree. `jmove` does not support Python yet; this folder intentionally holds only this note so nothing is dead code. See `docs/PLAN.md`.

View file

@ -0,0 +1,5 @@
import { sum } from "./lib/sum";
export function main(): number {
return sum(1, 2);
}

View file

@ -0,0 +1,3 @@
export function sum(a: number, b: number): number {
return a + b;
}

View file

@ -0,0 +1 @@
{ "name": "basic" }

View file

@ -0,0 +1 @@
{ "name": "complex" }

View file

@ -0,0 +1,6 @@
// Fixture with one intentionally broken relative import.
export const flag = true;
import { ghost } from "./gone";
export const who = ghost;

View file

@ -0,0 +1,3 @@
import { trim } from "./util/../util/text";
export const DEFAULT = trim(" hi ");

View file

@ -0,0 +1,5 @@
import { trim } from "../util/text";
export function solve(input: string): string {
return trim(input);
}

View file

@ -0,0 +1,7 @@
import {
pad,
} from "../../../util/text";
export function render(value: string): string {
return pad(value);
}

View file

@ -0,0 +1,7 @@
export function trim(s: string): string {
return s.trim();
}
export function pad(s: string): string {
return ` ${s} `;
}

1
tests/typescript/normal/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

View file

@ -0,0 +1 @@
{ "name": "normal" }

View file

@ -0,0 +1,14 @@
import type { Thing } from "./types";
import express from "express";
import "./impl/core";
import { log } from "./utils/logger";
const cfg = require("./types");
const later = () => import("./types");
export const app = express();
export function render(t: Thing): string {
log(cfg.name, t.id);
return t.id;
}
export { later };

View file

@ -0,0 +1,3 @@
export function compute(x: number): number {
return x * 2;
}

View file

@ -0,0 +1 @@
export * from "./core";

View file

@ -0,0 +1 @@
export * from "./impl";

View file

@ -0,0 +1,3 @@
export interface Thing {
id: string;
}

View file

@ -0,0 +1,3 @@
export function log(...parts: unknown[]): void {
void parts;
}