roto-rooter 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +29 -9
  2. package/dist/cli.js +4 -4
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@ npm install -g roto-rooter
11
11
  ## Usage
12
12
 
13
13
  ```bash
14
- # Check all files
14
+ # Check all files in current directory
15
15
  rr
16
16
 
17
17
  # Check specific file(s)
@@ -23,23 +23,39 @@ rr --check links,forms
23
23
  # Output as JSON
24
24
  rr --format json
25
25
 
26
- # Set app directory
27
- rr --app ./my-app
26
+ # Set project root (the directory containing the app/ folder)
27
+ rr --root ./my-app
28
+
29
+ # Automatically fix issues where possible
30
+ rr --fix
31
+
32
+ # Preview fixes without applying
33
+ rr --dry-run
34
+
35
+ # Fix specific file(s)
36
+ rr --fix app/routes/dashboard.tsx
28
37
  ```
29
38
 
30
39
  ## Checks
31
40
 
32
- - **links**: Validates Link, redirect(), and navigate() targets exist as defined routes
33
- - **forms**: Validates forms submit to routes with action exports, and that form fields match what the action reads via formData.get()
34
- - **loader**: Validates useLoaderData() is only used in routes with loaders
35
- - **params**: Validates useParams() accesses only params defined in the route
36
- - **hydration**: Detects SSR hydration mismatch risks (dates, locale formatting, random values, browser APIs)
41
+ - **links**: Validates `<Link>`, `redirect()`, and `navigate()` targets exist as defined routes. Suggests closest matching route when a typo is detected. Auto-fixable when a close match exists.
42
+ - **forms**: Validates `<Form>` components submit to routes with action exports, and that form fields match what the action reads via `formData.get()`. Supports intent-based dispatch patterns. Auto-fixable when targeting a mistyped route.
43
+ - **loader**: Validates `useLoaderData()` is only used in routes that export a loader function.
44
+ - **params**: Validates `useParams()` accesses only params defined in the route path (e.g., `:id` in `/users/:id`).
45
+ - **hydration**: Detects SSR hydration mismatch risks:
46
+ - Date/time operations without consistent timezone handling
47
+ - Locale-dependent formatting (e.g., `toLocaleString()`) without explicit `timeZone` option
48
+ - Random value generation during render (`Math.random()`, `uuid()`, `nanoid()`)
49
+ - Browser-only API access outside `useEffect` (`window`, `document`, `localStorage`)
50
+
51
+ Some hydration issues are auto-fixable (e.g., adding `{ timeZone: "UTC" }` to locale methods, replacing `uuid()` with `useId()`).
37
52
 
38
53
  ## Programmatic API
39
54
 
40
55
  ```typescript
41
- import { analyze } from 'roto-rooter';
56
+ import { analyze, applyFixes } from 'roto-rooter';
42
57
 
58
+ // Run analysis
43
59
  const result = analyze({
44
60
  root: './my-app',
45
61
  files: [], // empty = all files
@@ -48,6 +64,10 @@ const result = analyze({
48
64
  });
49
65
 
50
66
  console.log(result.issues);
67
+
68
+ // Apply auto-fixes
69
+ const fixResult = applyFixes(result.issues);
70
+ console.log(`Fixed ${fixResult.fixesApplied} issues`);
51
71
  ```
52
72
 
53
73
  ## Development
