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:
parent
9d8f1e0bba
commit
55670030c3
37 changed files with 1851 additions and 139 deletions
173
docs/EXAMPLES.md
Normal file
173
docs/EXAMPLES.md
Normal file
|
|
@ -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 <source> <target> [--dry-run] [--json]`,
|
||||
`check [--json]`, and the global `--root <DIR>`. 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`.
|
||||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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 <source> <target> [--root DIR] [--dry-run] [--json] [--force]
|
||||
jmove mv <source> <target> [--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.
|
||||
|
|
|
|||
188
src/cli/json.rs
188
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<T: Serialize> {
|
||||
|
|
@ -18,6 +24,40 @@ pub struct Envelope<T: Serialize> {
|
|||
pub data: T,
|
||||
}
|
||||
|
||||
impl<T: Serialize> Envelope<T> {
|
||||
/// 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<ErrorData> {
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String>) -> 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<Change>,
|
||||
}
|
||||
|
||||
/// 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<ChangedFile>,
|
||||
/// 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<ChangedFile>) -> 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<String>,
|
||||
/// 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<BrokenImport>,
|
||||
/// Number of broken imports (kept as an explicit counter for agents).
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
/// Serialize `value` as pretty JSON to stdout.
|
||||
pub fn print<T: Serialize>(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}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
198
src/cli/mod.rs
198
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<T> = Result<T, i32>;
|
||||
|
||||
/// Parse arguments and run the selected command.
|
||||
/// Returns the process exit code; `Err` is reserved for unexpected failures.
|
||||
pub fn run() -> anyhow::Result<i32> {
|
||||
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<i32> {
|
||||
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<i32> {
|
||||
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<ErrorData> {
|
||||
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<i32> {
|
||||
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<i32> {
|
||||
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<i32> {
|
||||
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<T>(json: bool, operation: &'static str, result: JmoveResult<T>) -> Flow<T> {
|
||||
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<PathBuf> {
|
||||
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<Component<'_>> = 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()
|
||||
}
|
||||
|
|
|
|||
145
src/cli/output.rs
Normal file
145
src/cli/output.rs
Normal file
|
|
@ -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<String> {
|
||||
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<Vec<BrokenImport>> {
|
||||
let mut broken: Vec<BrokenImport> = 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<Vec<ChangedFile>> {
|
||||
let mut per_file: BTreeMap<&PathBuf, Vec<Change>> = 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Applied> {
|
||||
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<String> {
|
||||
let _ = (root, plan);
|
||||
todo!("index agent: diff rendering via `similar`")
|
||||
}
|
||||
80
src/core/apply/diff.rs
Normal file
80
src/core/apply/diff.rs
Normal file
|
|
@ -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 <source> -> <target>` line, for dry-run. A plan without rewrites
|
||||
/// renders the empty string.
|
||||
pub fn render_diff(root: &Path, plan: &MovePlan) -> JmoveResult<String> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
71
src/core/apply/fsops.rs
Normal file
71
src/core/apply/fsops.rs
Normal file
|
|
@ -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<PathBuf, Vec<&Rewrite>> {
|
||||
let mut m: BTreeMap<PathBuf, Vec<&Rewrite>> = BTreeMap::new();
|
||||
for r in &plan.rewrites {
|
||||
m.entry(r.file.clone()).or_default().push(r);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
// `<file>.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<Vec<u8>> {
|
||||
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<Vec<PathBuf>> {
|
||||
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)
|
||||
}
|
||||
201
src/core/apply/mod.rs
Normal file
201
src/core/apply/mod.rs
Normal file
|
|
@ -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<u8>)>,
|
||||
dirs: Vec<PathBuf>,
|
||||
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<Applied> {
|
||||
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<Applied> {
|
||||
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<usize> = 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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Self> {
|
||||
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<PathBuf, SourceLanguage> = 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<dyn Language>)> = 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<PathBuf> {
|
||||
let _ = target;
|
||||
todo!("index agent: reverse-edge lookup")
|
||||
let mut importers: Vec<PathBuf> = 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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ pub type JmoveResult<T> = Result<T, JmoveError>;
|
|||
/// # 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<PathBuf> {
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -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<usize>,
|
||||
/// 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<Rewrite>,
|
||||
}
|
||||
|
||||
/// 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<MovePlan> {
|
||||
let _ = (index, source, target);
|
||||
todo!("index agent: implement planner incl. relative-specifier math")
|
||||
}
|
||||
187
src/core/plan/mod.rs
Normal file
187
src/core/plan/mod.rs
Normal file
|
|
@ -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<usize>,
|
||||
/// 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<Rewrite>,
|
||||
}
|
||||
|
||||
/// 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<MovePlan> {
|
||||
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<usize>, 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<ResolvedImport>)]) -> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/core/plan/specifier.rs
Normal file
93
src/core/plan/specifier.rs
Normal file
|
|
@ -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<String> {
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
248
tests/cli.rs
Normal file
248
tests/cli.rs
Normal 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
1
tests/go/README.md
Normal 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
1
tests/java/README.md
Normal 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
1
tests/python/README.md
Normal 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`.
|
||||
5
tests/typescript/basic/app.ts
Normal file
5
tests/typescript/basic/app.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { sum } from "./lib/sum";
|
||||
|
||||
export function main(): number {
|
||||
return sum(1, 2);
|
||||
}
|
||||
3
tests/typescript/basic/lib/sum.ts
Normal file
3
tests/typescript/basic/lib/sum.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function sum(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
1
tests/typescript/basic/package.json
Normal file
1
tests/typescript/basic/package.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "name": "basic" }
|
||||
1
tests/typescript/complex/package.json
Normal file
1
tests/typescript/complex/package.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "name": "complex" }
|
||||
6
tests/typescript/complex/src/broken.ts
Normal file
6
tests/typescript/complex/src/broken.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Fixture with one intentionally broken relative import.
|
||||
|
||||
export const flag = true;
|
||||
import { ghost } from "./gone";
|
||||
|
||||
export const who = ghost;
|
||||
3
tests/typescript/complex/src/config.ts
Normal file
3
tests/typescript/complex/src/config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { trim } from "./util/../util/text";
|
||||
|
||||
export const DEFAULT = trim(" hi ");
|
||||
5
tests/typescript/complex/src/engine/index.ts
Normal file
5
tests/typescript/complex/src/engine/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { trim } from "../util/text";
|
||||
|
||||
export function solve(input: string): string {
|
||||
return trim(input);
|
||||
}
|
||||
7
tests/typescript/complex/src/ui/deep/nested/view.ts
Normal file
7
tests/typescript/complex/src/ui/deep/nested/view.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import {
|
||||
pad,
|
||||
} from "../../../util/text";
|
||||
|
||||
export function render(value: string): string {
|
||||
return pad(value);
|
||||
}
|
||||
7
tests/typescript/complex/src/util/text.ts
Normal file
7
tests/typescript/complex/src/util/text.ts
Normal 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
1
tests/typescript/normal/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
1
tests/typescript/normal/package.json
Normal file
1
tests/typescript/normal/package.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "name": "normal" }
|
||||
14
tests/typescript/normal/src/app.ts
Normal file
14
tests/typescript/normal/src/app.ts
Normal 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 };
|
||||
3
tests/typescript/normal/src/impl/core.ts
Normal file
3
tests/typescript/normal/src/impl/core.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function compute(x: number): number {
|
||||
return x * 2;
|
||||
}
|
||||
1
tests/typescript/normal/src/impl/index.ts
Normal file
1
tests/typescript/normal/src/impl/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./core";
|
||||
1
tests/typescript/normal/src/index.ts
Normal file
1
tests/typescript/normal/src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./impl";
|
||||
3
tests/typescript/normal/src/types.ts
Normal file
3
tests/typescript/normal/src/types.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export interface Thing {
|
||||
id: string;
|
||||
}
|
||||
3
tests/typescript/normal/src/utils/logger.ts
Normal file
3
tests/typescript/normal/src/utils/logger.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function log(...parts: unknown[]): void {
|
||||
void parts;
|
||||
}
|
||||
52
todo.md
52
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue