chore: project skeleton — module contracts, docs plan, GPLv3

This commit is contained in:
loki5512344 2026-09-09 21:43:42 +02:00
commit a33ed2eb30
Signed by: boba
GPG key ID: 253067914055423B
20 changed files with 2220 additions and 0 deletions

5
src/cache/mod.rs vendored Normal file
View file

@ -0,0 +1,5 @@
//! On-disk index cache.
//!
//! Intentionally empty in Phase 1: the project is rescanned on every run.
//! Phase 2 will persist the import graph under `.jmove/` and support
//! incremental reindexing (see `todo.md`).

36
src/cli/json.rs Normal file
View file

@ -0,0 +1,36 @@
//! JSON output shapes shared by all `--json` commands.
//!
//! 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`).
use serde::Serialize;
/// Top-level envelope for every `--json` response.
#[derive(Debug, Serialize)]
pub struct Envelope<T: Serialize> {
/// One of `"ok"`, `"dry_run"`, `"error"`.
pub status: &'static str,
/// The operation that produced this response, e.g. `"mv"`, `"check"`.
pub operation: &'static str,
/// Command-specific payload.
#[serde(flatten)]
pub data: T,
}
/// Error payload: stable `code`, human `message`, actionable `hint`.
#[derive(Debug, Serialize)]
pub struct ErrorData {
/// Machine-readable code, e.g. `TARGET_EXISTS`, `SOURCE_NOT_FOUND`.
pub code: String,
/// Human-readable explanation.
pub message: String,
/// What the caller should do next (never null in output; omit if none).
pub hint: Option<String>,
}
/// 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)")
}

96
src/cli/mod.rs Normal file
View file

@ -0,0 +1,96 @@
//! Command line interface: argument parsing, dispatch and exit codes.
//!
//! Exit codes (mirrored in `docs/SKILL.md`):
//! `0` success · `1` operation error · `2` broken imports found.
pub mod json;
use std::path::PathBuf;
use clap::{Parser, Subcommand};
/// jmove — move source files, keep every import intact.
#[derive(Debug, Parser)]
#[command(name = "jmove", version, about, long_about = None)]
pub struct Args {
/// Subcommand to execute.
#[command(subcommand)]
pub command: Command,
/// Project root (defaults to the current directory).
#[arg(long, global = true, default_value = ".")]
pub root: PathBuf,
}
/// Available subcommands (MVP: `mv`, `check`).
#[derive(Debug, Subcommand)]
pub enum Command {
/// Move a file and rewrite all imports referencing it.
Mv {
/// File being moved (project-relative or inside the root).
source: PathBuf,
/// Destination path.
target: PathBuf,
/// Preview changes without touching the disk.
#[arg(long)]
dry_run: bool,
/// 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 {
/// Machine-readable JSON output.
#[arg(long)]
json: bool,
},
}
/// Process exit codes documented for humans and agents alike.
pub mod exit {
/// Operation completed successfully.
pub const OK: i32 = 0;
/// Invalid usage, rejected plan or IO failure.
pub const ERROR: i32 = 1;
/// `check` found at least one broken import.
pub const BROKEN: i32 = 2;
}
/// 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 {
Command::Mv {
source,
target,
dry_run,
json,
force,
} => mv(&args.root, &source, &target, dry_run, json, force),
Command::Check { json } => check(&args.root, json),
}
}
/// `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")
}
/// `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")
}
use std::path::Path;

35
src/core/apply.rs Normal file
View file

@ -0,0 +1,35 @@
//! 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`")
}

79
src/core/index.rs Normal file
View file

@ -0,0 +1,79 @@
//! Project indexing: gitignore-aware filesystem scan plus the import graph.
//!
//! Built fresh on every command (disk cache is Phase 2). `ignore::WalkBuilder`
//! handles `.gitignore`/hidden-file rules; every indexed TS/JS source file is
//! parsed through [`crate::parser`] and its specifiers resolved through
//! [`crate::parser::resolve`].
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::core::JmoveResult;
use crate::parser::ImportRecord;
/// Indexed source files with O(1) membership lookups.
#[derive(Debug, Default)]
pub struct FileSet {
paths: HashSet<PathBuf>,
}
impl FileSet {
/// Add a normalized project-relative path; `false` if already present.
pub fn add(&mut self, path: PathBuf) -> bool {
self.paths.insert(path)
}
/// Whether `path` is a known indexed source file.
#[must_use]
pub fn contains(&self, path: &Path) -> bool {
self.paths.contains(path)
}
/// All files in deterministic sorted order (stable for tests and diffs).
#[must_use]
pub fn sorted(&self) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = self.paths.iter().cloned().collect();
v.sort();
v
}
}
/// One import occurrence plus the project file it resolves to.
/// `target: None` means "external" — a bare package specifier or a path
/// that does not exist in the index.
#[derive(Debug, Clone)]
pub struct ResolvedImport {
/// Raw record from the parser (specifier text + byte span).
pub record: ImportRecord,
/// Project-relative resolved file, if any.
pub target: Option<PathBuf>,
}
/// Full in-memory project index: file set and forward import edges.
#[derive(Debug, Default)]
pub struct Index {
/// Absolute project root the index was built for.
pub root: PathBuf,
/// All indexed source files.
pub files: FileSet,
/// For each file, the imports it declares (in source order).
pub imports: HashMap<PathBuf, Vec<ResolvedImport>>,
}
impl Index {
/// Scan `root`, parse every supported source file and build the graph.
/// 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"
)
}
/// 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")
}
}

103
src/core/mod.rs Normal file
View file

@ -0,0 +1,103 @@
//! Core engine: project indexing, dependency graph, move planning and
//! atomic apply with rollback.
//!
//! Path convention used across the crate: every `PathBuf` produced by
//! `jmove` is **project-root-relative, normalized** (no `.`/`..` segments).
//! Use [`normalize_rel_path`] to canonicalize paths coming from users or
//! from OS walking.
pub mod apply;
pub mod index;
pub mod plan;
use std::ffi::OsStr;
use std::io;
use std::path::{Component, Path, PathBuf};
use thiserror::Error;
/// Crate-wide error type surfaced to the CLI layer.
#[derive(Debug, Error)]
pub enum JmoveError {
/// Filesystem or IO failure.
#[error("io error: {0}")]
Io(#[from] io::Error),
/// The user supplied a path that is invalid for the requested operation.
#[error("invalid argument: {0}")]
InvalidArgument(String),
/// Project index is stale (a file vanished or was moved externally).
#[error("index is stale: {0}")]
StaleIndex(String),
/// The planned move cannot be applied safely.
#[error("plan rejected: {0}")]
PlanRejected(String),
}
/// Result alias used throughout the crate.
pub type JmoveResult<T> = Result<T, JmoveError>;
/// Normalize a project-relative path: strip `.` segments, collapse `..`
/// where possible and reject paths that escape the project root.
/// Returns `None` if the result would be empty, absolute or above the root.
///
/// # Examples
///
/// ```
/// use std::path::Path;
/// use jmove::core::normalize_rel_path;
///
/// assert_eq!(
/// normalize_rel_path(Path::new("./src/../utils/foo.ts")),
/// Some(PathBuf::from("utils/foo.ts"))
/// );
/// assert_eq!(normalize_rel_path(Path::new("../outside")), None);
/// ```
#[must_use]
pub fn normalize_rel_path(path: &Path) -> Option<PathBuf> {
let mut stack: Vec<&OsStr> = Vec::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if stack.pop().is_none() {
return None; // would escape the project root
}
}
Component::Normal(piece) => stack.push(piece),
// Absolute paths and Windows prefixes are not project-relative.
Component::RootDir | Component::Prefix(_) => return None,
}
}
(!stack.is_empty()).then(|| stack.iter().collect::<PathBuf>())
}
#[cfg(test)]
mod tests {
use super::normalize_rel_path;
use std::path::{Path, PathBuf};
#[test]
fn normalizes_dots_and_parent_dirs() {
assert_eq!(
normalize_rel_path(Path::new("./src/../utils/foo.ts")),
Some(PathBuf::from("utils/foo.ts"))
);
assert_eq!(
normalize_rel_path(Path::new("a/b/c/../../d.ts")),
Some(PathBuf::from("a/d.ts"))
);
}
#[test]
fn rejects_escaping_and_empty_paths() {
assert_eq!(normalize_rel_path(Path::new("../outside")), None);
assert_eq!(normalize_rel_path(Path::new("a/../../outside")), None);
assert_eq!(normalize_rel_path(Path::new("")), None);
assert_eq!(normalize_rel_path(Path::new("./")), None);
}
#[test]
fn rejects_absolute_paths() {
assert_eq!(normalize_rel_path(Path::new("/etc/passwd")), None);
}
}

46
src/core/plan.rs Normal file
View file

@ -0,0 +1,46 @@
//! 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")
}

17
src/lib.rs Normal file
View file

@ -0,0 +1,17 @@
//! `jmove` — a project-aware file mover for TypeScript/JavaScript.
//!
//! Moving a file inside a project invalidates every relative import that
//! points at it. `jmove` indexes the project's import graph, computes the
//! minimal set of specifier rewrites, and applies everything atomically
//! (with rollback), optionally in dry-run mode.
//!
//! Module map:
//! - [`cli`] — argument parsing, command dispatch, user-facing output.
//! - [`core`] — indexing, dependency graph, move planning, atomic apply.
//! - [`parser`] — language frontends (import extraction, path resolution).
//! - [`cache`] — on-disk index cache (Phase 2, intentionally empty for now).
pub mod cache;
pub mod cli;
pub mod core;
pub mod parser;

19
src/main.rs Normal file
View file

@ -0,0 +1,19 @@
//! Binary entry point for `jmove`.
//!
//! All logic lives in the library crate; this main only maps the outcome of
//! [`jmove::cli::run`] onto process exit codes:
//! - `0` — success,
//! - `1` — user-facing plan/validation error (already printed),
//! - `2` — unexpected failure.
/// Parses arguments, runs the selected command and converts failures into a
/// non-zero exit code, printing a one-line message for the user.
fn main() {
match jmove::cli::run() {
Ok(code) => std::process::exit(code),
Err(err) => {
eprintln!("jmove: {err}");
std::process::exit(2);
}
}
}