package/dist/cli.js CHANGED
@@ -435,7 +435,7 @@ Additional information: BADCLIENT: Bad error code, ${T} not found in range ${c}.
435
435
  `).length,mr=tt.location.line+qt-1;return Mt.location.line>=tt.location.line&&Mt.location.line<=mr}function Fdt(tt){switch(tt.type){case"date-render":return{category:"hydration",severity:"error",message:"Date created during render causes hydration mismatch",location:tt.location,code:tt.code,suggestion:"Move to useEffect, use suppressHydrationWarning, or pass date from loader"};case"locale-format":{let Mt={category:"hydration",severity:"error",message:"Locale-dependent formatting without explicit timeZone causes hydration mismatch",location:tt.location,code:tt.code,suggestion:'Add { timeZone: "UTC" } option, use suppressHydrationWarning, or format in useEffect'};if(tt.callSpan&&tt.argCount!==void 0){let qt=Odt(tt);qt&&(Mt.fix=qt)}return Mt}case"random-value":{let Mt={category:"hydration",severity:"error",message:"Random value in render will cause hydration mismatch",location:tt.location,code:tt.code,suggestion:"Use React.useId() for IDs, or generate in useEffect and store in state"};return tt.callSpan&&Ldt(tt.code)&&(Mt.fix={description:"Replaced with useId()",edits:[{file:tt.callSpan.file,start:tt.callSpan.start,end:tt.callSpan.end,newText:"useId()"}]}),Mt}case"browser-api":return{category:"hydration",severity:"error",message:"Browser-only API accessed during render will fail on server and cause hydration mismatch",location:tt.location,code:tt.code,suggestion:'Move to useEffect or check typeof window !== "undefined" first'};case"loader-date":return{category:"hydration",severity:"warning",message:"Date from loader may cause hydration mismatch when formatted",location:tt.location,code:tt.code,suggestion:"Format with explicit timeZone, use suppressHydrationWarning, or format in useEffect"};default:return}}function Odt(tt){if(!tt.callSpan)return;let Mt=tt.argCount??0,qt=tt.code.match(/\.toLocale(String|DateString|TimeString)\(\)/);if(Mt===0&&qt){let mr=`toLocale${qt[1]}`,Tr=tt.code.replace(`.${mr}()`,`.${mr}(undefined, { timeZone: "UTC" })`);return{description:'Added { timeZone: "UTC" } option',edits:[{file:tt.callSpan.file,start:tt.callSpan.start,end:tt.callSpan.end,newText:Tr}]}}if(Mt===0&&tt.code.includes("Intl.DateTimeFormat")){let mr=tt.code.replace("Intl.DateTimeFormat()",'Intl.DateTimeFormat(undefined, { timeZone: "UTC" })');if(mr!==tt.code)return{description:'Added { timeZone: "UTC" } option',edits:[{file:tt.callSpan.file,start:tt.callSpan.start,end:tt.callSpan.end,newText:mr}]}}}function Ldt(tt){return/^(uuid|nanoid|uuidv4|generateId)\(\)$/.test(tt.trim())}function jFe(tt){let{root:Mt,files:qt,checks:mr}=tt,Tr=[];try{Tr=yFe(Mt)}catch(Aa){return{issues:[{category:"links",severity:"error",message:Aa instanceof Error?Aa.message:"Failed to parse routes file",location:{file:bD.join(Mt,"app","routes.ts"),line:1,column:1}}],routes:[],components:[]}}let Ur=Mdt(Mt,qt),kn=[];for(let Aa of Ur)try{let Ls=xFe(Aa);kn.push(Ls)}catch(Ls){console.error(`Warning: Could not parse ${Aa}:`,Ls)}let Nn=[],Fn=new Set(mr.length>0?mr:["links","forms","loader","params","hydration"]);if(Fn.has("links")&&Nn.push(...DFe(kn,Tr)),Fn.has("forms")&&Nn.push(...FFe(kn,Tr,Mt)),Fn.has("loader")&&Nn.push(...OFe(kn)),Fn.has("params")&&Nn.push(...MFe(kn,Tr,Mt)),Fn.has("hydration")&&Nn.push(...RFe(kn)),qt.length>0){let Aa=new Set(qt.map(xl=>bD.resolve(xl)));return{issues:Nn.filter(xl=>Aa.has(bD.resolve(xl.location.file))),routes:Tr,components:kn}}return{issues:Nn,routes:Tr,components:kn}}function Mdt(tt,Mt){if(Mt.length>0)return Mt.map(Tr=>bD.resolve(Tr));let qt=bD.join(tt,"app","routes");if(!DQ.existsSync(qt))return[];let mr=[];return BFe(qt,Tr=>{Tr.endsWith(".tsx")&&mr.push(Tr)}),mr}function BFe(tt,Mt){let qt=DQ.readdirSync(tt,{withFileTypes:!0});for(let mr of qt){let Tr=bD.join(tt,mr.name);mr.isDirectory()?BFe(Tr,Mt):mr.isFile()&&Mt(Tr)}}var PQ=Fg(require("fs"));function JFe(tt,Mt=!1){let qt=tt.filter(Fn=>Fn.fix),mr=tt.filter(Fn=>!Fn.fix),Tr=new Map;for(let Fn of qt)for(let Aa of Fn.fix.edits){let Ls=Tr.get(Aa.file)||[];Ls.push({edit:Aa,issue:Fn}),Tr.set(Aa.file,Ls)}let Ur=[],kn=[],Nn=[];for(let[Fn,Aa]of Tr)try{let Ls=Aa.map(Re=>Re.edit);if(Rdt(Fn,Ls,Mt).modified){Ur.push(Fn);for(let{issue:Re}of Aa)kn.includes(Re)||kn.push(Re)}}catch(Ls){Nn.push({file:Fn,error:Ls instanceof Error?Ls.message:String(Ls)})}return{filesModified:Ur,fixesApplied:kn.length,fixedIssues:kn,unfixableIssues:mr,errors:Nn}}function Rdt(tt,Mt,qt){let mr=PQ.readFileSync(tt,"utf-8"),Tr=[...Mt].sort((kn,Nn)=>Nn.start-kn.start);for(let kn=0;kn<Tr.length-1;kn++){let Nn=Tr[kn],Fn=Tr[kn+1];if(Nn.start<Fn.end)throw new Error(`Overlapping edits at positions ${Fn.start}-${Fn.end} and ${Nn.start}-${Nn.end}`)}let Ur=mr;for(let kn of Tr)Ur=Ur.slice(0,kn.start)+kn.newText+Ur.slice(kn.end);return!qt&&Ur!==mr?(PQ.writeFileSync(tt,Ur,"utf-8"),{modified:!0,newContent:Ur}):{modified:Ur!==mr,newContent:Ur}}var wQ=Fg(require("path"));function jdt(tt,Mt=process.cwd()){let qt=[],mr=wQ.relative(Mt,tt.location.file),Tr=wQ.basename(mr);return qt.push(`[${tt.severity}] ${Tr}:${tt.location.line}:${tt.location.column}`),tt.code&&qt.push(` ${tt.code}`),qt.push(` x ${tt.message}`),tt.suggestion&&qt.push(` -> ${tt.suggestion}`),qt.join(`
