feat: warn about non-import references to moved files
The import graph only rewrites import/export-from/require. Files are also named from markdown links, package.json fields, jest.mock strings and tsconfig file lists, and a move that dangles those must not pass silently. mv now scans text formats plus source strings for the moved paths, module names and old specifier forms (word-boundary matched, lockfiles/hidden dirs skipped) and reports each occurrence once per line on stderr and as non_import_refs[] in --json (dry-run included). References are never edited; exit codes are unchanged. Also moves normalize_scope into Index (its only consumer) and the Change/ChangedFile JSON structs into output where they are built, to stay inside the 250-line-per-file budget.
This commit is contained in:
parent
b1af375763
commit
1ddf79b508
21 changed files with 557 additions and 81 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ pub fn fix(
|
|||
json: bool,
|
||||
) -> Flow<i32> {
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Change>,
|
||||
}
|
||||
|
||||
/// 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<NonImportRef>,
|
||||
/// Directory moves only: each `(from, to)` relocation (omitted for file moves).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub moved_files: Vec<FileMoveData>,
|
||||
|
|
@ -180,7 +165,12 @@ fn dir_moves(plan: &MovePlan) -> Vec<FileMoveData> {
|
|||
impl MvData {
|
||||
/// Assemble the payload from an applied plan and its change details.
|
||||
#[must_use]
|
||||
pub fn new(plan: &MovePlan, changed_files: Vec<ChangedFile>, via_git: bool) -> Self {
|
||||
pub fn new(
|
||||
plan: &MovePlan,
|
||||
changed_files: Vec<ChangedFile>,
|
||||
via_git: bool,
|
||||
non_import_refs: Vec<NonImportRef>,
|
||||
) -> 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<NonImportRef>,
|
||||
/// Directory moves only: every relocation that would happen.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub would_move_files: Vec<FileMoveData>,
|
||||
|
|
@ -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<NonImportRef>,
|
||||
) -> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<i32> {
|
|||
&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<i32> {
|
||||
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<i32> {
|
||||
fn mv_dry_run(
|
||||
root: &Path,
|
||||
json: bool,
|
||||
plan: &MovePlan,
|
||||
git: apply::GitMode,
|
||||
hidden: Vec<refs::NonImportRef>,
|
||||
) -> Flow<i32> {
|
||||
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<i32> {
|
||||
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<i32> {
|
|||
Ok(code)
|
||||
}
|
||||
|
||||
/// Validate the global `--source-root`: project-relative, existing dir.
|
||||
fn normalize_scope(root: &Path, scope: Option<&Path>) -> JmoveResult<Option<PathBuf>> {
|
||||
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<T>(json: bool, operation: &'static str, result: JmoveResult<T>) -> Flow<T> {
|
||||
result.map_err(|err| fail(json, operation, ErrorData::from_core(&err)))
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
/// 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<Change>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
|
|
|||
|
|
@ -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<Option<PathBuf>> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
205
src/core/refs/mod.rs
Normal file
205
src/core/refs/mod.rs
Normal file
|
|
@ -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<NonImportRef> {
|
||||
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<std::ops::Range<usize>> {
|
||||
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<PathBuf> {
|
||||
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<usize> {
|
||||
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;
|
||||
123
src/core/refs/tests.rs
Normal file
123
src/core/refs/tests.rs
Normal file
|
|
@ -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<TempDir> {
|
||||
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<String> {
|
||||
refs.iter().map(|r| r.file.clone()).collect()
|
||||
}
|
||||
|
||||
fn scan_move(src: &str, dst: &str) -> JmoveResult<(TempDir, Vec<NonImportRef>)> {
|
||||
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<usize> = 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]);
|
||||
}
|
||||
68
tests/cli_refs.rs
Normal file
68
tests/cli_refs.rs
Normal file
|
|
@ -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}");
|
||||
}
|
||||
1
tests/typescript/refs/.notes/refs.md
Normal file
1
tests/typescript/refs/.notes/refs.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
stale ref: ./lib/sum
|
||||
3
tests/typescript/refs/README.md
Normal file
3
tests/typescript/refs/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Use [sum](./lib/sum.ts) via `lib/sum`.
|
||||
See also lib/summary for text.
|
||||
Layout note: lib/ holds helpers.
|
||||
1
tests/typescript/refs/__tests__/sum.test.ts
Normal file
1
tests/typescript/refs/__tests__/sum.test.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
jest.mock('../lib/sum');
|
||||
2
tests/typescript/refs/app.ts
Normal file
2
tests/typescript/refs/app.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { sum } from './lib/sum';
|
||||
export const x = sum(1, 2);
|
||||
1
tests/typescript/refs/lib/sum.ts
Normal file
1
tests/typescript/refs/lib/sum.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const sum = (a: number, b: number): number => a + b;
|
||||
1
tests/typescript/refs/lib/summary.ts
Normal file
1
tests/typescript/refs/lib/summary.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const summary = 'text';
|
||||
1
tests/typescript/refs/package-lock.json
generated
Normal file
1
tests/typescript/refs/package-lock.json
generated
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "packages": { "x": "./lib/sum" } }
|
||||
1
tests/typescript/refs/package.json
Normal file
1
tests/typescript/refs/package.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "name": "refs-fixture", "main": "./lib/sum.ts" }
|
||||
4
todo.md
4
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue