diff --git a/README.md b/README.md index 0a42c3c..c9f9669 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,9 @@ jmove mv src/foo.ts src/bar/foo.ts --no-git # Move a whole directory: every file relocates, every importer follows jmove mv src/utils src/helpers +# Moves also warn (never edit) about references the import graph cannot +# see: markdown links, package.json fields, jest.mock strings + # Java: jmove updates `package`, all `import`s and moves the file jmove mv src/com/example/utils/Parser.java src/com/example/core/Parser.java diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 6ea482d..75117f3 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -165,15 +165,13 @@ $ jmove mv lib/sum.ts utils/sum.ts --dry-run --json "would_move": "lib/sum.ts", "target": "utils/sum.ts", "would_update": 1, - "affected_files": [ - "app.ts" - ], + "affected_files": ["app.ts"], "would_move_via": "fs", "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 +Review `affected_files` and `non_import_refs`; abort and ask the user if the blast radius is unexpected. ### 2. Apply @@ -188,25 +186,26 @@ $ jmove mv lib/sum.ts utils/sum.ts --json "changed_files": [ { "path": "app.ts", - "changes": [ - { - "line": 1, - "old": "./lib/sum", - "new": "./utils/sum" - } - ] + "changes": [{ "line": 1, "old": "./lib/sum", "new": "./utils/sum" }] } ], "moved": 1, "updated_imports": 1, - "moved_via": "fs" + "moved_via": "fs", + "non_import_refs": [ + { "file": "README.md", "line": 1, "token": "lib/sum.ts", + "kind": "path", "text": "Use [sum](./lib/sum.ts) via `lib/sum`." } + ] } ``` `changed_files[].changes[]` lists every rewritten specifier with its 1-based line; `moved` and `updated_imports` are the counters, `moved_via` tells whether the rename went through git (`"git"`, staged) -or the plain filesystem (`"fs"`). +or the plain filesystem (`"fs"`). `non_import_refs` (omitted when empty, +also present in dry-run) names references the import graph cannot see — +markdown links, `package.json` fields, `jest.mock` strings. jmove never +edits those; report them to the user. ### 3. Verify diff --git a/docs/SKILL.md b/docs/SKILL.md index e16325f..dbffb3a 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -47,6 +47,13 @@ while the new file stays inside the mapped tree and falls back to a relative specifier otherwise. `extends` chains and 2nd+ candidate lists are not followed (v1); an invalid tsconfig silently means "no aliases". +Non-import references: before writing, `mv` scans markdown/config/text +files and source strings for mentions of moved paths and old specifiers. +Such references are never rewritten (each format has its own semantics); +they are reported as `non_import_refs[]` (`file`, `line`, `token`, `kind`, +`text`) in `mv --json` — dry-run included — and on stderr for humans. +Exit code never changes; surface the list to the user. + ### check — find broken imports and Java layout errors ``` diff --git a/src/cli/fix.rs b/src/cli/fix.rs index 40daca4..fde7cdd 100644 --- a/src/cli/fix.rs +++ b/src/cli/fix.rs @@ -79,7 +79,7 @@ pub fn fix( json: bool, ) -> Flow { let root = flow(json, "fix", root.canonicalize().map_err(JmoveError::from))?; - let source_root = flow(json, "fix", super::normalize_scope(&root, source_root))?; + let source_root = flow(json, "fix", Index::normalize_scope(&root, source_root))?; if let Some(rejected) = fix_reject(rule) { return Err(fail(json, "fix", rejected)); } diff --git a/src/cli/json.rs b/src/cli/json.rs index 72aacb9..cabe4fa 100644 --- a/src/cli/json.rs +++ b/src/cli/json.rs @@ -8,9 +8,11 @@ use serde::Serialize; use crate::core::plan::MovePlan; +use crate::core::refs::NonImportRef; use crate::core::{JmoveError, rel_str}; -use super::output; +use super::output::ChangedFile; +use super::output::{self}; /// Top-level envelope for every `--json` response. #[derive(Debug, Serialize)] @@ -107,26 +109,6 @@ impl ErrorData { } } -/// 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 { @@ -142,6 +124,9 @@ pub struct MvData { pub updated_imports: usize, /// Rename backend: `"git"` (every rename staged in the index) or `"fs"`. pub moved_via: &'static str, + /// Non-import textual references left unfixed (omitted when none). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub non_import_refs: Vec, /// Directory moves only: each `(from, to)` relocation (omitted for file moves). #[serde(skip_serializing_if = "Vec::is_empty")] pub moved_files: Vec, @@ -180,7 +165,12 @@ fn dir_moves(plan: &MovePlan) -> Vec { impl MvData { /// Assemble the payload from an applied plan and its change details. #[must_use] - pub fn new(plan: &MovePlan, changed_files: Vec, via_git: bool) -> Self { + pub fn new( + plan: &MovePlan, + changed_files: Vec, + via_git: bool, + non_import_refs: Vec, + ) -> Self { Self { source: rel_str(&plan.source), target: rel_str(&plan.target), @@ -188,6 +178,7 @@ impl MvData { moved: plan.moves.len(), updated_imports: plan.rewrites.len(), moved_via: if via_git { "git" } else { "fs" }, + non_import_refs, // A single-file move keeps the old contract: no extra fields. moved_files: dir_moves(plan), left_behind: plan.left_behind.iter().map(|p| rel_str(p)).collect(), @@ -211,6 +202,9 @@ pub struct MvDryRunData { pub diff: String, /// Rename backend a real run would use: `"git"` or `"fs"`. pub would_move_via: &'static str, + /// Non-import references a real run would leave unfixed. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub non_import_refs: Vec, /// Directory moves only: every relocation that would happen. #[serde(skip_serializing_if = "Vec::is_empty")] pub would_move_files: Vec, @@ -219,7 +213,12 @@ pub struct MvDryRunData { impl MvDryRunData { /// Assemble the preview payload from a plan and its rendered diff. #[must_use] - pub fn new(plan: &MovePlan, diff: String, via_git: bool) -> Self { + pub fn new( + plan: &MovePlan, + diff: String, + via_git: bool, + non_import_refs: Vec, + ) -> Self { Self { would_move: rel_str(&plan.source), target: rel_str(&plan.target), @@ -231,6 +230,7 @@ impl MvDryRunData { diff, would_move_via: if via_git { "git" } else { "fs" }, would_move_files: dir_moves(plan), + non_import_refs, } } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f18956e..c627d5b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -15,6 +15,7 @@ use clap::{Parser, Subcommand}; use crate::core::apply; use crate::core::index::Index; use crate::core::plan::{self, MovePlan}; +use crate::core::refs; use crate::core::{self, JmoveError, JmoveResult}; use json::{Envelope, ErrorData, MvData, MvDryRunData}; @@ -106,7 +107,7 @@ pub fn run() -> anyhow::Result { &target, dry_run, json, - no_git, + apply::GitMode::from_no_git(no_git), ), Command::Check { json } => check(&args.root, args.source_root.as_deref(), json), Command::Fix { @@ -133,11 +134,10 @@ fn mv( target: &Path, dry_run: bool, json: bool, - no_git: bool, + git: apply::GitMode, ) -> Flow { - let git = apply::GitMode::from_no_git(no_git); let root = flow(json, "mv", root.canonicalize().map_err(JmoveError::from))?; - let source_root = flow(json, "mv", normalize_scope(&root, source_root))?; + let source_root = flow(json, "mv", Index::normalize_scope(&root, source_root))?; let source = flow(json, "mv", core::rel_from_root(&root, source))?; let target = flow(json, "mv", core::rel_from_root(&root, target))?; if let Some(rejected) = output::mv_reject(&root, &source, &target) { @@ -147,11 +147,11 @@ fn mv( let scope = source_root.as_deref(); let index = flow(json, "mv", Index::build_scoped(&root, scope))?; let plan = flow(json, "mv", plan::plan_move(&index, &source, &target))?; + let hidden = refs::scan(&root, &index, &plan); if dry_run { - return mv_dry_run(&root, json, &plan, git); + return mv_dry_run(&root, json, &plan, git, hidden); } - // Line numbers use spans against the *original* contents, so the JSON - // payload is assembled before the rewrites hit the disk. + // Line numbers use spans against the *original* contents. let changed = if json { flow(json, "mv", output::changed_files(&root, &plan))? } else { @@ -160,27 +160,31 @@ fn mv( let applied = flow(json, "mv", apply::apply(&root, &plan, git))?; if json { - json::print(&Envelope::ok( - "mv", - MvData::new(&plan, changed, applied.via_git), - )); + let data = MvData::new(&plan, changed, applied.via_git, hidden); + json::print(&Envelope::ok("mv", data)); } else { println!("{}", output::mv_summary(&plan, applied.via_git)); + output::report_refs(&hidden); } Ok(exit::OK) } /// Dry-run branch: unified diff for humans, structured preview for agents. -fn mv_dry_run(root: &Path, json: bool, plan: &MovePlan, git: apply::GitMode) -> Flow { +fn mv_dry_run( + root: &Path, + json: bool, + plan: &MovePlan, + git: apply::GitMode, + hidden: Vec, +) -> Flow { let diff = flow(json, "mv", apply::render_diff(root, plan))?; let via_git = apply::would_use_git(root, &plan.source, git); if json { - json::print(&Envelope::dry_run( - "mv", - MvDryRunData::new(plan, diff, via_git), - )); + let data = MvDryRunData::new(plan, diff, via_git, hidden); + json::print(&Envelope::dry_run("mv", data)); } else { print!("{diff}"); + output::report_refs(&hidden); } Ok(exit::OK) } @@ -192,7 +196,7 @@ fn mv_dry_run(root: &Path, json: bool, plan: &MovePlan, git: apply::GitMode) -> /// command itself succeeded; agents read `total` or the exit code). fn check(root: &Path, source_root: Option<&Path>, json: bool) -> Flow { let root = flow(json, "check", root.canonicalize().map_err(JmoveError::from))?; - let source_root = flow(json, "check", normalize_scope(&root, source_root))?; + let source_root = flow(json, "check", Index::normalize_scope(&root, source_root))?; let scope = source_root.as_deref(); let index = flow(json, "check", Index::build_scoped(&root, scope))?; let broken = flow(json, "check", output::broken_imports(&root, &index))?; @@ -217,21 +221,6 @@ fn check(root: &Path, source_root: Option<&Path>, json: bool) -> Flow { Ok(code) } -/// Validate the global `--source-root`: project-relative, existing dir. -fn normalize_scope(root: &Path, scope: Option<&Path>) -> JmoveResult> { - let Some(scope) = scope else { - return Ok(None); - }; - let rel = core::rel_from_root(root, scope)?; - if !root.join(&rel).is_dir() { - return Err(JmoveError::InvalidArgument(format!( - "--source-root '{}' is not a directory", - core::rel_str(&rel) - ))); - } - Ok(Some(rel)) -} - /// 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))) diff --git a/src/cli/output/mod.rs b/src/cli/output/mod.rs index a9ad44a..2c22443 100644 --- a/src/cli/output/mod.rs +++ b/src/cli/output/mod.rs @@ -13,7 +13,7 @@ use crate::core::{JmoveResult, rel_str}; mod check; -use super::json::{Change, ChangedFile, ErrorData}; +use super::json::ErrorData; pub use check::{ BrokenImport, CheckData, NameMismatch, broken_imports, name_mismatches, report_check, @@ -24,15 +24,27 @@ pub(crate) 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 +/// Re-exported so `output::line_of` call sites stay stable. +pub use crate::core::line_of; + +/// One rewritten import inside a changed file. +#[derive(Debug, serde::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, serde::Serialize)] +pub struct ChangedFile { + /// Project-relative path of the importer. + pub path: String, + /// Rewritten specifiers, in source order. + pub changes: Vec, } /// Group rewrites by importer file; files in sorted order, rewrites of one @@ -148,6 +160,32 @@ pub fn print_error(message: &str, hint: Option<&str>) { } } +/// Human warning block for non-import references (stderr, after the mv +/// result, so stdout stays clean for scripts). Silent when there are none. +pub fn report_refs(refs: &[crate::core::refs::NonImportRef]) { + if refs.is_empty() { + return; + } + eprintln!( + "warning: {} non-import reference {} may need manual fixing:", + refs.len(), + if refs.len() == 1 { + "to the moved file" + } else { + "s to moved files" + } + ); + for r in refs.iter().take(10) { + eprintln!(" {}:{}", r.file, r.line); + } + if refs.len() > 10 { + eprintln!( + " ... and {} more (see --json non_import_refs)", + refs.len() - 10 + ); + } +} + /// `N noun` with a naive English plural. pub(crate) fn plural(count: usize, noun: &str) -> String { if count == 1 { diff --git a/src/core/index/mod.rs b/src/core/index/mod.rs index 92b3419..01c77c2 100644 --- a/src/core/index/mod.rs +++ b/src/core/index/mod.rs @@ -19,7 +19,7 @@ use std::path::{Path, PathBuf}; use ignore::WalkBuilder; -use crate::core::{JmoveResult, normalize_rel_path}; +use crate::core::{JmoveError, JmoveResult, normalize_rel_path}; use crate::parser::java::JavaClassIndex; use crate::parser::resolve::resolve_module; use crate::parser::{ImportRecord, Language, PackageDecl, SourceLanguage, frontend_for}; @@ -55,6 +55,25 @@ pub struct Index { pub aliases: PathAliases, } +/// Validate the global `--source-root`: project-relative, existing dir. +/// Kept next to [`Index::build_scoped`], its only consumer. +impl Index { + /// Resolve `--source-root` against the project root. + pub fn normalize_scope(root: &Path, scope: Option<&Path>) -> JmoveResult> { + let Some(scope) = scope else { + return Ok(None); + }; + let rel = crate::core::rel_from_root(root, scope)?; + if !root.join(&rel).is_dir() { + return Err(JmoveError::InvalidArgument(format!( + "--source-root '{}' is not a directory", + crate::core::rel_str(&rel) + ))); + } + Ok(Some(rel)) + } +} + impl Index { /// Scan `root`, parse every supported source file and build the graph. /// Unreadable or unparseable files are skipped, not fatal. diff --git a/src/core/mod.rs b/src/core/mod.rs index 7d8279c..0c79130 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -10,6 +10,7 @@ pub mod apply; pub mod fix; pub mod index; pub mod plan; +pub mod refs; use std::ffi::OsStr; use std::io; @@ -123,6 +124,17 @@ pub fn rel_str(path: &Path) -> String { .join("/") } +/// 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 +} + /// Convert a user-supplied path to a normalized project-relative path. /// Relative paths are taken against `root`; absolute ones must live /// underneath it. Shared by CLI commands that accept paths. diff --git a/src/core/refs/mod.rs b/src/core/refs/mod.rs new file mode 100644 index 0000000..4daa320 --- /dev/null +++ b/src/core/refs/mod.rs @@ -0,0 +1,205 @@ +//! Non-import textual references to planned moves: report only. +//! +//! The import graph understands `import`/`export from`/`require` and +//! `import()` — but projects also reference files from markdown links, +//! `package.json` fields, `jest.mock("./x")` strings, tsconfig `files` +//! arrays and friends. Auto-rewriting those would require understanding +//! each format's semantics, so `jmove` never edits them; what it must not +//! do is move a file and stay silent while half a readme keeps pointing at +//! the old path. The scan runs before any write, on the same plan the +//! apply step consumes, and returns every suspicious occurrence with its +//! file and line. +//! +//! Matching is boundary-checked substring search over plain tokens (the +//! moved path, its extension-less module form, the exact specifier strings +//! importers used, and the directory prefix for batch moves). Tokens are +//! deliberately conservative: `lib/sum` matches `./lib/sum.ts` but not +//! `lib/summary`; occurrences inside the plan's own rewrite spans are the +//! import statements and are excluded. + +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use crate::core::index::Index; +use crate::core::plan::MovePlan; +use crate::core::{line_of, normalize_rel_path, rel_str}; + +/// One non-import occurrence of a moved path or specifier. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct NonImportRef { + /// Project-relative file containing the reference. + pub file: String, + /// 1-based line of the occurrence. + pub line: usize, + /// The matched token. + pub token: String, + /// Category of the token: `path`, `module`, `specifier` or `dir`. + /// Patterns are ranked in this order; one entry is kept per line. + pub kind: &'static str, + /// The matched line, trimmed and capped (context for humans/agents). + pub text: String, +} + +/// Text formats beyond source code that may name project files. +const TEXT_EXTS: &[&str] = &[ + "md", "mdx", "json", "json5", "yaml", "yml", "html", "htm", "xml", "txt", "pro", "gradle", +]; + +/// Lockfiles never reference project-local paths and are huge: skipped. +const LOCKFILES: &[&str] = &["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]; + +const MAX_TEXT_BYTES: u64 = 512 * 1024; + +/// Scan the project for references to `plan`'s moves outside the import +/// statements the plan already rewrites. Infallible by design: unreadable +/// files are skipped (the scanner already ignored what it could not read). +pub fn scan(root: &Path, index: &Index, plan: &MovePlan) -> Vec { + let patterns = patterns(root, plan); + if patterns.is_empty() { + return Vec::new(); + } + let mut refs = Vec::new(); + for file in text_files(root, index) { + let Ok(source) = std::fs::read_to_string(root.join(&file)) else { + continue; // binary or vanished between walk and read + }; + let skip = skip_spans(plan, &file); + for (token, kind) in &patterns { + for at in token_occurrences(&source, token) { + if skip.iter().any(|s| s.contains(&at)) { + continue; // the import statements themselves + } + refs.push(NonImportRef { + file: rel_str(&file), + line: line_of(&source, at), + token: token.clone(), + kind, + text: excerpt(&source, at), + }); + } + } + } + // Stable sort keeps the first-inserted (highest-ranked) token per line: + // one warning per reference site, not one per matching pattern. + refs.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line))); + refs.dedup_by(|a, b| a.file == b.file && a.line == b.line); + refs +} + +// The search tokens of a plan. `source_root` scoping needs no special +// treatment: every token is already project-relative. +fn patterns(root: &Path, plan: &MovePlan) -> Vec<(String, &'static str)> { + let mut out: Vec<(String, &'static str)> = Vec::new(); + let mut push = |token: String, kind: &'static str| { + if token.len() > 1 && !out.iter().any(|(t, _)| t == &token) { + out.push((token, kind)); + } + }; + for m in &plan.moves { + let path = rel_str(&m.source); + push(path.clone(), "path"); + if let Some((stem, _)) = path.rsplit_once('.') { + push(stem.to_owned(), "module"); + } + } + for rewrite in &plan.rewrites { + push(rewrite.old_text.clone(), "specifier"); + } + if plan.moves.len() > 1 && root.join(&plan.source).is_dir() { + push(format!("{}/", rel_str(&plan.source)), "dir"); + } + out +} + +// Files whose rewrite the plan already performs legitimately: their spans. +fn skip_spans(plan: &MovePlan, file: &Path) -> Vec> { + plan.rewrites + .iter() + .filter(|r| r.file == file) + .map(|r| r.span.clone()) + .collect() +} + +/// Code files from the index plus non-hidden text files on disk. +fn text_files(root: &Path, index: &Index) -> Vec { + let mut out = index.files.sorted(); + let walker = ignore::WalkBuilder::new(root).require_git(false).build(); + for entry in walker.flatten() { + let path = entry.path(); + if entry.path_is_symlink() || !entry.file_type().is_some_and(|t| t.is_file()) { + continue; + } + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + if LOCKFILES.contains(&name) { + continue; + } + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if !TEXT_EXTS.contains(&ext.as_str()) { + continue; + } + let Ok(stripped) = path.strip_prefix(root) else { + continue; + }; + let Some(rel) = normalize_rel_path(stripped) else { + continue; + }; + let hidden = rel + .components() + .any(|c| c.as_os_str().to_string_lossy().starts_with('.')); + let small = path + .metadata() + .map(|m| m.len() <= MAX_TEXT_BYTES) + .unwrap_or(false); + if !hidden && small && !out.contains(&rel) { + out.push(rel); + } + } + out.sort(); + out +} + +/// Every offset where `token` occurs with a word-free byte before it and +/// no word/joiner byte after: `lib/sum` matches `./lib/sum.ts` but never +/// `lib/summary` or `lib/sum-2`. +fn token_occurrences(source: &str, token: &str) -> Vec { + let bytes = source.as_bytes(); + let mut hits = Vec::new(); + let mut from = 0; + while let Some(found) = source[from..].find(token) { + let at = from + found; + let end = at + token.len(); + let before_ok = at == 0 || !is_word_byte(bytes[at - 1]); + let after_ok = !bytes + .get(end) + .is_some_and(|c| is_word_byte(*c) || matches!(c, b'-' | b'_')); + if before_ok && after_ok { + hits.push(at); + } + from = end; + } + hits +} + +fn is_word_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b >= 0x80 +} + +/// The matched line, trimmed, with a hard cap. +fn excerpt(source: &str, at: usize) -> String { + let line_start = source[..at].rfind('\n').map_or(0, |i| i + 1); + let line_end = source[at..].find('\n').map_or(source.len(), |i| at + i); + let line = source[line_start..line_end].trim(); + let cut = line.char_indices().nth(100).map_or(line.len(), |(i, _)| i); + line[..cut].to_owned() +} + +#[cfg(test)] +mod tests; diff --git a/src/core/refs/tests.rs b/src/core/refs/tests.rs new file mode 100644 index 0000000..5dbe580 --- /dev/null +++ b/src/core/refs/tests.rs @@ -0,0 +1,123 @@ +//! Scanner tests: what may be referenced, what must never match. + +use std::path::Path; + +use crate::core::JmoveResult; +use crate::core::index::Index; +use crate::core::plan::plan_move; +use tempfile::TempDir; + +use super::{NonImportRef, scan, token_occurrences}; + +fn fixture() -> JmoveResult { + let dir = TempDir::new()?; + let root = dir.path(); + let write = |rel: &str, body: &str| -> JmoveResult<()> { + fs_create(rel, body, root)?; + Ok(()) + }; + write("lib/sum.ts", "export const sum = 3;\n")?; + write("lib/summary.ts", "export const summary = 's';\n")?; + write("app.ts", "import { sum } from './lib/sum';\n")?; + write( + "README.md", + "Use [sum](./lib/sum.ts) via `lib/sum`.\nSee also lib/summary for text.\nLayout note: lib/ holds helpers.\n", + )?; + write( + "package.json", + "{\"name\": \"refs\", \"main\": \"./lib/sum.ts\"}\n", + )?; + write("__tests__/sum.test.ts", "jest.mock('../lib/sum');\n")?; + write("package-lock.json", "{\"x\": \"./lib/sum\"}\n")?; + write(".notes/refs.md", "stale: ./lib/sum\n")?; + Ok(dir) +} + +fn fs_create(rel: &str, body: &str, root: &Path) -> std::io::Result<()> { + let p = root.join(rel); + std::fs::create_dir_all(p.parent().unwrap())?; + std::fs::write(p, body) +} + +fn files_of(refs: &[NonImportRef]) -> Vec { + refs.iter().map(|r| r.file.clone()).collect() +} + +fn scan_move(src: &str, dst: &str) -> JmoveResult<(TempDir, Vec)> { + let dir = fixture()?; + let index = Index::build(dir.path())?; + let plan = plan_move(&index, Path::new(src), Path::new(dst))?; + let refs = scan(dir.path(), &index, &plan); + Ok((dir, refs)) +} + +#[test] +fn doc_config_and_mock_references_are_reported() -> JmoveResult<()> { + let (_dir, refs) = scan_move("lib/sum.ts", "lib/total.ts")?; + let files = files_of(&refs); + for expected in ["README.md", "package.json", "__tests__/sum.test.ts"] { + assert!( + files.contains(&expected.to_string()), + "{expected} missing: {refs:?}" + ); + } + Ok(()) +} + +#[test] +fn the_import_statement_itself_is_never_reported() -> JmoveResult<()> { + let (_dir, refs) = scan_move("lib/sum.ts", "lib/total.ts")?; + assert!(!files_of(&refs).contains(&"app.ts".to_string()), "{refs:?}"); + assert!(!files_of(&refs).contains(&"lib/sum.ts".to_string())); + Ok(()) +} + +#[test] +fn decoys_lockfiles_and_hidden_dirs_are_not_reported() -> JmoveResult<()> { + let (_dir, refs) = scan_move("lib/sum.ts", "lib/total.ts")?; + let files = files_of(&refs); + for absent in ["lib/summary.ts", "package-lock.json", ".notes/refs.md"] { + assert!( + !files.contains(&absent.to_string()), + "{absent} leaked: {refs:?}" + ); + } + // README line 2 mentions only `lib/summary`: no ref may point there. + assert!( + refs.iter().all(|r| r.file != "README.md" || r.line != 2), + "{refs:?}" + ); + Ok(()) +} + +#[test] +fn one_entry_per_line_keeps_the_highest_ranked_token() -> JmoveResult<()> { + let (_dir, refs) = scan_move("lib/sum.ts", "lib/total.ts")?; + let readme = refs.iter().filter(|r| r.file == "README.md").count(); + // Line 1 (three matching tokens) and line 3 (`lib/` dir note is only a + // file-move token set away) collapse to one entry per line. + assert_eq!(readme, 1, "{refs:?}"); + assert_eq!(refs.iter().find(|r| r.file == "README.md").unwrap().line, 1); + Ok(()) +} + +#[test] +fn dir_move_reports_the_directory_prefix() -> JmoveResult<()> { + let (_dir, refs) = scan_move("lib", "pkg/lib")?; + assert!( + refs.iter() + .any(|r| r.file == "README.md" && r.line == 3 && r.kind == "dir"), + "{refs:?}" + ); + Ok(()) +} + +#[test] +fn boundary_rules_reject_longer_joined_and_prefixed_names() { + let none: Vec = Vec::new(); + assert_eq!(token_occurrences("lib/summary", "lib/sum"), none); + assert_eq!(token_occurrences("lib/sum-2", "lib/sum"), none); + assert_eq!(token_occurrences("mylib/sum", "lib/sum"), none); + assert_eq!(token_occurrences("./lib/sum.ts", "lib/sum"), vec![2]); + assert_eq!(token_occurrences("x/lib/sum", "lib/sum"), vec![2]); +} diff --git a/tests/cli_refs.rs b/tests/cli_refs.rs new file mode 100644 index 0000000..1493bc3 --- /dev/null +++ b/tests/cli_refs.rs @@ -0,0 +1,68 @@ +//! `mv` warns (never edits) about non-import references: markdown links, +//! `package.json` fields and `jest.mock` strings survive silent unless the +//! user sees them. JSON payload carries the same list for agents. + +mod common; + +use common::{copy_fixture, in_root, jmove, read}; +use predicates::prelude::*; + +fn dry_run_refs() -> String { + let tmp = copy_fixture("typescript", "refs"); + let out = jmove( + &tmp, + &["mv", "lib/sum.ts", "lib/total.ts", "--dry-run", "--json"], + ) + .success() + .stdout(predicate::str::contains("\"non_import_refs\"")) + .get_output() + .stdout + .clone(); + String::from_utf8(out).expect("json is utf-8") +} + +#[test] +fn json_dry_run_lists_hidden_references_and_skips_the_noise() { + let out = dry_run_refs(); + for expected in [ + "\"file\": \"README.md\"", + "\"file\": \"package.json\"", + "\"file\": \"__tests__/sum.test.ts\"", + ] { + assert!(out.contains(expected), "missing {expected} in {out}"); + } + for absent in [ + "\"file\": \"app.ts\"", + "\"file\": \"package-lock.json\"", + "\"file\": \".notes/refs.md\"", + "\"file\": \"lib/summary.ts\"", + ] { + assert!(!out.contains(absent), "leaked {absent} in {out}"); + } +} + +#[test] +fn human_dry_run_warns_on_stderr_and_exits_zero() { + let tmp = copy_fixture("typescript", "refs"); + jmove(&tmp, &["mv", "lib/sum.ts", "lib/total.ts", "--dry-run"]) + .success() + .stderr( + predicate::str::contains("non-import reference") + .and(predicate::str::contains("README.md:1")) + .and(predicate::str::contains("__tests__/sum.test.ts:1")), + ); +} + +#[test] +fn real_move_still_succeeds_and_still_warns() { + let tmp = copy_fixture("typescript", "refs"); + jmove(&tmp, &["mv", "lib/sum.ts", "lib/total.ts"]) + .success() + .stderr(predicate::str::contains("may need manual fixing")); + assert!(in_root(tmp.path(), "lib/total.ts").exists()); + let app = read(&in_root(tmp.path(), "app.ts")); + assert!(app.contains("from './lib/total'"), "{app}"); + // The scanner warns; only the import graph is rewritten. + let readme = read(&in_root(tmp.path(), "README.md")); + assert!(readme.contains("./lib/sum.ts"), "{readme}"); +} diff --git a/tests/typescript/refs/.notes/refs.md b/tests/typescript/refs/.notes/refs.md new file mode 100644 index 0000000..6289495 --- /dev/null +++ b/tests/typescript/refs/.notes/refs.md @@ -0,0 +1 @@ +stale ref: ./lib/sum diff --git a/tests/typescript/refs/README.md b/tests/typescript/refs/README.md new file mode 100644 index 0000000..afa69e5 --- /dev/null +++ b/tests/typescript/refs/README.md @@ -0,0 +1,3 @@ +Use [sum](./lib/sum.ts) via `lib/sum`. +See also lib/summary for text. +Layout note: lib/ holds helpers. diff --git a/tests/typescript/refs/__tests__/sum.test.ts b/tests/typescript/refs/__tests__/sum.test.ts new file mode 100644 index 0000000..bf03682 --- /dev/null +++ b/tests/typescript/refs/__tests__/sum.test.ts @@ -0,0 +1 @@ +jest.mock('../lib/sum'); diff --git a/tests/typescript/refs/app.ts b/tests/typescript/refs/app.ts new file mode 100644 index 0000000..e28a9fa --- /dev/null +++ b/tests/typescript/refs/app.ts @@ -0,0 +1,2 @@ +import { sum } from './lib/sum'; +export const x = sum(1, 2); diff --git a/tests/typescript/refs/lib/sum.ts b/tests/typescript/refs/lib/sum.ts new file mode 100644 index 0000000..8073d27 --- /dev/null +++ b/tests/typescript/refs/lib/sum.ts @@ -0,0 +1 @@ +export const sum = (a: number, b: number): number => a + b; diff --git a/tests/typescript/refs/lib/summary.ts b/tests/typescript/refs/lib/summary.ts new file mode 100644 index 0000000..fc920fc --- /dev/null +++ b/tests/typescript/refs/lib/summary.ts @@ -0,0 +1 @@ +export const summary = 'text'; diff --git a/tests/typescript/refs/package-lock.json b/tests/typescript/refs/package-lock.json new file mode 100644 index 0000000..8c799c5 --- /dev/null +++ b/tests/typescript/refs/package-lock.json @@ -0,0 +1 @@ +{ "packages": { "x": "./lib/sum" } } diff --git a/tests/typescript/refs/package.json b/tests/typescript/refs/package.json new file mode 100644 index 0000000..21ec30c --- /dev/null +++ b/tests/typescript/refs/package.json @@ -0,0 +1 @@ +{ "name": "refs-fixture", "main": "./lib/sum.ts" } diff --git a/todo.md b/todo.md index 580817c..1a8d707 100644 --- a/todo.md +++ b/todo.md @@ -96,7 +96,9 @@ AI оставляем СНАРУЖИ: при неоднозначности jmov - [ ] Параллельная индексация через rayon - [x] --git интеграция (git mv для stage/истории): auto для tracked файлов, --no-git флаг, moved_via/would_move_via в --json - [x] Перенос директорий целиком (mv папки): зеркальный batch-move всех индексируемых файлов, merged rewrites, prune пустых исходных каталогов, left_behind для неиндексируемых -- [ ] Предупреждения о не-import ссылках: package.json exports, jest mocks, tsconfig includes, markdown links +- [x] Предупреждения о не-import ссылках: скан text/md/json/yaml/html + строк в коде на path/module/specifier/dir токены (с word-границами); + никогда не правит, только stderr + non_import_refs[] в --json; lockfiles/hidden/>512KiB пропускаются. + Ограничение v1: ссылки из чужих директорий в своей относительной форме ('./sum' из __tests__/ при переносе 'lib/sum') не ловятся - [ ] prettier интеграция после rewrite (по желанию) ## Phase 3