436
436
  `)}function zFe(tt,Mt=process.cwd()){if(tt.length===0)return"No issues found.";let qt=[];qt.push(`
437
437
  rr found ${tt.length} issue${tt.length===1?"":"s"}:`),qt.push("");for(let Ur of tt)qt.push(jdt(Ur,Mt)),qt.push("");let mr=tt.filter(Ur=>Ur.severity==="error").length,Tr=tt.filter(Ur=>Ur.severity==="warning").length;return qt.push(`Summary: ${mr} error${mr===1?"":"s"}, ${Tr} warning${Tr===1?"":"s"}`),qt.push("Run with --help for options."),qt.join(`
438
- `)}var WFe=Fg(require("path"));function Bdt(tt,Mt=process.cwd()){let qt=WFe.relative(Mt,tt.location.file),mr={category:tt.category,severity:tt.severity,message:tt.message,file:qt,line:tt.location.line,column:tt.location.column};return tt.code&&(mr.code=tt.code),tt.suggestion&&(mr.suggestion=tt.suggestion),mr}function VFe(tt,Mt=process.cwd()){let qt=tt.issues.map(Ur=>Bdt(Ur,Mt)),mr=tt.issues.filter(Ur=>Ur.severity==="error").length,Tr=tt.issues.filter(Ur=>Ur.severity==="warning").length;return{issues:qt,summary:{total:tt.issues.length,errors:mr,warnings:Tr}}}function Jdt(){return"0.1.4"}function zdt(){let tt=process.argv.slice(2),Mt=Wdt(tt);Mt.version&&(console.log(Jdt()),process.exit(0)),Mt.help&&(Vdt(),process.exit(0));let qt=jFe(Mt);if(Mt.fix||Mt.dryRun){let Tr=JFe(qt.issues,Mt.dryRun??!1);Mt.format==="json"?console.log(JSON.stringify({...qt,fixResult:Tr},null,2)):Udt(Tr,Mt.dryRun??!1),Tr.unfixableIssues.filter(kn=>kn.severity==="error").length>0&&process.exit(1);return}if(Mt.format==="json"){let Tr=VFe(qt);console.log(JSON.stringify(Tr,null,2))}else console.log(zFe(qt.issues));qt.issues.filter(Tr=>Tr.severity==="error").length>0&&process.exit(1)}function Wdt(tt){let Mt={files:[],checks:[],format:"text",root:process.cwd(),help:!1,version:!1,fix:!1,dryRun:!1},qt=0;for(;qt<tt.length;){let mr=tt[qt];if(mr==="--help"||mr==="-h"){Mt.help=!0,qt++;continue}if(mr==="--version"||mr==="-v"){Mt.version=!0,qt++;continue}if(mr==="--fix"){Mt.fix=!0,qt++;continue}if(mr==="--dry-run"){Mt.dryRun=!0,qt++;continue}if(mr==="--format"||mr==="-f"){let Tr=tt[qt+1];(Tr==="json"||Tr==="text")&&(Mt.format=Tr),qt+=2;continue}if(mr==="--check"||mr==="-c"){let Tr=tt[qt+1];Tr&&(Mt.checks=Tr.split(",").map(Ur=>Ur.trim())),qt+=2;continue}if(mr==="--app"||mr==="-a"){let Tr=tt[qt+1];Tr&&(Mt.root=S7.resolve(Tr)),qt+=2;continue}mr.startsWith("-")||Mt.files.push(mr),qt++}return Mt}function Vdt(){console.log(`
438
+ `)}var WFe=Fg(require("path"));function Bdt(tt,Mt=process.cwd()){let qt=WFe.relative(Mt,tt.location.file),mr={category:tt.category,severity:tt.severity,message:tt.message,file:qt,line:tt.location.line,column:tt.location.column};return tt.code&&(mr.code=tt.code),tt.suggestion&&(mr.suggestion=tt.suggestion),mr}function VFe(tt,Mt=process.cwd()){let qt=tt.issues.map(Ur=>Bdt(Ur,Mt)),mr=tt.issues.filter(Ur=>Ur.severity==="error").length,Tr=tt.issues.filter(Ur=>Ur.severity==="warning").length;return{issues:qt,summary:{total:tt.issues.length,errors:mr,warnings:Tr}}}function Jdt(){return"0.1.5"}function zdt(){let tt=process.argv.slice(2),Mt=Wdt(tt);Mt.version&&(console.log(Jdt()),process.exit(0)),Mt.help&&(Vdt(),process.exit(0));let qt=jFe(Mt);if(Mt.fix||Mt.dryRun){let Tr=JFe(qt.issues,Mt.dryRun??!1);Mt.format==="json"?console.log(JSON.stringify({...qt,fixResult:Tr},null,2)):Udt(Tr,Mt.dryRun??!1),Tr.unfixableIssues.filter(kn=>kn.severity==="error").length>0&&process.exit(1);return}if(Mt.format==="json"){let Tr=VFe(qt);console.log(JSON.stringify(Tr,null,2))}else console.log(zFe(qt.issues));qt.issues.filter(Tr=>Tr.severity==="error").length>0&&process.exit(1)}function Wdt(tt){let Mt={files:[],checks:[],format:"text",root:process.cwd(),help:!1,version:!1,fix:!1,dryRun:!1},qt=0;for(;qt<tt.length;){let mr=tt[qt];if(mr==="--help"||mr==="-h"){Mt.help=!0,qt++;continue}if(mr==="--version"||mr==="-v"){Mt.version=!0,qt++;continue}if(mr==="--fix"){Mt.fix=!0,qt++;continue}if(mr==="--dry-run"){Mt.dryRun=!0,qt++;continue}if(mr==="--format"||mr==="-f"){let Tr=tt[qt+1];(Tr==="json"||Tr==="text")&&(Mt.format=Tr),qt+=2;continue}if(mr==="--check"||mr==="-c"){let Tr=tt[qt+1];Tr&&(Mt.checks=Tr.split(",").map(Ur=>Ur.trim())),qt+=2;continue}if(mr==="--root"||mr==="-r"){let Tr=tt[qt+1];Tr&&(Mt.root=S7.resolve(Tr)),qt+=2;continue}mr.startsWith("-")||Mt.files.push(mr),qt++}return Mt}function Vdt(){console.log(`
439
439
  rr - Static analysis and functional verification for React Router applications
