From 55670030c3beb497bcbc54e5bec8bada83453e80 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Thu, 10 Sep 2026 11:41:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20MVP=20=E2=80=94=20move=20files,=20rewri?= =?UTF-8?q?te=20imports,=20atomically?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/EXAMPLES.md | 173 ++++++++++++ docs/PLAN.md | 2 +- docs/SKILL.md | 5 +- src/cli/json.rs | 188 ++++++++++++- src/cli/mod.rs | 198 ++++++++++++-- src/cli/output.rs | 145 ++++++++++ src/core/apply.rs | 35 --- src/core/apply/diff.rs | 80 ++++++ src/core/apply/fsops.rs | 71 +++++ src/core/apply/mod.rs | 201 ++++++++++++++ src/core/index.rs | 191 +++++++++++++- src/core/mod.rs | 7 +- src/core/plan.rs | 46 ---- src/core/plan/mod.rs | 187 +++++++++++++ src/core/plan/specifier.rs | 93 +++++++ tests/cli.rs | 248 ++++++++++++++++++ tests/go/README.md | 1 + tests/java/README.md | 1 + tests/python/README.md | 1 + tests/typescript/basic/app.ts | 5 + tests/typescript/basic/lib/sum.ts | 3 + tests/typescript/basic/package.json | 1 + tests/typescript/complex/package.json | 1 + tests/typescript/complex/src/broken.ts | 6 + tests/typescript/complex/src/config.ts | 3 + tests/typescript/complex/src/engine/index.ts | 5 + .../complex/src/ui/deep/nested/view.ts | 7 + tests/typescript/complex/src/util/text.ts | 7 + tests/typescript/normal/.gitignore | 1 + tests/typescript/normal/package.json | 1 + tests/typescript/normal/src/app.ts | 14 + tests/typescript/normal/src/impl/core.ts | 3 + tests/typescript/normal/src/impl/index.ts | 1 + tests/typescript/normal/src/index.ts | 1 + tests/typescript/normal/src/types.ts | 3 + tests/typescript/normal/src/utils/logger.ts | 3 + todo.md | 52 +++- 37 files changed, 1851 insertions(+), 139 deletions(-) create mode 100644 docs/EXAMPLES.md create mode 100644 src/cli/output.rs delete mode 100644 src/core/apply.rs create mode 100644 src/core/apply/diff.rs create mode 100644 src/core/apply/fsops.rs create mode 100644 src/core/apply/mod.rs delete mode 100644 src/core/plan.rs create mode 100644 src/core/plan/mod.rs create mode 100644 src/core/plan/specifier.rs create mode 100644 tests/cli.rs create mode 100644 tests/go/README.md create mode 100644 tests/java/README.md create mode 100644 tests/python/README.md create mode 100644 tests/typescript/basic/app.ts create mode 100644 tests/typescript/basic/lib/sum.ts create mode 100644 tests/typescript/basic/package.json create mode 100644 tests/typescript/complex/package.json create mode 100644 tests/typescript/complex/src/broken.ts create mode 100644 tests/typescript/complex/src/config.ts create mode 100644 tests/typescript/complex/src/engine/index.ts create mode 100644 tests/typescript/complex/src/ui/deep/nested/view.ts create mode 100644 tests/typescript/complex/src/util/text.ts create mode 100644 tests/typescript/normal/.gitignore create mode 100644 tests/typescript/normal/package.json create mode 100644 tests/typescript/normal/src/app.ts create mode 100644 tests/typescript/normal/src/impl/core.ts create mode 100644 tests/typescript/normal/src/impl/index.ts create mode 100644 tests/typescript/normal/src/index.ts create mode 100644 tests/typescript/normal/src/types.ts create mode 100644 tests/typescript/normal/src/utils/logger.ts diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md new file mode 100644 index 0000000..f98883d --- /dev/null +++ b/docs/EXAMPLES.md @@ -0,0 +1,173 @@ +# jmove — examples + +Concrete examples for both audiences: humans at a terminal and AI agents +consuming `--json`. All outputs below are captured from the real binary. + +Flags (see `jmove --help`): `mv [--dry-run] [--json]`, +`check [--json]`, and the global `--root `. Paths may be relative to +the root or absolute inside it; `.gitignore`d files are never indexed. +Exit codes: `0` ok · `1` operation error · `2` `check` found broken imports. + +## Human usage + +### Preview a move (always do this first) + +```console +$ jmove mv lib/sum.ts utils/sum.ts --dry-run +--- app.ts ++++ app.ts +@@ -1,4 +1,4 @@ +-import { sum } from "./lib/sum"; ++import { sum } from "./utils/sum"; + + export function main(): number { + return sum(1, 2); +move lib/sum.ts -> utils/sum.ts +``` + +Nothing on disk changed. If no file imports the source, the diff is empty. + +### Apply the move + +```console +$ jmove mv lib/sum.ts utils/sum.ts +moved lib/sum.ts -> utils/sum.ts, updated 1 import in 1 file +``` + +Rewrites happen first, the rename last; any failure rolls everything back. + +### Verify the project afterwards + +```console +$ jmove check +check: no broken imports found +``` + +When something does point at nothing, `check` prints one line per broken +import and exits with code `2`: + +```console +$ jmove check +src/broken.ts:4: cannot resolve './gone' +$ echo $? +2 +``` + +### Operating on another project + +`--root` points jmove at a project other than the current directory; all +path arguments stay relative to that root: + +```console +$ jmove --root ~/code/frontend mv src/old.ts src/new.ts --dry-run +``` + +### Errors + +```console +$ jmove mv lib/sum.ts app.ts +jmove: target path 'app.ts' already exists + hint: remove or rename the existing target first +$ echo $? +1 +``` + +jmove never overwrites: free the destination (or pick another name) and +retry. A missing source reports `SOURCE_NOT_FOUND` the same way, and a +file nobody imports simply moves with zero rewrites. + +## AI-agent usage (`--json`) + +Every `--json` response is a flat envelope: `status` (`"ok"` | `"dry_run"` +| `"error"`) and `operation` plus the payload fields. Errors carry a +stable `code` and an actionable `hint`. + +### 1. Preview + +```console +$ jmove mv lib/sum.ts utils/sum.ts --dry-run --json +{ + "status": "dry_run", + "operation": "mv", + "would_move": "lib/sum.ts", + "target": "utils/sum.ts", + "would_update": 1, + "affected_files": [ + "app.ts" + ], + "diff": "--- app.ts\n+++ app.ts\n@@ -1,4 +1,4 @@\n-import { sum } from \"./lib/sum\";\n+import { sum } from \"./utils/sum\";\n..." +} +``` + +Review `affected_files`; abort and ask the user if the blast radius is +unexpected. + +### 2. Apply + +```console +$ jmove mv lib/sum.ts utils/sum.ts --json +{ + "status": "ok", + "operation": "mv", + "source": "lib/sum.ts", + "target": "utils/sum.ts", + "changed_files": [ + { + "path": "app.ts", + "changes": [ + { + "line": 1, + "old": "./lib/sum", + "new": "./utils/sum" + } + ] + } + ], + "moved": 1, + "updated_imports": 1 +} +``` + +`changed_files[].changes[]` lists every rewritten specifier with its +1-based line; `moved` and `updated_imports` are the counters. + +### 3. Verify + +```console +$ jmove check --json +{ + "status": "ok", + "operation": "check", + "broken_imports": [ + { + "file": "src/broken.ts", + "line": 4, + "import": "./gone", + "reason": "file_not_found" + } + ], + "total": 1 +} +``` + +Note: this response keeps `status: "ok"` (the command itself succeeded) +while the process exits `2`; treat a non-zero `total` — or exit code `2` — +as a failed refactor. A clean project returns `"broken_imports": [], "total": 0` +and exit code `0`. + +### Error shape + +```console +$ jmove mv lib/sum.ts app.ts --json +{ + "status": "error", + "operation": "mv", + "code": "TARGET_EXISTS", + "message": "target path 'app.ts' already exists", + "hint": "remove or rename the existing target first" +} +``` + +Stable codes: `TARGET_EXISTS`, `SOURCE_NOT_FOUND`, `INVALID_ARGUMENT`, +`IO_ERROR`, `STALE_INDEX`, `PLAN_REJECTED` (exit `1`). The recommended +agent loop — preview, inspect, apply, verify — is in `docs/SKILL.md`. diff --git a/docs/PLAN.md b/docs/PLAN.md index 8cda0d3..5f8bbb6 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -52,7 +52,7 @@ Existing tools ship a raw `.agents/skills/` folder; we do it properly: - `--json` on every command. Envelope: `{ "status": "ok" | "dry_run" | "error", "operation": "...", ... }` - Stable error codes + actionable hints: - `{ "status":"error", "code":"TARGET_EXISTS", "message":"...", "hint":"Use --force or choose another target" }` + `{ "status":"error", "code":"TARGET_EXISTS", "message":"...", "hint":"choose another target or remove the existing one" }` - `mv --dry-run --json` → `{ would_move, would_update, affected_files }` so agents preview → confirm → apply → `check --json`. - Exit codes: `0` ok · `1` error · `2` broken imports (from `check`). diff --git a/docs/SKILL.md b/docs/SKILL.md index 413f23b..48d12a1 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -17,10 +17,12 @@ JavaScript; Java/Python/Go on the roadmap. Single binary, no LSP needed. ### mv — move a file and rewrite its importers ``` -jmove mv [--root DIR] [--dry-run] [--json] [--force] +jmove mv [--root DIR] [--dry-run] [--json] ``` Always run `--dry-run` first and confirm the change set looks right. +Moving onto an existing path fails with `TARGET_EXISTS` — choose another +target (Phase 1 has no overwrite mode). ### check — find broken imports @@ -54,6 +56,5 @@ and a `hint` describing the next action. On success, `mv` reports ## Rules of use - Never run `mv` without a prior `--dry-run` in the same session. -- Never pass `--force` unless the user explicitly authorized overwriting. - After every successful `mv`, run `check`; treat exit code `2` as a failed refactor. diff --git a/src/cli/json.rs b/src/cli/json.rs index 74f5a1d..2c4a0f4 100644 --- a/src/cli/json.rs +++ b/src/cli/json.rs @@ -2,10 +2,16 @@ //! //! Every response is an [`Envelope`] whose `status` is one of //! `"ok" | "dry_run" | "error"`, plus a machine-readable error `code` -//! and a human `hint` on failure (see `docs/SKILL.md`). +//! and a human `hint` on failure (see `docs/SKILL.md`). Payload structs +//! are flattened into the envelope, so they never repeat `operation`. use serde::Serialize; +use crate::core::JmoveError; +use crate::core::plan::MovePlan; + +use super::output; + /// Top-level envelope for every `--json` response. #[derive(Debug, Serialize)] pub struct Envelope { @@ -18,6 +24,40 @@ pub struct Envelope { pub data: T, } +impl Envelope { + /// Success envelope: `status = "ok"`. + #[must_use] + pub fn ok(operation: &'static str, data: T) -> Self { + Self { + status: "ok", + operation, + data, + } + } + + /// Dry-run preview envelope: `status = "dry_run"`. + #[must_use] + pub fn dry_run(operation: &'static str, data: T) -> Self { + Self { + status: "dry_run", + operation, + data, + } + } +} + +impl Envelope { + /// Failure envelope: `status = "error"` with a flattened [`ErrorData`]. + #[must_use] + pub fn error(operation: &'static str, data: ErrorData) -> Self { + Self { + status: "error", + operation, + data, + } + } +} + /// Error payload: stable `code`, human `message`, actionable `hint`. #[derive(Debug, Serialize)] pub struct ErrorData { @@ -25,12 +65,152 @@ pub struct ErrorData { pub code: String, /// Human-readable explanation. pub message: String, - /// What the caller should do next (never null in output; omit if none). + /// What the caller should do next (omitted from JSON when absent). + #[serde(skip_serializing_if = "Option::is_none")] pub hint: Option, } +impl ErrorData { + /// Build an error payload from a stable code, message and optional hint. + #[must_use] + pub fn new(code: &str, message: String, hint: Option) -> Self { + Self { + code: code.to_owned(), + message, + hint, + } + } + + /// Map an engine error onto a stable code plus an actionable hint. + #[must_use] + pub fn from_core(err: &JmoveError) -> Self { + let (code, hint) = match err { + JmoveError::Io(_) => ("IO_ERROR", "check file permissions and disk space"), + JmoveError::InvalidArgument(_) => ( + "INVALID_ARGUMENT", + "paths must be inside the project root given by --root", + ), + JmoveError::StaleIndex(_) => ( + "STALE_INDEX", + "rerun the command; the index is rebuilt on every run", + ), + JmoveError::PlanRejected(_) => ( + "PLAN_REJECTED", + "run `jmove check --json` to inspect the import graph", + ), + }; + Self::new(code, err.to_string(), Some(hint.to_owned())) + } +} + +/// One rewritten import inside a changed file. +#[derive(Debug, Serialize)] +pub struct Change { + /// 1-based line of the rewritten specifier. + pub line: usize, + /// Specifier text before the move. + pub old: String, + /// Specifier text after the move. + pub new: String, +} + +/// A file whose imports were rewritten, with line-level change details. +#[derive(Debug, Serialize)] +pub struct ChangedFile { + /// Project-relative path of the importer. + pub path: String, + /// Rewritten specifiers, in source order. + pub changes: Vec, +} + +/// Success payload of `mv --json` (flattened under `status: "ok"`). +#[derive(Debug, Serialize)] +pub struct MvData { + /// Project-relative path the file moved from. + pub source: String, + /// Project-relative path the file moved to. + pub target: String, + /// Importer files touched by the move. + pub changed_files: Vec, + /// Number of files moved (always 1 in Phase 1). + pub moved: usize, + /// Total specifiers rewritten across all importers. + pub updated_imports: usize, +} + +impl MvData { + /// Assemble the payload from an applied plan and its change details. + #[must_use] + pub fn new(plan: &MovePlan, changed_files: Vec) -> Self { + Self { + source: plan.source.display().to_string(), + target: plan.target.display().to_string(), + changed_files, + moved: 1, + updated_imports: plan.rewrites.len(), + } + } +} + +/// Dry-run payload of `mv --dry-run --json` (flattened under +/// `status: "dry_run"`). +#[derive(Debug, Serialize)] +pub struct MvDryRunData { + /// Project-relative path that would move. + pub would_move: String, + /// Project-relative destination that would be created. + pub target: String, + /// Specifiers that would be rewritten. + pub would_update: usize, + /// Importer files that would be touched, sorted. + pub affected_files: Vec, + /// Unified diff (rewrites + rename) of the whole plan. + pub diff: String, +} + +impl MvDryRunData { + /// Assemble the preview payload from a plan and its rendered diff. + #[must_use] + pub fn new(plan: &MovePlan, diff: String) -> Self { + Self { + would_move: plan.source.display().to_string(), + target: plan.target.display().to_string(), + would_update: plan.rewrites.len(), + affected_files: output::group_by_file(&plan.rewrites) + .into_iter() + .map(|(file, _)| file.display().to_string()) + .collect(), + diff, + } + } +} + +/// One unresolvable relative import found by `check`. +#[derive(Debug, Serialize)] +pub struct BrokenImport { + /// Project-relative file declaring the import. + pub file: String, + /// 1-based line of the specifier. + pub line: usize, + /// Specifier text as written. + pub import: String, + /// Stable reason code, currently always `"file_not_found"`. + pub reason: &'static str, +} + +/// Success payload of `check --json` (flattened under the envelope). +#[derive(Debug, Serialize)] +pub struct CheckData { + /// Broken imports, sorted by file then line. + pub broken_imports: Vec, + /// Number of broken imports (kept as an explicit counter for agents). + pub total: usize, +} + /// Serialize `value` as pretty JSON to stdout. pub fn print(value: &T) { - let _ = value; - todo!("cli agent: println!(serde_json::to_string_pretty)") + match serde_json::to_string_pretty(value) { + Ok(text) => println!("{text}"), + Err(err) => eprintln!("jmove: failed to serialize JSON output: {err}"), + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index bb3fd9f..d1dfab1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,11 +4,20 @@ //! `0` success · `1` operation error · `2` broken imports found. pub mod json; +pub mod output; -use std::path::PathBuf; +use std::convert::identity; +use std::path::{Component, Path, PathBuf}; use clap::{Parser, Subcommand}; +use crate::core::apply; +use crate::core::index::Index; +use crate::core::plan::{self, MovePlan}; +use crate::core::{self, JmoveError, JmoveResult}; + +use json::{CheckData, Envelope, ErrorData, MvData, MvDryRunData}; + /// jmove — move source files, keep every import intact. #[derive(Debug, Parser)] #[command(name = "jmove", version, about, long_about = None)] @@ -36,9 +45,6 @@ pub enum Command { /// Machine-readable JSON output (for AI agents). #[arg(long)] json: bool, - /// Allow overwriting an existing target file. - #[arg(long)] - force: bool, }, /// Report broken imports in the project. Check { @@ -58,39 +64,185 @@ pub mod exit { pub const BROKEN: i32 = 2; } +/// Handler flow: `Ok(value)` continues, `Err(code)` means the failure was +/// already reported to the user and the process must exit with `code`. +type Flow = Result; + /// Parse arguments and run the selected command. /// Returns the process exit code; `Err` is reserved for unexpected failures. pub fn run() -> anyhow::Result { let args = Args::parse(); - match args.command { + let outcome = match args.command { Command::Mv { source, target, dry_run, json, - force, - } => mv(&args.root, &source, &target, dry_run, json, force), + } => mv(&args.root, &source, &target, dry_run, json), Command::Check { json } => check(&args.root, json), + }; + // Handlers report their own failures; both arms carry an exit code. + Ok(outcome.unwrap_or_else(identity)) +} + +/// `mv` handler: normalize paths, validate, index, plan, then dry-run or apply. +fn mv(root: &Path, source: &Path, target: &Path, dry_run: bool, json: bool) -> Flow { + let root = flow(json, "mv", root.canonicalize().map_err(JmoveError::from))?; + let source = flow(json, "mv", rel_from_root(&root, source))?; + let target = flow(json, "mv", rel_from_root(&root, target))?; + if let Some(rejected) = mv_reject(&root, &source, &target) { + return Err(fail(json, "mv", rejected)); } + + let index = flow(json, "mv", Index::build(&root))?; + let plan = flow(json, "mv", plan::plan_move(&index, &source, &target))?; + if dry_run { + return mv_dry_run(&root, json, &plan); + } + // Line numbers use spans against the *original* contents, so the JSON + // payload is assembled before the rewrites hit the disk. + let changed = if json { + flow(json, "mv", output::changed_files(&root, &plan))? + } else { + Vec::new() + }; + flow(json, "mv", apply::apply(&root, &plan))?; + + if json { + json::print(&Envelope::ok("mv", MvData::new(&plan, changed))); + } else { + println!("{}", output::mv_summary(&plan)); + } + Ok(exit::OK) } -/// `mv` handler: build index, plan, then dry-run-print or apply. -fn mv( - root: &Path, - source: &Path, - target: &Path, - dry_run: bool, - json: bool, - force: bool, -) -> anyhow::Result { - let _ = (root, source, target, dry_run, json, force); - todo!("cli agent: wire mv to core::index/plan/apply") +/// Pre-flight `mv` validation. A file that exists on disk but is absent +/// from the import index stays moveable: its plan simply has no rewrites. +fn mv_reject(root: &Path, source: &Path, target: &Path) -> Option { + let bad = |code: &str, message: String, hint: &str| { + Some(ErrorData::new(code, message, Some(hint.into()))) + }; + if source == target { + let msg = "source and target are the same path".into(); + return bad("INVALID_ARGUMENT", msg, "pick a different destination"); + } + if !root.join(source).is_file() { + let msg = format!("source file '{}' does not exist", source.display()); + return bad( + "SOURCE_NOT_FOUND", + msg, + "check the path or run `jmove check`", + ); + } + if root.join(target).exists() { + let msg = format!("target path '{}' already exists", target.display()); + return bad( + "TARGET_EXISTS", + msg, + "remove or rename the existing target first", + ); + } + // `target` names a file, so `parent()` always yields the directory part. + let parent = root.join(target.parent().unwrap_or(Path::new(""))); + if parent.exists() && !parent.is_dir() { + let msg = format!("target parent of '{}' is not a directory", target.display()); + return bad( + "INVALID_ARGUMENT", + msg, + "pick a destination inside a directory", + ); + } + None } -/// `check` handler: report imports that resolve to nothing. -fn check(root: &Path, json: bool) -> anyhow::Result { - let _ = (root, json); - todo!("cli agent: wire check to core::index") +/// Dry-run branch: unified diff for humans, structured preview for agents. +fn mv_dry_run(root: &Path, json: bool, plan: &MovePlan) -> Flow { + let diff = flow(json, "mv", apply::render_diff(root, plan))?; + if json { + json::print(&Envelope::dry_run("mv", MvDryRunData::new(plan, diff))); + } else { + print!("{diff}"); + } + Ok(exit::OK) } -use std::path::Path; +/// `check` handler: report relative imports that resolve to nothing. +/// +/// Exit code is `2` when at least one broken import was found, in both the +/// human and the `--json` mode (the JSON `status` stays `"ok"` — the +/// command itself succeeded; agents read `total` or the exit code). +fn check(root: &Path, json: bool) -> Flow { + let root = flow(json, "check", root.canonicalize().map_err(JmoveError::from))?; + let index = flow(json, "check", Index::build(&root))?; + let broken = flow(json, "check", output::broken_imports(&root, &index))?; + let code = if broken.is_empty() { + exit::OK + } else { + exit::BROKEN + }; + + if json { + let total = broken.len(); + let data = CheckData { + broken_imports: broken, + total, + }; + json::print(&Envelope::ok("check", data)); + } else { + output::report_check(&broken); + } + Ok(code) +} + +/// Unwrap a core result, routing failures through the CLI error channel. +fn flow(json: bool, operation: &'static str, result: JmoveResult) -> Flow { + result.map_err(|err| fail(json, operation, ErrorData::from_core(&err))) +} + +/// Report `err` as a JSON envelope or stderr lines; return the exit code. +fn fail(json: bool, operation: &'static str, err: ErrorData) -> i32 { + if json { + json::print(&Envelope::error(operation, err)); + } else { + output::print_error(&err.message, err.hint.as_deref()); + } + exit::ERROR +} + +/// Convert a user path to a normalized project-relative path. Relative +/// paths are taken against `root`; absolute ones must live underneath it. +fn rel_from_root(root: &Path, path: &Path) -> JmoveResult { + let joined = if path.is_absolute() { + path.into() + } else { + root.join(path) + }; + let outside = || { + JmoveError::InvalidArgument(format!( + "path '{}' is outside the project root", + path.display() + )) + }; + let abs = collapse(&joined); + let rel = abs.strip_prefix(root).map_err(|_| outside())?; + core::normalize_rel_path(rel).ok_or_else(|| { + JmoveError::InvalidArgument(format!("invalid project path '{}'", path.display())) + }) +} + +/// Lexically normalize a path: drop `.` segments, apply `..` where possible. +fn collapse(path: &Path) -> PathBuf { + let mut stack: Vec> = Vec::new(); + for comp in path.components() { + match comp { + Component::CurDir => {} + Component::ParentDir => { + if stack.last() != Some(&Component::ParentDir) { + stack.pop(); + } + } + other => stack.push(other), + } + } + stack.into_iter().collect() +} diff --git a/src/cli/output.rs b/src/cli/output.rs new file mode 100644 index 0000000..e68939d --- /dev/null +++ b/src/cli/output.rs @@ -0,0 +1,145 @@ +//! Human-readable rendering plus the payload builders that both output +//! modes share: grouping rewrites, resolving broken imports, line lookup. +//! +//! Pure functions returning data, except [`report_check`] and +//! [`print_error`] which perform the only I/O (stdout and stderr). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::core::JmoveResult; +use crate::core::index::Index; +use crate::core::plan::{MovePlan, Rewrite}; + +use super::json::{BrokenImport, Change, ChangedFile}; + +/// `check` stdout line when the project has no broken imports. +const CHECK_CLEAN: &str = "check: no broken imports found"; + +/// Read a project file (project-relative path) as UTF-8 text. +fn read_file(root: &Path, rel: &Path) -> JmoveResult { + Ok(std::fs::read_to_string(root.join(rel))?) +} + +/// 1-based line containing the byte offset `byte` in `source`. +#[must_use] +pub fn line_of(source: &str, byte: usize) -> usize { + let upto = source.len().min(byte); + source.as_bytes()[..upto] + .iter() + .filter(|b| **b == b'\n') + .count() + + 1 +} + +/// Group rewrites by importer file; files in sorted order, rewrites of one +/// file keep their source order. Shared by JSON payloads and summaries. +#[must_use] +pub fn group_by_file(rewrites: &[Rewrite]) -> Vec<(&Path, Vec<&Rewrite>)> { + let mut map: BTreeMap<&Path, Vec<&Rewrite>> = BTreeMap::new(); + for rewrite in rewrites { + map.entry(&rewrite.file).or_default().push(rewrite); + } + map.into_iter().collect() +} + +/// Collect every relative import that resolves to nothing in `index`. +/// +/// A specifier starting with `.` whose target is `None` is broken; a bare +/// package specifier without a target is an external dependency, not an +/// error. Results are sorted by file, then line. +pub fn broken_imports(root: &Path, index: &Index) -> JmoveResult> { + let mut broken: Vec = Vec::new(); + for (file, imports) in &index.imports { + for import in imports { + if import.target.is_some() || !import.record.specifier.starts_with('.') { + continue; + } + let text = read_file(root, file)?; + broken.push(BrokenImport { + file: file.display().to_string(), + line: line_of(&text, import.record.span.start), + import: import.record.specifier.clone(), + reason: "file_not_found", + }); + } + } + broken.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line))); + Ok(broken) +} + +/// Build the `changed_files` payload: line-level specifier diffs grouped per +/// importer, computed against the on-disk contents at call time. +pub fn changed_files(root: &Path, plan: &MovePlan) -> JmoveResult> { + let mut per_file: BTreeMap<&PathBuf, Vec> = BTreeMap::new(); + let mut contents: BTreeMap<&PathBuf, String> = BTreeMap::new(); + for rewrite in &plan.rewrites { + if !contents.contains_key(&rewrite.file) { + contents.insert(&rewrite.file, read_file(root, &rewrite.file)?); + } + // The key was just ensured, so this lookup cannot fail. + let line = line_of(&contents[&rewrite.file], rewrite.span.start); + let change = Change { + line, + old: rewrite.old_text.clone(), + new: rewrite.new_text.clone(), + }; + per_file.entry(&rewrite.file).or_default().push(change); + } + Ok(per_file + .into_iter() + .map(|(path, changes)| ChangedFile { + path: path.display().to_string(), + changes, + }) + .collect()) +} + +/// `moved src -> tgt, updated N imports in M files` success summary. +#[must_use] +pub fn mv_summary(plan: &MovePlan) -> String { + let imports = plan.rewrites.len(); + let files = group_by_file(&plan.rewrites).len(); + format!( + "moved {} -> {}, updated {} {} in {} {}", + plan.source.display(), + plan.target.display(), + imports, + plural(imports, "import"), + files, + plural(files, "file"), + ) +} + +/// Print the human `check` report: the clean note, or one +/// `path:line: cannot resolve 'spec'` line per broken import. +pub fn report_check(broken: &[BrokenImport]) { + if broken.is_empty() { + println!("{CHECK_CLEAN}"); + return; + } + for entry in broken { + let line = format!( + "{}:{}: cannot resolve '{}'", + entry.file, entry.line, entry.import + ); + println!("{line}"); + } +} + +/// Print an operation error to stderr, with the hint on its own line. +pub fn print_error(message: &str, hint: Option<&str>) { + eprintln!("jmove: {message}"); + if let Some(hint) = hint { + eprintln!(" hint: {hint}"); + } +} + +/// `N noun` with a naive English plural. +fn plural(count: usize, noun: &str) -> String { + if count == 1 { + noun.to_owned() + } else { + format!("{noun}s") + } +} diff --git a/src/core/apply.rs b/src/core/apply.rs deleted file mode 100644 index 01b2498..0000000 --- a/src/core/apply.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Atomic apply with rollback, plus unified-diff rendering for dry-run. -//! -//! Order matters: specifier rewrites are applied to importer files first -//! (each written atomically via temp-file + rename), and the actual -//! `source -> target` rename happens last. Any failure mid-way triggers -//! rollback of everything already written. - -use std::path::Path; - -use crate::core::JmoveResult; -use crate::core::plan::MovePlan; - -/// Summary of a successfully applied plan. -#[derive(Debug, Clone)] -pub struct Applied { - /// Number of files whose imports were rewritten. - pub files_rewritten: usize, - /// The moved file's new project-relative path. - pub new_path: std::path::PathBuf, -} - -/// Apply `plan` under `root` atomically (see module docs). Rollback is -/// best-effort: on restore failure the error message states which files -/// need manual recovery. -pub fn apply(root: &Path, plan: &MovePlan) -> JmoveResult { - let _ = (root, plan); - todo!("index agent: atomic apply + rollback") -} - -/// Render the plan as a unified diff (rewrites + file rename) for dry-run. -#[must_use] -pub fn render_diff(root: &Path, plan: &MovePlan) -> JmoveResult { - let _ = (root, plan); - todo!("index agent: diff rendering via `similar`") -} diff --git a/src/core/apply/diff.rs b/src/core/apply/diff.rs new file mode 100644 index 0000000..bd0b5b4 --- /dev/null +++ b/src/core/apply/diff.rs @@ -0,0 +1,80 @@ +//! Unified-diff rendering of a plan, used by `mv --dry-run`. + +use similar::TextDiff; + +use crate::core::JmoveResult; +use crate::core::apply::fsops::{group_by_file, rewrite_bytes}; +use crate::core::plan::MovePlan; +use std::path::Path; + +/// Render the plan as a unified diff per rewritten file plus a final +/// `move -> ` line, for dry-run. A plan without rewrites +/// renders the empty string. +pub fn render_diff(root: &Path, plan: &MovePlan) -> JmoveResult { + let mut out = String::new(); + for (file, rewrites) in group_by_file(plan) { + let original = std::fs::read(root.join(&file))?; + let patched = rewrite_bytes(&original, &rewrites)?; + let name = file.display().to_string(); + let old = String::from_utf8_lossy(&original); + let new = String::from_utf8_lossy(&patched); + let text = TextDiff::from_lines(&old, &new); + out.push_str(&text.unified_diff().header(&name, &name).to_string()); + } + if !out.is_empty() { + let (src, dst) = (plan.source.display(), plan.target.display()); + out.push_str(&format!("move {src} -> {dst}\n")); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::render_diff; + use crate::core::JmoveResult; + use crate::core::plan::{MovePlan, Rewrite}; + use std::path::Path; + + const OLD: &str = "import {\n fmt,\n} from '../lib/fmt';\n"; + + fn plan() -> MovePlan { + MovePlan { + source: "lib/fmt.ts".into(), + target: "deep/fmt.ts".into(), + rewrites: vec![Rewrite { + file: "src/app.ts".into(), + span: 24..34, + old_text: "../lib/fmt".into(), + new_text: "../deep/fmt".into(), + }], + } + } + + #[test] + fn render_diff_smoke() -> JmoveResult<()> { + let dir = tempfile::TempDir::new()?; + std::fs::create_dir_all(dir.path().join("src"))?; + std::fs::write(dir.path().join("src/app.ts"), OLD)?; + let diff = render_diff(dir.path(), &plan())?; + assert!(diff.contains("--- src/app.ts") && diff.contains("+++ src/app.ts")); + assert!(diff.contains("@@")); + assert!(diff.contains("-} from '../lib/fmt';") && diff.contains("+} from '../deep/fmt';")); + assert!(diff.ends_with("move lib/fmt.ts -> deep/fmt.ts\n")); + let mut empty = plan(); + empty.rewrites.clear(); + assert_eq!(render_diff(dir.path(), &empty)?, ""); + Ok(()) + } + + #[test] + fn render_diff_reads_from_root() -> JmoveResult<()> { + // Relative-root sanity: same content, root passed as `.` style path. + let dir = tempfile::TempDir::new()?; + std::fs::create_dir_all(dir.path().join("src"))?; + std::fs::write(dir.path().join("src/app.ts"), OLD)?; + let abs: &Path = dir.path(); + let diff = render_diff(abs, &plan())?; + assert!(diff.contains("../deep/fmt")); + Ok(()) + } +} diff --git a/src/core/apply/fsops.rs b/src/core/apply/fsops.rs new file mode 100644 index 0000000..cb741e8 --- /dev/null +++ b/src/core/apply/fsops.rs @@ -0,0 +1,71 @@ +//! Low-level byte and filesystem helpers shared by apply and diff. + +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::core::plan::{MovePlan, Rewrite}; +use crate::core::{JmoveError, JmoveResult}; + +// Group rewrites by file; the BTreeMap keeps the order deterministic. +pub(super) fn group_by_file(plan: &MovePlan) -> BTreeMap> { + let mut m: BTreeMap> = BTreeMap::new(); + for r in &plan.rewrites { + m.entry(r.file.clone()).or_default().push(r); + } + m +} + +// `.jmove-tmp` next to `path` (same dir => same filesystem). +pub(super) fn sibling_temp(path: &Path) -> PathBuf { + let mut temp = path.as_os_str().to_os_string(); + temp.push(".jmove-tmp"); + PathBuf::from(temp) +} + +// Apply byte-span replacements in reverse offset order so earlier spans +// stay valid; a span/content mismatch means the plan is stale. Valid +// UTF-8 needles can only match on char boundaries, so a successful +// rewrite of valid UTF-8 stays valid UTF-8. +pub(super) fn rewrite_bytes(original: &[u8], rewrites: &[&Rewrite]) -> JmoveResult> { + let mut out = original.to_vec(); + let mut sorted = rewrites.to_vec(); + sorted.sort_by_key(|r| std::cmp::Reverse(r.span.start)); + for r in sorted { + if r.span.end > out.len() || &out[r.span.clone()] != r.old_text.as_bytes() { + return Err(JmoveError::StaleIndex(format!( + "'{}' changed since indexing (expected {:?} at {:?})", + r.file.display(), + r.old_text, + r.span + ))); + } + out.splice(r.span.clone(), r.new_text.as_bytes().iter().copied()); + } + Ok(out) +} + +// Write `bytes` to `temp` durably: create, write, flush, fsync. +pub(super) fn write_durable(temp: &Path, bytes: &[u8]) -> JmoveResult<()> { + let mut file = fs::File::create(temp)?; + file.write_all(bytes)?; + file.flush()?; + Ok(file.sync_all()?) +} + +// Create the missing parent dirs of `dst`; returns the ones actually +// created (innermost last) so rollback can remove them in reverse. +pub(super) fn create_missing_dirs(dst: &Path) -> JmoveResult> { + let mut created = Vec::new(); + let mut current = dst.parent().unwrap_or(Path::new("")).to_path_buf(); + while !current.exists() { + created.push(current.clone()); + let Some(up) = current.parent() else { break }; + current = up.to_path_buf(); + } + for dir in created.iter().rev() { + fs::create_dir(dir)?; + } + Ok(created) +} diff --git a/src/core/apply/mod.rs b/src/core/apply/mod.rs new file mode 100644 index 0000000..4595861 --- /dev/null +++ b/src/core/apply/mod.rs @@ -0,0 +1,201 @@ +//! Atomic apply with rollback, plus unified-diff rendering for dry-run. +//! +//! Rewrites land on importer files first (each atomically via temp-file + +//! rename), the `source -> target` rename happens last, and any failure +//! mid-way rolls back everything already written. + +mod diff; +mod fsops; + +pub use diff::render_diff; + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::core::apply::fsops::{ + create_missing_dirs, group_by_file, rewrite_bytes, sibling_temp, write_durable, +}; +use crate::core::plan::{MovePlan, Rewrite}; +use crate::core::{JmoveError, JmoveResult}; + +/// Summary of a successfully applied plan. +#[derive(Debug, Clone)] +pub struct Applied { + /// Number of files whose imports were rewritten. + pub files_rewritten: usize, + /// The moved file's new project-relative path. + pub new_path: PathBuf, +} + +// Rollback state for one run: originals of rewritten files (newest last), +// dirs created for the target, and the final rename once it happened. +#[derive(Default)] +struct Run { + root: PathBuf, + backups: Vec<(PathBuf, Vec)>, + dirs: Vec, + moved: Option<(PathBuf, PathBuf)>, +} + +/// Apply `plan` under `root` atomically (see module docs). Rollback is +/// best-effort: on restore failure the error names the files that need +/// manual recovery. +pub fn apply(root: &Path, plan: &MovePlan) -> JmoveResult { + let mut run = Run { + root: root.to_path_buf(), + ..Default::default() + }; + match run.try_apply(plan) { + Ok(applied) => Ok(applied), + Err(err) => Err(run.undo(err)), + } +} + +impl Run { + fn try_apply(&mut self, plan: &MovePlan) -> JmoveResult { + let by_file = group_by_file(plan); + for (file, rewrites) in &by_file { + self.rewrite_one(file, rewrites)?; + } + // The move comes last, after every importer was rewritten. + let (src, dst) = (self.root.join(&plan.source), self.root.join(&plan.target)); + self.dirs = create_missing_dirs(&dst)?; + fs::rename(&src, &dst)?; + self.moved = Some((src, dst)); + Ok(Applied { + files_rewritten: by_file.len(), + new_path: plan.target.clone(), + }) + } + + // Patch one file in memory, then temp-file + fsync + rename over it; + // the original bytes go to `backups` for rollback. + fn rewrite_one(&mut self, file: &Path, rewrites: &[&Rewrite]) -> JmoveResult<()> { + let path = self.root.join(file); + let original = fs::read(&path)?; + let patched = rewrite_bytes(&original, rewrites)?; + self.backups.push((file.to_path_buf(), original)); + let temp = sibling_temp(&path); // same dir => rename stays atomic + write_durable(&temp, &patched)?; + if let Err(err) = fs::rename(&temp, &path) { + let _ = fs::remove_file(&temp); // no stray temp behind + return Err(err.into()); + } + Ok(()) + } + + // Undo newest-first; keep the original error, appending any rollback + // problems to its message. + fn undo(&mut self, err: JmoveError) -> JmoveError { + let mut problems = Vec::new(); + if let Some((src, dst)) = self.moved.take() + && let Err(e) = fs::rename(&dst, &src) + { + problems.push(format!("could not move back {}: {e}", dst.display())); + } + for (file, bytes) in self.backups.drain(..).rev() { + if let Err(e) = fs::write(self.root.join(&file), &bytes) { + problems.push(format!("could not restore {}: {e}", file.display())); + } + } + for dir in self.dirs.drain(..).rev() { + let _ = fs::remove_dir(&dir); // best-effort: only empty dirs + } + if problems.is_empty() { + return err; + } + let msg = format!("{err}; rollback incomplete: {}", problems.join("; ")); + JmoveError::Io(std::io::Error::other(msg)) + } +} + +#[cfg(test)] +mod tests { + use super::apply; + use crate::core::JmoveResult; + use crate::core::plan::{MovePlan, Rewrite}; + use std::fs; + use std::path::{Path, PathBuf}; + + const OLD: &str = "import {\n fmt,\n} from '../lib/fmt';\n"; + const NEW: &str = "import {\n fmt,\n} from '../deep/fmt';\n"; + // Byte span of `../lib/fmt` (between the quotes) inside OLD. + const SPAN: std::ops::Range = 24..34; + + // Plan moving lib/fmt.ts -> deep/fmt.ts, rewriting src/app.ts. + fn plan() -> MovePlan { + let rewrite = Rewrite { + file: "src/app.ts".into(), + span: SPAN, + old_text: "../lib/fmt".into(), + new_text: "../deep/fmt".into(), + }; + MovePlan { + source: "lib/fmt.ts".into(), + target: "deep/fmt.ts".into(), + rewrites: vec![rewrite], + } + } + + fn mk(dir: &Path, rel: &str, body: &str) -> JmoveResult<()> { + let path = dir.join(rel); + fs::create_dir_all(path.parent().unwrap())?; + fs::write(path, body)?; + Ok(()) + } + + #[test] + fn apply_rewrites_spans_creates_dirs_and_moves_last() -> JmoveResult<()> { + assert_eq!(&OLD[SPAN], "../lib/fmt"); // sanity: the span is real + let dir = tempfile::TempDir::new()?; + let root = dir.path(); + mk(root, "src/app.ts", OLD)?; + mk(root, "lib/fmt.ts", "export const fmt = 1;\n")?; + let applied = apply(root, &plan())?; + assert_eq!( + (applied.files_rewritten, &applied.new_path), + (1, &PathBuf::from("deep/fmt.ts")) + ); + assert!(!root.join("lib/fmt.ts").exists()); + assert_eq!( + fs::read_to_string(root.join("deep/fmt.ts"))?, + "export const fmt = 1;\n" + ); + // Only the specifier bytes changed; the layout is kept byte-exact. + assert_eq!(fs::read_to_string(root.join("src/app.ts"))?, NEW); + assert!(!root.join("src/app.ts.jmove-tmp").exists()); + Ok(()) + } + + #[test] + fn apply_rolls_back_when_the_move_fails() -> JmoveResult<()> { + // Missing source: the last rename fails after the rewrite landed. + let dir = tempfile::TempDir::new()?; + mk(dir.path(), "src/app.ts", OLD)?; + let err = apply(dir.path(), &plan()).expect_err("missing source"); + assert!(matches!(err, crate::core::JmoveError::Io(_)), "{err}"); + // Importer restored to its exact original bytes; created dirs gone. + assert_eq!(fs::read_to_string(dir.path().join("src/app.ts"))?, OLD); + assert!(!dir.path().join("deep").exists()); + Ok(()) + } + + #[test] + fn apply_rejects_a_stale_plan_without_writing() -> JmoveResult<()> { + // SPAN was computed on OLD's layout; a single-line importer has + // different bytes there, so the run fails before any write. + let other = "import { fmt } from '../lib/fmt';\n"; + let dir = tempfile::TempDir::new()?; + let root = dir.path(); + mk(root, "src/app.ts", other)?; + mk(root, "lib/fmt.ts", "export const fmt = 1;\n")?; + let err = apply(root, &plan()).expect_err("span mismatch"); + assert!( + matches!(err, crate::core::JmoveError::StaleIndex(_)), + "{err}" + ); + assert_eq!(fs::read_to_string(root.join("src/app.ts"))?, other); + assert!(root.join("lib/fmt.ts").exists()); + Ok(()) + } +} diff --git a/src/core/index.rs b/src/core/index.rs index 85e481f..67addd4 100644 --- a/src/core/index.rs +++ b/src/core/index.rs @@ -5,11 +5,15 @@ //! parsed through [`crate::parser`] and its specifiers resolved through //! [`crate::parser::resolve`]. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; use std::path::{Path, PathBuf}; -use crate::core::JmoveResult; -use crate::parser::ImportRecord; +use ignore::WalkBuilder; + +use crate::core::{JmoveResult, normalize_rel_path}; +use crate::parser::resolve::resolve_module; +use crate::parser::{ImportRecord, Language, SourceLanguage, frontend_for}; /// Indexed source files with O(1) membership lookups. #[derive(Debug, Default)] @@ -62,18 +66,185 @@ pub struct Index { impl Index { /// Scan `root`, parse every supported source file and build the graph. - /// Unparseable files are skipped, not fatal. + /// Unreadable or unparseable files are skipped, not fatal. pub fn build(root: &Path) -> JmoveResult { - let _ = root; - todo!( - "index agent: scan with `ignore`, parse via crate::parser, resolve via parser::resolve" - ) + let root = root.canonicalize()?; + let mut index = Self { + root, + files: FileSet::default(), + imports: HashMap::new(), + }; + index.scan()?; + // Resolution needs the complete file set (extension/index guessing), + // so it runs as a second pass over the staged records. + for (importer, imports) in &mut index.imports { + for resolved in imports { + resolved.target = + resolve_module(importer, &resolved.record.specifier, &index.files); + } + } + Ok(index) + } + + // Walk the project and parse each supported source file, staging the + // raw records with `target: None` for the resolution pass above. + fn scan(&mut self) -> JmoveResult<()> { + // Sorted map: deterministic discovery order. + let mut found: BTreeMap = BTreeMap::new(); + for entry in WalkBuilder::new(&self.root).require_git(false).build() { + // Walker errors (unreadable dirs, etc.) simply skip the entry. + let Ok(entry) = entry else { continue }; + if entry.path_is_symlink() || !entry.file_type().is_some_and(|t| t.is_file()) { + continue; + } + let stripped = entry + .path() + .strip_prefix(&self.root) + .unwrap_or(Path::new("")); + let Some(rel) = normalize_rel_path(stripped) else { + continue; + }; + if let Some(lang) = SourceLanguage::for_path(&rel) { + found.insert(rel, lang); + } + } + // One frontend per language, created lazily as languages appear. + let mut frontends: Vec<(SourceLanguage, Box)> = Vec::new(); + for (rel, lang) in found { + let Ok(text) = fs::read_to_string(self.root.join(&rel)) else { + continue; // non-UTF-8 or vanished between scan and read + }; + if !frontends.iter().any(|(l, _)| *l == lang) { + frontends.push((lang, frontend_for(lang))); + } + let frontend = &mut frontends + .iter_mut() + .find(|(l, _)| *l == lang) + .expect("frontend was just ensured") + .1; + let records = frontend.extract_imports(&text); + self.files.add(rel.clone()); + self.imports.insert( + rel, + records + .into_iter() + .map(|record| ResolvedImport { + record, + target: None, + }) + .collect(), + ); + } + Ok(()) } /// Reverse edge lookup: every indexed file that imports `target`. #[must_use] pub fn importers_of(&self, target: &Path) -> Vec { - let _ = target; - todo!("index agent: reverse-edge lookup") + let mut importers: Vec = self + .imports + .iter() + .filter(|(_, imports)| imports.iter().any(|r| r.target.as_deref() == Some(target))) + .map(|(file, _)| file.clone()) + .collect(); + importers.sort(); + importers + } +} + +#[cfg(test)] +mod tests { + use super::{Index, ResolvedImport}; + use crate::core::JmoveResult; + use crate::parser::ImportRecord; + use std::fs; + use std::path::{Path, PathBuf}; + + // Write `rel` (creating parent dirs) inside `root`. + fn write_file(root: &Path, rel: &str, body: &str) -> JmoveResult<()> { + let path = root.join(rel); + fs::create_dir_all(path.parent().unwrap())?; + fs::write(path, body)?; + Ok(()) + } + + // Stub edge with resolved target `target` (parser-independent). + fn edge(target: &str) -> ResolvedImport { + let record = ImportRecord { + specifier: format!("./{target}"), + span: 0..0, + is_dynamic: false, + }; + ResolvedImport { + record, + target: Some(PathBuf::from(target)), + } + } + + #[test] + fn build_skips_ignored_and_unsupported_files() -> JmoveResult<()> { + let dir = tempfile::TempDir::new()?; + let root = dir.path(); + fs::create_dir(root.join(".git"))?; + write_file(root, ".gitignore", "ignored/\n")?; + write_file(root, "src/a.ts", "import { b } from './b';\n")?; + write_file(root, "src/b.ts", "export const b = 1;\n")?; + write_file(root, "src/legacy.js", "const a = require('./a');\n")?; + write_file(root, "ignored/c.ts", "export const c = 1;\n")?; + write_file(root, "docs/note.md", "not source\n")?; + fs::write(root.join("src/binary.ts"), [0xff_u8, 0xfe, 0x00, 0x01])?; + + let index = Index::build(root)?; + assert!(index.files.contains(Path::new("src/a.ts"))); + assert!(index.files.contains(Path::new("src/b.ts"))); + assert!(index.files.contains(Path::new("src/legacy.js"))); + // gitignored, non-source and non-UTF-8 files must never be indexed. + for skipped in ["ignored/c.ts", "docs/note.md", "src/binary.ts"] { + assert!(!index.files.contains(Path::new(skipped)), "{skipped}"); + } + Ok(()) + } + + #[test] + fn build_resolves_relative_specifier_to_indexed_file() -> JmoveResult<()> { + let dir = tempfile::TempDir::new()?; + let root = dir.path(); + write_file(root, "src/a.ts", "import { b } from './b';\n")?; + write_file(root, "src/b.ts", "export const b = 1;\n")?; + + let index = Index::build(root)?; + let imports = index + .imports + .get(Path::new("src/a.ts")) + .expect("a.ts must be indexed"); + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].record.specifier, "./b"); + assert_eq!(imports[0].target.as_deref(), Some(Path::new("src/b.ts"))); + assert_eq!(index.root, fs::canonicalize(root)?); + Ok(()) + } + + #[test] + fn importers_of_returns_sorted_reverse_edges() -> JmoveResult<()> { + let dir = tempfile::TempDir::new()?; + let mut index = Index::build(dir.path())?; + // Stub the graph so reverse-edge logic is independent of the parser. + index.imports.clear(); + let edges = [ + ("z.ts", "shared.ts"), + ("a.ts", "shared.ts"), + ("m.ts", "other.ts"), + ]; + for (file, target) in edges { + index + .imports + .insert(PathBuf::from(file), vec![edge(target)]); + } + assert_eq!( + index.importers_of(Path::new("shared.ts")), + vec![PathBuf::from("a.ts"), PathBuf::from("z.ts")] + ); + assert!(index.importers_of(Path::new("missing.ts")).is_empty()); + Ok(()) } } diff --git a/src/core/mod.rs b/src/core/mod.rs index f4f8245..7f02590 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -43,7 +43,7 @@ pub type JmoveResult = Result; /// # Examples /// /// ``` -/// use std::path::Path; +/// use std::path::{Path, PathBuf}; /// use jmove::core::normalize_rel_path; /// /// assert_eq!( @@ -59,9 +59,8 @@ pub fn normalize_rel_path(path: &Path) -> Option { match comp { Component::CurDir => {} Component::ParentDir => { - if stack.pop().is_none() { - return None; // would escape the project root - } + // `?` on the popped Option: escaping the root yields None. + stack.pop()?; } Component::Normal(piece) => stack.push(piece), // Absolute paths and Windows prefixes are not project-relative. diff --git a/src/core/plan.rs b/src/core/plan.rs deleted file mode 100644 index ae7a9db..0000000 --- a/src/core/plan.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Move planning: decide which import specifiers must be rewritten. -//! -//! A plan is pure data (no disk writes), so dry-run and `--json` can render -//! it without touching the filesystem. - -use std::ops::Range; -use std::path::Path; -use std::path::PathBuf; - -use crate::core::JmoveResult; -use crate::core::index::Index; - -/// One in-file replacement of an import specifier. Only the specifier text -/// between the quotes is touched — the statement layout is never reformatted. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Rewrite { - /// Project-relative file to modify. - pub file: PathBuf, - /// Byte range of the old specifier text (without quotes). - pub span: Range, - /// Specifier as currently written. - pub old_text: String, - /// Specifier after the move. - pub new_text: String, -} - -/// Complete plan for moving `source` to `target`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MovePlan { - /// Project-relative path being moved. - pub source: PathBuf, - /// Project-relative destination path. - pub target: PathBuf, - /// Specifier rewrites, grouped per importer file. - pub rewrites: Vec, -} - -/// Compute the rewrite plan for `source -> target`. -/// -/// Every indexed import whose resolved target is `source` gets a new -/// relative specifier computed from the *importer's* directory to `target`. -/// Rewrites whose result equals the old specifier are dropped. -pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult { - let _ = (index, source, target); - todo!("index agent: implement planner incl. relative-specifier math") -} diff --git a/src/core/plan/mod.rs b/src/core/plan/mod.rs new file mode 100644 index 0000000..a84993c --- /dev/null +++ b/src/core/plan/mod.rs @@ -0,0 +1,187 @@ +//! Move planning: decide which import specifiers must be rewritten. +//! +//! A plan is pure data (no disk writes), so dry-run and `--json` can render +//! it without touching the filesystem. Specifier arithmetic lives in +//! [`specifier`]. + +mod specifier; + +pub use specifier::relative_specifier; + +use std::ops::Range; +use std::path::{Path, PathBuf}; + +use crate::core::index::Index; +use crate::core::{JmoveError, JmoveResult, normalize_rel_path}; + +/// One in-file replacement of an import specifier. Only the specifier text +/// between the quotes is touched — the statement layout is never reformatted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rewrite { + /// Project-relative file to modify. + pub file: PathBuf, + /// Byte range of the old specifier text (without quotes). + pub span: Range, + /// Specifier as currently written. + pub old_text: String, + /// Specifier after the move. + pub new_text: String, +} + +/// Complete plan for moving `source` to `target`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MovePlan { + /// Project-relative path being moved. + pub source: PathBuf, + /// Project-relative destination path. + pub target: PathBuf, + /// Specifier rewrites, sorted by (file, span). + pub rewrites: Vec, +} + +/// Compute the rewrite plan for `source -> target`. +/// +/// Every indexed import whose resolved target equals `source` gets a new +/// relative specifier from the importer's directory to `target` (see +/// [`relative_specifier`]). Rewrites whose result equals the old specifier +/// are dropped; the result is sorted by (file, span). +pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult { + let rel = |label: &str, p: &Path| match normalize_rel_path(p) { + Some(r) => Ok(r), + None => Err(JmoveError::InvalidArgument(format!( + "invalid {label} '{}'", + p.display() + ))), + }; + let (source, target) = (rel("source path", source)?, rel("target path", target)?); + if !index.files.contains(&source) { + let s = source.display(); + return Err(JmoveError::InvalidArgument(format!( + "source '{s}' is not an indexed file" + ))); + } + if source == target { + return Err(JmoveError::PlanRejected( + "source and target are the same".into(), + )); + } + if index.files.contains(&target) { + let t = target.display(); + return Err(JmoveError::PlanRejected(format!( + "target '{t}' already exists" + ))); + } + + let mut rewrites = Vec::new(); + for importer in index.importers_of(&source) { + let edges = index.imports[&importer] + .iter() + .filter(|e| e.target.as_deref() == Some(source.as_path())); + for edge in edges { + let new_text = relative_specifier(&importer, &target); + if new_text == edge.record.specifier { + continue; // no-op rewrite, never reaches the plan + } + let record = &edge.record; + rewrites.push(Rewrite { + file: importer.clone(), + span: record.span.clone(), + old_text: record.specifier.clone(), + new_text, + }); + } + } + rewrites.sort_by_key(|r| (r.file.clone(), r.span.start)); + Ok(MovePlan { + source, + target, + rewrites, + }) +} + +#[cfg(test)] +mod tests { + use super::{Rewrite, plan_move}; + use crate::core::JmoveError; + use crate::core::index::{Index, ResolvedImport}; + use crate::parser::ImportRecord; + use std::ops::Range; + use std::path::{Path, PathBuf}; + + // Hand-wired edges: planner tests never touch the parser. + fn edge(spec: &str, span: Range, target: &str) -> ResolvedImport { + let record = ImportRecord { + specifier: spec.into(), + span, + is_dynamic: false, + }; + ResolvedImport { + record, + target: Some(target.into()), + } + } + + fn index_with(files: &[&str], imports: &[(&str, Vec)]) -> Index { + let mut ix = Index::default(); + for f in files { + ix.files.add(PathBuf::from(f)); + } + ix.imports + .extend(imports.iter().map(|(f, r)| (PathBuf::from(*f), r.clone()))); + ix + } + + #[test] + fn plan_rewrites_sorted_by_file_then_span() { + let z = vec![edge("./s", 30..33, "s.ts"), edge("./s", 5..8, "s.ts")]; + let a = vec![edge("./s", 0..3, "s.ts")]; + let index = index_with(&["s.ts", "z.ts", "a.ts"], &[("z.ts", z), ("a.ts", a)]); + let plan = plan_move(&index, Path::new("s.ts"), Path::new("sub/deep/s.ts")).unwrap(); + let keys: Vec<(String, usize)> = plan + .rewrites + .iter() + .map(|r| (r.file.display().to_string(), r.span.start)) + .collect(); + assert_eq!( + keys, + [ + ("a.ts".to_string(), 0usize), + ("z.ts".into(), 5), + ("z.ts".into(), 30) + ] + ); + assert_eq!( + plan.rewrites[0], + Rewrite { + file: "a.ts".into(), + span: 0..3, + old_text: "./s".into(), + new_text: "./sub/deep/s".into(), + } + ); + } + + #[test] + fn plan_drops_noop_rewrites() { + // fmt.ts -> fmt.js keeps `./lib/fmt` valid: no specifier edits. + let edges = vec![edge("./lib/fmt", 0..0, "lib/fmt.ts")]; + let index = index_with(&["lib/fmt.ts", "app.ts"], &[("app.ts", edges)]); + let plan = plan_move(&index, Path::new("lib/fmt.ts"), Path::new("lib/fmt.js")).unwrap(); + assert!(plan.rewrites.is_empty()); + assert_eq!(plan.target, PathBuf::from("lib/fmt.js")); + } + + #[test] + fn plan_rejects_bad_source_and_target() { + let edges = vec![edge("./old", 7..12, "src/old.ts")]; + let index = index_with(&["src/old.ts", "src/app.ts"], &[("src/app.ts", edges)]); + // A missing source is an invalid argument... + let err = plan_move(&index, Path::new("ghost.ts"), Path::new("x.ts")).unwrap_err(); + assert!(matches!(err, JmoveError::InvalidArgument(_))); + // ...an existing or identical target is a plan rejection. + for target in ["src/app.ts", "src/old.ts"] { + let err = plan_move(&index, Path::new("src/old.ts"), Path::new(target)).unwrap_err(); + assert!(matches!(err, JmoveError::PlanRejected(_)), "{target}"); + } + } +} diff --git a/src/core/plan/specifier.rs b/src/core/plan/specifier.rs new file mode 100644 index 0000000..0f2e4b7 --- /dev/null +++ b/src/core/plan/specifier.rs @@ -0,0 +1,93 @@ +// Pure specifier arithmetic: TS module-stem conventions and directory +// component math. Separated from planner logic to keep plan/ files small. + +use std::path::{Component, Path}; + +// Module stem of a file name: `x.d.ts` -> `x`, `x.ts` -> `x`, unknown or +// missing extension kept. `index` is never stripped (stays conservative). +fn module_stem(name: &str) -> &str { + if let Some(s) = name.strip_suffix(".d.ts") { + return s; + } + match name.rsplit_once('.') { + Some((s, "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs")) => s, + _ => name, + } +} + +// Normal directory components of a project-relative path. +fn dir_parts(path: &Path) -> Vec { + let parent = path.parent().unwrap_or(Path::new("")); + parent + .components() + .filter_map(|c| match c { + Component::Normal(s) => Some(s.to_string_lossy().into_owned()), + _ => None, + }) + .collect() +} + +/// Compute the TS/JS relative specifier from `importer`'s directory to +/// `target`, prefixed with `./` or `../` as needed. Extension stripping +/// follows TS module-resolution convention: `.ts`/`.tsx`/`.js`/`.jsx`/ +/// `.mjs`/`.cjs` are removed from file targets (an importer that wrote the +/// extension may keep doing so — the specifier stays valid). +/// +/// ``` +/// # use std::path::Path; +/// # use jmove::core::plan::relative_specifier; +/// assert_eq!(relative_specifier(Path::new("src/services/a.ts"), Path::new("src/utils/fmt.ts")), "../utils/fmt"); +/// ``` +#[must_use] +pub fn relative_specifier(importer: &Path, target: &Path) -> String { + // Component math: consume the common directory prefix, one `..` per + // leftover importer dir; the stem joins last, never as a directory. + let name = target + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let (stem, dirs, mdirs) = (module_stem(&name), dir_parts(importer), dir_parts(target)); + let common = dirs.iter().zip(&mdirs).take_while(|(a, b)| a == b).count(); + let up = dirs.len() - common; + let mut parts = vec!["..".to_string(); up]; + parts.extend_from_slice(&mdirs[common..]); + parts.push(stem.to_string()); + let joined = parts.join("/"); + if up > 0 { + joined // already starts with `../` + } else { + format!("./{joined}") + } +} + +#[cfg(test)] +mod tests { + use super::relative_specifier; + use std::path::Path; + + #[test] + fn relative_specifier_table() { + let cases = [ + ("src/a.ts", "src/b.ts", "./b"), // sibling + ("src/a.ts", "src/lib/b.ts", "./lib/b"), // child + ("src/lib/a.ts", "src/b.ts", "../b"), // parent + ("a/b/c/x.ts", "root/file.ts", "../../../root/file"), // `..` chain + ("main.ts", "src/util.ts", "./src/util"), // root importer + ("src/main.ts", "util.js", "../util"), // root target + ("src/a.ts", "src/foo/index.ts", "./foo/index"), // index kept + ("src/a/deep.ts", "src/foo/index.ts", "../foo/index"), + ("src/a.ts", "src/shim.d.ts", "./shim"), // .d.ts dropped + ("src/a.ts", "types/global.d.ts", "../types/global"), + ("src/a.ts", "src/b.mts", "./b.mts"), // unknown ext kept + ("src/a.ts", "data/config.json", "../data/config.json"), + ]; + for (i, t, want) in cases { + assert_eq!( + relative_specifier(Path::new(i), Path::new(t)), + want, + "{i} -> {t}" + ); + } + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..ce2e853 --- /dev/null +++ b/tests/cli.rs @@ -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 ` 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/` 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 ` 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 = 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 = 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\"")); +} diff --git a/tests/go/README.md b/tests/go/README.md new file mode 100644 index 0000000..3246da0 --- /dev/null +++ b/tests/go/README.md @@ -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`. diff --git a/tests/java/README.md b/tests/java/README.md new file mode 100644 index 0000000..9b3e19b --- /dev/null +++ b/tests/java/README.md @@ -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`. diff --git a/tests/python/README.md b/tests/python/README.md new file mode 100644 index 0000000..f5b8969 --- /dev/null +++ b/tests/python/README.md @@ -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`. diff --git a/tests/typescript/basic/app.ts b/tests/typescript/basic/app.ts new file mode 100644 index 0000000..b1340e4 --- /dev/null +++ b/tests/typescript/basic/app.ts @@ -0,0 +1,5 @@ +import { sum } from "./lib/sum"; + +export function main(): number { + return sum(1, 2); +} diff --git a/tests/typescript/basic/lib/sum.ts b/tests/typescript/basic/lib/sum.ts new file mode 100644 index 0000000..7372ed0 --- /dev/null +++ b/tests/typescript/basic/lib/sum.ts @@ -0,0 +1,3 @@ +export function sum(a: number, b: number): number { + return a + b; +} diff --git a/tests/typescript/basic/package.json b/tests/typescript/basic/package.json new file mode 100644 index 0000000..350b3f4 --- /dev/null +++ b/tests/typescript/basic/package.json @@ -0,0 +1 @@ +{ "name": "basic" } diff --git a/tests/typescript/complex/package.json b/tests/typescript/complex/package.json new file mode 100644 index 0000000..9b8b7bd --- /dev/null +++ b/tests/typescript/complex/package.json @@ -0,0 +1 @@ +{ "name": "complex" } diff --git a/tests/typescript/complex/src/broken.ts b/tests/typescript/complex/src/broken.ts new file mode 100644 index 0000000..b02be7b --- /dev/null +++ b/tests/typescript/complex/src/broken.ts @@ -0,0 +1,6 @@ +// Fixture with one intentionally broken relative import. + +export const flag = true; +import { ghost } from "./gone"; + +export const who = ghost; diff --git a/tests/typescript/complex/src/config.ts b/tests/typescript/complex/src/config.ts new file mode 100644 index 0000000..9aee5c7 --- /dev/null +++ b/tests/typescript/complex/src/config.ts @@ -0,0 +1,3 @@ +import { trim } from "./util/../util/text"; + +export const DEFAULT = trim(" hi "); diff --git a/tests/typescript/complex/src/engine/index.ts b/tests/typescript/complex/src/engine/index.ts new file mode 100644 index 0000000..f99ddb7 --- /dev/null +++ b/tests/typescript/complex/src/engine/index.ts @@ -0,0 +1,5 @@ +import { trim } from "../util/text"; + +export function solve(input: string): string { + return trim(input); +} diff --git a/tests/typescript/complex/src/ui/deep/nested/view.ts b/tests/typescript/complex/src/ui/deep/nested/view.ts new file mode 100644 index 0000000..86eca10 --- /dev/null +++ b/tests/typescript/complex/src/ui/deep/nested/view.ts @@ -0,0 +1,7 @@ +import { + pad, +} from "../../../util/text"; + +export function render(value: string): string { + return pad(value); +} diff --git a/tests/typescript/complex/src/util/text.ts b/tests/typescript/complex/src/util/text.ts new file mode 100644 index 0000000..23dbeb2 --- /dev/null +++ b/tests/typescript/complex/src/util/text.ts @@ -0,0 +1,7 @@ +export function trim(s: string): string { + return s.trim(); +} + +export function pad(s: string): string { + return ` ${s} `; +} diff --git a/tests/typescript/normal/.gitignore b/tests/typescript/normal/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/tests/typescript/normal/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/tests/typescript/normal/package.json b/tests/typescript/normal/package.json new file mode 100644 index 0000000..786d882 --- /dev/null +++ b/tests/typescript/normal/package.json @@ -0,0 +1 @@ +{ "name": "normal" } diff --git a/tests/typescript/normal/src/app.ts b/tests/typescript/normal/src/app.ts new file mode 100644 index 0000000..49b6a2c --- /dev/null +++ b/tests/typescript/normal/src/app.ts @@ -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 }; diff --git a/tests/typescript/normal/src/impl/core.ts b/tests/typescript/normal/src/impl/core.ts new file mode 100644 index 0000000..5a84016 --- /dev/null +++ b/tests/typescript/normal/src/impl/core.ts @@ -0,0 +1,3 @@ +export function compute(x: number): number { + return x * 2; +} diff --git a/tests/typescript/normal/src/impl/index.ts b/tests/typescript/normal/src/impl/index.ts new file mode 100644 index 0000000..8d119de --- /dev/null +++ b/tests/typescript/normal/src/impl/index.ts @@ -0,0 +1 @@ +export * from "./core"; diff --git a/tests/typescript/normal/src/index.ts b/tests/typescript/normal/src/index.ts new file mode 100644 index 0000000..d5e1142 --- /dev/null +++ b/tests/typescript/normal/src/index.ts @@ -0,0 +1 @@ +export * from "./impl"; diff --git a/tests/typescript/normal/src/types.ts b/tests/typescript/normal/src/types.ts new file mode 100644 index 0000000..088c2a7 --- /dev/null +++ b/tests/typescript/normal/src/types.ts @@ -0,0 +1,3 @@ +export interface Thing { + id: string; +} diff --git a/tests/typescript/normal/src/utils/logger.ts b/tests/typescript/normal/src/utils/logger.ts new file mode 100644 index 0000000..603a17a --- /dev/null +++ b/tests/typescript/normal/src/utils/logger.ts @@ -0,0 +1,3 @@ +export function log(...parts: unknown[]): void { + void parts; +} diff --git a/todo.md b/todo.md index b9fcd5c..a83b260 100644 --- a/todo.md +++ b/todo.md @@ -10,30 +10,54 @@ - Запрещён mёртвый код: unused code удаляется или реализуется; `#[allow(dead_code)]` не использовать. ## MVP (Phase 1) -- [ ] CLI skeleton (clap) — команды: mv, index, check, --dry-run -- [ ] Сканер файлов проекта (уважать .gitignore через `ignore` крейт) -- [ ] Парсер импортов для TypeScript/JS через tree-sitter -- [ ] Построение графа зависимостей (файл → что импортирует) -- [ ] Инвертированный граф (файл → кто его импортирует) -- [ ] Вычисление нового относительного пути после mv -- [ ] Rewrite импортов в файлах -- [ ] Атомарный apply (сначала rewrite, потом mv) + rollback при ошибке -- [ ] Dry-run режим с diff выводом +- [x] CLI skeleton (clap) — команды: mv, check, --dry-run, --json +- [x] Сканер файлов проекта (уважать .gitignore через `ignore` крейт) +- [x] Парсер импортов для TypeScript/JS через tree-sitter +- [x] Разрешение путей: extension guessing (.ts/.tsx/.js/...), index.* (в MVP — без этого инструмент игрушка) +- [x] Построение графа зависимостей (файл → что импортирует) +- [x] Инвертированный граф (файл → кто его импортирует) +- [x] Вычисление нового относительного пути после mv +- [x] Rewrite импортов в файлах (трогаем только specifier-строку, никогда не реформатим statement) +- [x] Атомарный apply (сначала rewrite, потом mv) + rollback при ошибке +- [x] Dry-run режим с diff выводом +- [x] Команда check (битые импорты, exit code 2) — self-test инструмента +- [x] Тесты: unit (parser, core) + e2e (CLI на фикстурах) + +## AI / Agent support +- [x] --json флаг на всех командах (status ok|dry_run|error) +- [x] --dry-run + --json (preview без записи на диск) +- [x] Стабильные error codes (TARGET_EXISTS, SOURCE_NOT_FOUND, ...) + hint поле +- [x] Exit codes: 0 ok / 1 error / 2 broken imports +- [x] docs/SKILL.md — машиночитаемая документация для AI агентов +- [x] docs/EXAMPLES.md — примеры для людей и агентов + +## Phase 1.5 — Java (наша ниша, аналогов в CLI нет) +- [ ] tree-sitter Java грамматика: package + import extraction +- [ ] Детект source root (src/main/java, src/) и соответствие package ⇄ директория +- [ ] mv = три синхронных правки: package, все import в проекту, физический перенос +- [ ] e2e фикстуры tests/java/ ## Phase 2 - [ ] Кэш индекса на диске (bincode/rkyv) → .jmove/index - [ ] Инкрементальная переиндексация (только изменённые файлы) - [ ] Поддержка tsconfig paths / алиасов (@/...) - [ ] Параллельная индексация через rayon +- [ ] --git интеграция (git mv для stage/истории) +- [ ] Перенос директорий целиком (mv папки) +- [ ] Предупреждения о не-import ссылках: package.json exports, jest mocks, tsconfig includes, markdown links +- [ ] prettier интеграция после rewrite (по желанию) ## Phase 3 -- [ ] Поддержка Python -- [ ] Поддержка Java -- [ ] Поддержка Go +- [ ] Поддержка Python (from/import, относительные точки) +- [ ] Поддержка Go (per-file, НЕ whole-package как refac) - [ ] Команда split (авто-разбивка файла на несколько) -- [ ] Команда check (найти все битые импорты) +- [ ] Windows-пути (camino/normalize) — CI matrix ## Идеи на потом -- [ ] LSP интеграция +- [ ] LSP интеграция (jmove сам как LSP server) - [ ] Watch mode - [ ] VS Code расширение как обёртка над CLI + +## Конкуренты (см. docs/PLAN.md) +- refac / ai_refac (jav-ed): TS/Py/Rust/Go/Dart, но без dry-run, лимит 30 файлов в TS, + Go = весь пакет, Java нет. Наш edge: dry-run+атомарность, Java, split, скорость (без LSP), UX.