83
src/parser/mod.rs Normal file
View file

@ -0,0 +1,83 @@
//! Language frontends: import extraction and module specifier resolution.
//!
//! ## Contract (stable across submodules — implementers must not change it)
//!
//! - A [`Language`] parses one source file into [`ImportRecord`]s: every
//! *static-ish* module reference (TS `import`/`export from`/`require`/
//! dynamic `import()`).
//! - [`crate::core::parser_support::resolve`] turns a specifier into a
//! project-relative file path using a resolver aware of the indexed file
//! set. Non-project (package/bare) specifiers resolve to `None`.
//! - Rewrites must touch **only the specifier string**, never the rest of
//! the statement (KISS + no formatter dependency): that is why
//! [`ImportRecord::span`] is a byte range into the original source.
use std::path::Path;
pub mod resolve;
pub mod ts;
/// Source languages `jmove` understands (Phase 1: TypeScript/JavaScript).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SourceLanguage {
/// `.ts` (non-TSX) sources.
TypeScript,
/// `.tsx` / `.jsx` sources.
Tsx,
/// Plain `.js` / `.mjs` / `.cjs` sources.
JavaScript,
}
impl SourceLanguage {
/// Map a file extension (lowercase, no dot) to a language, if supported.
#[must_use]
pub fn from_extension(ext: &str) -> Option<Self> {
match ext {
"ts" | "mts" | "cts" => Some(Self::TypeScript),
"tsx" | "jsx" => Some(Self::Tsx),
"js" | "mjs" | "cjs" => Some(Self::JavaScript),
_ => None,
}
}
/// Detect the language from a file name. `None` means "not a source file
/// we index" (skip it).
#[must_use]
pub fn for_path(path: &Path) -> Option<Self> {
Self::from_extension(
path.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()?,
)
}
}
/// One module reference found in a source file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportRecord {
/// Raw specifier text as written, e.g. `"../utils/format"`.
pub specifier: String,
/// Byte range of the *specifier string contents* (inside the quotes,
/// without the quote characters) in the parsed file. The rewriter
/// replaces exactly this span and nothing else.
pub span: std::ops::Range<usize>,
/// `true` for dynamic `import("...")` / `require("...")` occurrences.
pub is_dynamic: bool,
}
/// A language frontend that extracts imports from source text.
pub trait Language: Send + Sync {
/// The language this frontend handles.
fn language(&self) -> SourceLanguage;
/// Extract all import records from `source` in byte-offset order.
/// Parse errors must not be fatal: return what was understood.
fn extract_imports(&self, source: &str) -> Vec<ImportRecord>;
}
/// Build the default frontend for `lang`.
#[must_use]
pub fn frontend_for(lang: SourceLanguage) -> Box<dyn Language> {
Box::new(ts::TreeSitterTs::new(lang))
}

26
src/parser/resolve.rs Normal file
View file

@ -0,0 +1,26 @@
//! Module specifier resolution for TS/JS projects.
//!
//! CONTRACT: see [`crate::parser`]. Given a *relative* specifier and the
//! importing file, find which indexed project file it refers to. Bare /
//! package specifiers (not starting with `.`) are out of project scope and
//! resolve to `None`.
use std::path::{Path, PathBuf};
use crate::core::index::FileSet;
/// Resolve `specifier` (e.g. `"../utils/fmt"`) written in the file at
/// `importer` (project-relative), against the indexed `files`.
///
/// Resolution order for extensionless specifiers (Node/TS classic):
/// 1. exact path if indexed (e.g. `"./a.ts"`),
/// 2. `<base>` + each supported extension (`.ts`, `.tsx`, `.js`, `.jsx`,
/// `.mjs`, `.cjs` — declaration files only when nothing else matches),
/// 3. `<base>/index.<ext>`.
///
/// Returns `None` for bare specifiers or unresolvable paths.
#[must_use]
pub fn resolve_module(importer: &Path, specifier: &str, files: &FileSet) -> Option<PathBuf> {
let _ = (importer, specifier, files);
todo!("parser agent: implement resolver")
}

29
src/parser/ts.rs Normal file
View file

@ -0,0 +1,29 @@
//! Tree-sitter based frontend for TypeScript/JavaScript.
//!
//! CONTRACT: see [`crate::parser`]. Implement `extract_imports` using
//! `tree-sitter-typescript` grammars.
use super::{ImportRecord, Language, SourceLanguage};
/// Frontend backed by the tree-sitter TypeScript/TSX/JS grammar.
pub struct TreeSitterTs {
lang: SourceLanguage,
}
impl TreeSitterTs {
/// Create a frontend for the given language variant.
#[must_use]
pub fn new(lang: SourceLanguage) -> Self {
Self { lang }
}
}
impl Language for TreeSitterTs {
fn language(&self) -> SourceLanguage {
self.lang
}
fn extract_imports(&self, _source: &str) -> Vec<ImportRecord> {
todo!("parser agent: implement tree-sitter extraction")
}
}