440
440
 
441
441
  USAGE:
@@ -447,7 +447,7 @@ OPTIONS:
447
447
  -f, --format <format> Output format: text (default) or json
448
448
  -c, --check <checks> Comma-separated list of checks to run (default is all checks)
449
449
  Available: links, forms, loader, params, hydration
450
- -a, --app <path> Root directory of the app to roto-root (default: current directory)
450
+ -r, --root <path> Project root directory containing the app/ folder (default: cwd)
451
451
  --fix Automatically fix issues where possible
452
452
  --dry-run Show what would be fixed without modifying files
453
453
 
@@ -473,8 +473,8 @@ EXAMPLES:
473
473
  # Fix specific file(s)
474
474
  rr --fix app/routes/dashboard.tsx
475
475
 
476
- # Analyze files in the context of a different app
477
- rr --app ./my-app ./my-app/app/routes/dashboard.tsx
476
+ # Analyze files in a different project directory
477
+ rr --root ./my-app ./my-app/app/routes/dashboard.tsx
478
478
  `)}function Udt(tt,Mt){let{fixedIssues:qt,unfixableIssues:mr,filesModified:Tr,errors:Ur}=tt;if(qt.length===0&&mr.length===0){console.log("No issues found.");return}if(qt.length>0){console.log(`
479
479
  ${Mt?"Would fix":"Fixed"} ${qt.length} issue${qt.length===1?"":"s"} in ${Tr.length} file${Tr.length===1?"":"s"}:`),console.log();for(let Nn of qt){let Fn=Nn.severity==="error"?"[error]":"[warning]",Aa=S7.relative(process.cwd(),Nn.location.file),Ls=Mt?"[would fix]":"[fixed]";console.log(`[${Nn.category}] ${Aa}:${Nn.location.line}:${Nn.location.column}`),Nn.code&&console.log(` ${Nn.code}`),console.log(` ${Fn} ${Nn.message}`),Nn.fix&&console.log(` ${Ls} ${Nn.fix.description}`),console.log()}}if(Ur.length>0){console.log("Errors while applying fixes:");for(let{file:kn,error:Nn}of Ur){let Fn=S7.relative(process.cwd(),kn);console.log(` [!] ${Fn}: ${Nn}`)}console.log()}if(mr.length>0){let kn=mr.filter(Fn=>Fn.severity==="error").length,Nn=mr.filter(Fn=>Fn.severity==="warning").length;console.log(`${mr.length} issue${mr.length===1?"":"s"} could not be auto-fixed:`),console.log();for(let Fn of mr){let Aa=Fn.severity==="error"?"[error]":"[warning]",Ls=S7.relative(process.cwd(),Fn.location.file);console.log(`[${Fn.category}] ${Ls}:${Fn.location.line}:${Fn.location.column}`),Fn.code&&console.log(` ${Fn.code}`),console.log(` ${Aa} ${Fn.message}`),Fn.suggestion&&console.log(` -> ${Fn.suggestion}`),console.log()}console.log(`Unfixable: ${kn} error${kn===1?"":"s"}, ${Nn} warning${Nn===1?"":"s"}`)}Mt&&qt.length>0&&console.log("Run with --fix to apply changes.")}zdt();
480
480
  /*! Bundled license information:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roto-rooter",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Static analysis and functional verifier tool for React Router applications",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",