rvlib-shipkit 0.22.2 → 0.23.0

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.
@@ -0,0 +1,255 @@
1
+ // Rewrites local imports for extension-carrying absolute specifiers:
2
+ // 1. converts relative paths (./foo, ../../bar) to the canonical absolute
3
+ // form: root-relative ('src/...'), or package-name-relative
4
+ // ('rvlib-x/src/...') when the target lives inside a sub-package
5
+ // 2. adds real file extensions (.ts, .tsx, .d.ts)
6
+ // 3. validates that every resolved path exists on disk
7
+ //
8
+ // Handles single-line + multi-line static imports/exports, side-effect
9
+ // imports, dynamic import(), declare-module augmentations, and the tsc
10
+ // substitutions (.js -> .ts/.tsx/.d.ts, .ts -> .tsx). Leaves alone: comments,
11
+ // string-embedded imports (codegen), asset imports (.css, .png, .json, ...),
12
+ // bundler suffixes (?raw, ?url), template-literal specifiers, packages.
13
+ import { readdirSync, readFileSync, writeFileSync, existsSync, type Dirent } from 'fs'
14
+ import { dirname, extname, isAbsolute, join, relative, resolve } from 'path'
15
+ import { canonicalSpecifier, isSkipped, loadConfig, resolvePackageSpecifier, toPosix, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
16
+ import { templateLineMask } from 'rvlib-shipkit/src/ts/source-mask.ts'
17
+
18
+ const ASSET_EXTENSIONS = new Set([
19
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
20
+ '.mp3', '.wav', '.ogg', '.mp4', '.webm',
21
+ '.css', '.scss', '.less', '.sass',
22
+ '.html', '.json', '.md', '.txt',
23
+ '.woff', '.woff2', '.eot', '.ttf',
24
+ '.graphql', '.gql',
25
+ ])
26
+
27
+ function shouldSkipSpecifier(specifier: string): boolean {
28
+ if (specifier.includes('?')) return true // ?raw / ?inline / ?url bundler syntax
29
+ if (specifier.includes('${')) return true // template-literal codegen
30
+ const ext = extname(specifier).toLowerCase()
31
+ if (ext !== '' && ASSET_EXTENSIONS.has(ext)) return true
32
+ return false
33
+ }
34
+
35
+ // ─── extension resolution (with an exists cache — the walk is hot) ──────────
36
+ const existsCache = new Map<string, boolean>()
37
+ const cachedExists = (p: string): boolean => {
38
+ let r = existsCache.get(p)
39
+ if (r == null) {
40
+ r = existsSync(p)
41
+ existsCache.set(p, r)
42
+ }
43
+ return r
44
+ }
45
+ export const clearExistsCache = (): void => existsCache.clear()
46
+
47
+ function resolveFileExtension(absPath: string): string | null {
48
+ if (/\.(d\.ts|tsx?|json)$/.test(absPath) && cachedExists(absPath)) return absPath
49
+ // tsc substitution: a .ts specifier can point at a .tsx file
50
+ if (/\.ts$/.test(absPath) && !/\.d\.ts$/.test(absPath)) {
51
+ const tsx = absPath.replace(/\.ts$/, '.tsx')
52
+ if (cachedExists(tsx)) return tsx
53
+ }
54
+ // .js/.jsx -> .ts/.tsx/.d.ts mapping; a real .js wins over a sibling .d.ts
55
+ if (/\.jsx?$/.test(absPath)) {
56
+ const base = absPath.replace(/\.jsx?$/, '')
57
+ for (const ext of ['.ts', '.tsx']) if (cachedExists(base + ext)) return base + ext
58
+ if (cachedExists(absPath)) return absPath
59
+ if (cachedExists(`${base}.d.ts`)) return `${base}.d.ts`
60
+ }
61
+ for (const c of [`${absPath}.ts`, `${absPath}.tsx`, `${absPath}.d.ts`, `${absPath}.js`, `${absPath}.jsx`, join(absPath, 'index.ts'), join(absPath, 'index.tsx')]) {
62
+ if (cachedExists(c)) return c
63
+ }
64
+ return null
65
+ }
66
+
67
+ // ─── line-level rewrite ─────────────────────────────────────────────────────
68
+ export type ImportsResult = {
69
+ filePath: string
70
+ changes: { line: number; before: string; after: string }[]
71
+ errors: { line: number; specifier: string; reason: string }[]
72
+ }
73
+
74
+ const REL = '\\.\\.?\\/[^\'"]*'
75
+ const relRes = {
76
+ static: new RegExp(`(?:import|export)\\s+(?:.*?\\s+)?from\\s+(['"])(${REL})\\1`, 'g'),
77
+ multiline: new RegExp(`}\\s*from\\s+(['"])(${REL})\\1`, 'g'),
78
+ sideEffect: new RegExp(`^(\\s*import\\s+)(['"])(${REL})\\2`, 'g'),
79
+ dynamic: new RegExp(`import\\(\\s*(['"])(${REL})\\1\\s*\\)`, 'g'),
80
+ }
81
+ const absRes = (roots: string[]) => {
82
+ const ABS = `(?:${roots.map((r) => r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\/[^'"]*`
83
+ return {
84
+ static: new RegExp(`(?:import|export)\\s+(?:.*?\\s+)?from\\s+(['"])(${ABS})\\1`, 'g'),
85
+ multiline: new RegExp(`}\\s*from\\s+(['"])(${ABS})\\1`, 'g'),
86
+ sideEffect: new RegExp(`^(\\s*import\\s+)(['"])(${ABS})\\2`, 'g'),
87
+ dynamic: new RegExp(`import\\(\\s*(['"])(${ABS})\\1\\s*\\)`, 'g'),
88
+ declareModule: new RegExp(`declare\\s+module\\s+(['"])(${ABS})\\1`, 'g'),
89
+ }
90
+ }
91
+
92
+ export function rewriteFile(filePath: string, cfg: RvtConfig, save: boolean): ImportsResult | null {
93
+ if (cfg.roots.length === 0) throw new Error('rvt: no roots configured — set tsconfig "paths" or package.json "rvt".roots')
94
+ const content = readFileSync(filePath, 'utf8')
95
+ const fileDir = dirname(filePath)
96
+ const lines = content.split('\n')
97
+ const changes: ImportsResult['changes'] = []
98
+ const errors: ImportsResult['errors'] = []
99
+ const seenErrors = new Set<string>()
100
+ // absolute specifiers to re-validate: repo roots + discovered package names
101
+ const A = absRes([...cfg.roots, ...cfg.packages.map((p) => p.name)])
102
+
103
+ const pushError = (lineNum: number, specifier: string, reason: string): void => {
104
+ const key = `${lineNum}:${specifier}`
105
+ if (seenErrors.has(key)) return
106
+ seenErrors.add(key)
107
+ errors.push({ line: lineNum, specifier, reason })
108
+ }
109
+
110
+ // resolve a specifier and return its canonical form (or null: leave alone):
111
+ // package-name specifier when the target lives inside a sub-package,
112
+ // repo-root path otherwise
113
+ const resolveNew = (specifier: string, lineNum: number, isRelative: boolean): string | null => {
114
+ if (shouldSkipSpecifier(specifier)) return null
115
+ const viaPackage = isRelative ? null : resolvePackageSpecifier(specifier, cfg)
116
+ const absPath = isRelative ? resolve(fileDir, specifier) : resolve(cfg.root, viaPackage ?? specifier)
117
+ const resolved = resolveFileExtension(absPath)
118
+ if (resolved == null) {
119
+ pushError(lineNum, specifier, 'could not resolve file on disk')
120
+ return null
121
+ }
122
+ const rel = toPosix(relative(cfg.root, resolved))
123
+ const next = canonicalSpecifier(rel, cfg)
124
+ if (next === rel && !cfg.roots.some((r) => rel.startsWith(`${r}/`))) {
125
+ pushError(lineNum, specifier, `resolved outside allowed roots (${cfg.roots.join(', ')}): ${rel}`)
126
+ return null
127
+ }
128
+ return next === specifier ? null : next
129
+ }
130
+ const sub = (match: string, quote: string, specifier: string, lineNum: number, isRelative: boolean): string => {
131
+ const next = resolveNew(specifier, lineNum, isRelative)
132
+ return next == null ? match : match.replace(`${quote}${specifier}${quote}`, `${quote}${next}${quote}`)
133
+ }
134
+
135
+ let inBlockComment = false
136
+ // template-literal content is TEXT, not code (a codegen emitting import
137
+ // lines inside a backtick block must not be scanned or rewritten)
138
+ const templateMask = templateLineMask(lines)
139
+ const newLines = lines.map((line, lineIdx) => {
140
+ const lineNum = lineIdx + 1
141
+ const trimmed = line.trimStart()
142
+
143
+ if (templateMask?.[lineIdx]) return line
144
+
145
+ if (inBlockComment) {
146
+ if (trimmed.includes('*/')) inBlockComment = false
147
+ return line
148
+ }
149
+ if (trimmed.startsWith('/*')) {
150
+ inBlockComment = !trimmed.includes('*/')
151
+ return line
152
+ }
153
+ // cheap prefilter: line must look import-shaped at all
154
+ if (!line.includes('from ') && !line.includes('import(') && !line.includes('declare module') && !/^\s*import\s+['"]/.test(line)) return line
155
+ if (trimmed.startsWith('//')) return line
156
+ // string-embedded imports (codegen) and template-literal content
157
+ if (/^\s*(const|let|var|return)\s/.test(line) && /[`'"].*(?:import|export).*from\s/.test(line)) return line
158
+ if (trimmed.startsWith('`') || trimmed.startsWith("'") || trimmed.startsWith('"')) return line
159
+
160
+ let newLine = line
161
+ newLine = newLine.replace(relRes.static, (m, q, s) => sub(m, q, s, lineNum, true))
162
+ newLine = newLine.replace(relRes.multiline, (m, q, s) => sub(m, q, s, lineNum, true))
163
+ newLine = newLine.replace(relRes.sideEffect, (m, prefix, q, s) => {
164
+ const next = resolveNew(s, lineNum, true)
165
+ return next == null ? m : `${prefix}${q}${next}${q}`
166
+ })
167
+ newLine = newLine.replace(relRes.dynamic, (m, q, s) => sub(m, q, s, lineNum, true))
168
+ newLine = newLine.replace(A.static, (m, q, s) => sub(m, q, s, lineNum, false))
169
+ newLine = newLine.replace(A.multiline, (m, q, s) => sub(m, q, s, lineNum, false))
170
+ newLine = newLine.replace(A.sideEffect, (m, prefix, q, s) => {
171
+ const next = resolveNew(s, lineNum, false)
172
+ return next == null ? m : `${prefix}${q}${next}${q}`
173
+ })
174
+ newLine = newLine.replace(A.dynamic, (m, q, s) => sub(m, q, s, lineNum, false))
175
+ newLine = newLine.replace(A.declareModule, (m, q, s) => sub(m, q, s, lineNum, false))
176
+
177
+ if (newLine !== line) changes.push({ line: lineNum, before: line.trim(), after: newLine.trim() })
178
+ return newLine
179
+ })
180
+
181
+ if (changes.length === 0 && errors.length === 0) return null
182
+ if (save && changes.length > 0) writeFileSync(filePath, newLines.join('\n'), 'utf8')
183
+ return { filePath, changes, errors }
184
+ }
185
+
186
+ // ─── walker + CLI ───────────────────────────────────────────────────────────
187
+ function walk(cfg: RvtConfig, dir: string, cb: (filePath: string) => void): void {
188
+ let entries: Dirent[]
189
+ try {
190
+ entries = readdirSync(dir, { withFileTypes: true })
191
+ } catch {
192
+ return
193
+ }
194
+ for (const entry of entries) {
195
+ const fullPath = join(dir, entry.name)
196
+ const rel = toPosix(relative(cfg.root, fullPath))
197
+ if (entry.isDirectory()) {
198
+ if (!isSkipped(rel, cfg)) walk(cfg, fullPath, cb)
199
+ } else if (entry.isFile() && /\.tsx?$/.test(entry.name)) {
200
+ // .d.ts included: they carry import() type expressions that need rewriting
201
+ cb(fullPath)
202
+ }
203
+ }
204
+ }
205
+
206
+ /** `files` = files CHANGED; `scanned` = files actually visited (a green run
207
+ * over zero files is indistinguishable from a green run over 3000 without
208
+ * it); `missingRoots` = configured roots absent on disk */
209
+ export type ImportsRun = { files: number; scanned: number; roots: string[]; missingRoots: string[]; changes: number; errors: { file: string; line: number; specifier: string; reason: string }[] }
210
+
211
+ export function runImports(opts: { save?: boolean; files?: string[]; cfg?: RvtConfig } = {}): ImportsRun {
212
+ const cfg = opts.cfg ?? loadConfig()
213
+ clearExistsCache()
214
+ const save = opts.save === true
215
+ const run: ImportsRun = { files: 0, scanned: 0, roots: cfg.roots, missingRoots: [], changes: 0, errors: [] }
216
+ const process1 = (fp: string): void => {
217
+ const abs = isAbsolute(fp) ? fp : resolve(cfg.root, fp)
218
+ run.scanned++
219
+ const result = rewriteFile(abs, cfg, save)
220
+ if (result == null) return
221
+ run.files++
222
+ run.changes += result.changes.length
223
+ for (const e of result.errors) run.errors.push({ file: toPosix(relative(cfg.root, abs)), ...e })
224
+ }
225
+ if (opts.files != null && opts.files.length > 0) {
226
+ for (const fp of opts.files) process1(fp)
227
+ } else {
228
+ for (const r of cfg.roots) {
229
+ const abs = join(cfg.root, r)
230
+ if (existsSync(abs)) walk(cfg, abs, process1)
231
+ else run.missingRoots.push(r)
232
+ }
233
+ }
234
+ return run
235
+ }
236
+
237
+ // modes: (none)=preview · --check = CI gate (exit 1 if anything would change)
238
+ // · --fix = rewrite in place · <file...> = fix exactly these files (on-save hook)
239
+ export function importsCli(argv: string[]): number {
240
+ const flags = argv.filter((a) => a.startsWith('--'))
241
+ const files = argv.filter((a) => !a.startsWith('--'))
242
+ const check = flags.includes('--check')
243
+ const save = !check && (files.length > 0 || flags.includes('--fix') || flags.includes('--save'))
244
+ const run = runImports({ save, files: files.length > 0 ? files : undefined })
245
+ const mode = save ? 'fix' : check ? 'check (ci gate)' : 'preview (use --fix to write)'
246
+ console.log(`imports [${mode}]: ${run.changes} rewrite(s) across ${run.files} file(s), ${run.scanned} scanned, ${run.errors.length} error(s)`)
247
+ // a gate that scanned nothing is not a passing gate — say so instead of
248
+ // printing the same green as a full run
249
+ for (const r of run.missingRoots) console.log(` WARN configured root '${r}' does not exist — drop it from package.json "rvt".roots or create it`)
250
+ if (run.scanned === 0)
251
+ console.log(` scanned 0 files — roots (${run.roots.join(', ')}) hold no .ts/.tsx; this gate is checking NOTHING${check ? ', which fails the gate' : ''}`)
252
+ for (const e of run.errors) console.log(` ${e.file}:${e.line} -> '${e.specifier}' (${e.reason})`)
253
+ if (check && run.changes > 0) console.log('non-absolute imports found — run `rvt imports --fix` and commit the result.')
254
+ return run.errors.length > 0 || (check && (run.changes > 0 || run.scanned === 0)) ? 1 : 0
255
+ }
@@ -0,0 +1,238 @@
1
+ // Rename / move a file or folder AND update every exact occurrence of its
2
+ // repo-relative path across all tracked + untracked source files.
3
+ //
4
+ // Assumes non-relative import paths with explicit extensions, e.g.
5
+ // import { X } from 'src/foo/bar.ts'
6
+ // so the exact path string is a reliable search key.
7
+ //
8
+ // Modes:
9
+ // file -> file path renames the file
10
+ // file -> existing folder moves the file into that folder (keeps name)
11
+ // file -> path ending with / same, creates folder if missing
12
+ // folder -> folder path renames the folder (recursive replacement)
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs'
14
+ import { basename, dirname, extname, join, relative, resolve } from 'path'
15
+ import { canonicalSpecifier, isSkipped, loadConfig, toPosix, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
16
+
17
+ const BINARY_EXTS = new Set([
18
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.tiff',
19
+ '.mp3', '.mp4', '.wav', '.webm', '.ogg', '.mov', '.avi',
20
+ '.pdf', '.zip', '.gz', '.tar', '.7z', '.rar',
21
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
22
+ '.wasm', '.node', '.keystore', '.jks', '.p12', '.pem',
23
+ '.psd', '.sketch', '.lockb',
24
+ ])
25
+
26
+ /** the nearest ancestor package.json of a repo-relative path, whatever the
27
+ * configured roots say. Its `name` plus the path below it IS the specifier a
28
+ * sibling module imports. */
29
+ function enclosingPackage(repoRoot: string, relPath: string): { name: string; dir: string } | null {
30
+ const segs = toPosix(relPath).split('/')
31
+ for (let i = segs.length - 1; i > 0; i--) {
32
+ const dir = segs.slice(0, i).join('/')
33
+ const manifest = join(repoRoot, dir, 'package.json')
34
+ if (!existsSync(manifest)) continue
35
+ try {
36
+ const name = (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: string }).name
37
+ if (name != null && name !== '') return { name, dir }
38
+ } catch {
39
+ // an unparseable manifest is not a package boundary
40
+ }
41
+ }
42
+ return null
43
+ }
44
+
45
+ export type RenameResult = {
46
+ srcRel: string
47
+ dstRel: string
48
+ isFolder: boolean
49
+ hits: { file: string; count: number }[]
50
+ totalRefs: number
51
+ suspicious: string[]
52
+ applied: boolean
53
+ }
54
+
55
+ export function runRename(srcArg: string, dstArg: string, opts: { apply?: boolean; cfg?: RvtConfig } = {}): RenameResult {
56
+ const cfg = opts.cfg ?? loadConfig()
57
+ const repoRoot = cfg.root
58
+
59
+ const srcAbs = resolve(repoRoot, srcArg)
60
+ if (!existsSync(srcAbs)) throw new Error(`source does not exist: ${srcAbs}`)
61
+ const srcIsFolder = statSync(srcAbs).isDirectory()
62
+
63
+ let dstAbs = resolve(repoRoot, dstArg)
64
+ const dstEndsWithSep = /[/\\]$/.test(dstArg)
65
+ const dstIsExistingFolder = existsSync(dstAbs) && statSync(dstAbs).isDirectory()
66
+ // file -> folder: move into folder, keep basename
67
+ if (!srcIsFolder && (dstIsExistingFolder || dstEndsWithSep)) dstAbs = join(dstAbs, basename(srcAbs))
68
+ if (existsSync(dstAbs)) throw new Error(`destination already exists: ${dstAbs}`)
69
+
70
+ const srcRel = toPosix(relative(repoRoot, srcAbs))
71
+ const dstRel = toPosix(relative(repoRoot, dstAbs))
72
+ if (srcRel === dstRel) throw new Error('src == dst, nothing to do')
73
+ if (srcRel.startsWith('..')) throw new Error(`source outside repo: ${srcRel}`)
74
+ if (dstRel.startsWith('..')) throw new Error(`destination outside repo: ${dstRel}`)
75
+
76
+ // both spellings of the path are search keys: the repo-relative form and,
77
+ // when inside a sub-package, the package-name form imports actually use.
78
+ // The package is found by walking UP from the file, never by the configured
79
+ // roots: the content scan is repo-wide, so the specifier derivation must be
80
+ // too. A repo whose packages live outside `roots` (a docs tree that ships
81
+ // libs) otherwise renamed the file and rewrote NOTHING, silently.
82
+ const pairs: { from: string; to: string }[] = [{ from: srcRel, to: dstRel }]
83
+ const srcCanon = canonicalSpecifier(srcRel, cfg)
84
+ if (srcCanon !== srcRel) pairs.push({ from: srcCanon, to: canonicalSpecifier(dstRel, cfg) })
85
+ else {
86
+ const owner = enclosingPackage(repoRoot, srcRel)
87
+ if (owner != null && dstRel.startsWith(`${owner.dir}/`))
88
+ pairs.push({
89
+ from: `${owner.name}${srcRel.slice(owner.dir.length)}`,
90
+ to: `${owner.name}${dstRel.slice(owner.dir.length)}`,
91
+ })
92
+ }
93
+
94
+ const pkgDirs = cfg.packages.map((p) => p.dir)
95
+ // scan all tracked + untracked (non-ignored) files
96
+ const ls = Bun.spawnSync(['git', 'ls-files', '-co', '--exclude-standard', '-z'], { cwd: repoRoot })
97
+ const allFiles = ls.stdout.toString().split('\u0000').filter(Boolean)
98
+
99
+ const hits: { file: string; count: number }[] = []
100
+ let totalRefs = 0
101
+ for (const file of allFiles) {
102
+ if (isSkipped(file, cfg, pkgDirs)) continue
103
+ if (BINARY_EXTS.has(extname(file).toLowerCase())) continue
104
+ let content: string
105
+ try {
106
+ content = readFileSync(join(repoRoot, file), 'utf8')
107
+ } catch {
108
+ continue
109
+ }
110
+ const nul = content.indexOf('\u0000')
111
+ if (nul !== -1 && nul < 4096) continue // cheap binary sniff
112
+ const count = scanPairs(content, pairs).count
113
+ if (count > 0) {
114
+ hits.push({ file, count })
115
+ totalRefs += count
116
+ }
117
+ }
118
+
119
+ const suspicious = pairs.flatMap((p) => findSuspiciousContexts(repoRoot, p.from, hits))
120
+ // a file naming the BASENAME without matching any full path form is a
121
+ // near-miss the rewrite cannot make: a regex-escaped path in a test, a doc
122
+ // line, a specifier built from parts. Reported, never rewritten, because
123
+ // guessing at those is how a rename corrupts a repo
124
+ // the STEM, not the filename: a regex-escaped path writes `foo\.ts`, so the
125
+ // extension never matches literally
126
+ const srcBase = basename(srcRel).replace(/\.[^.]+$/, '')
127
+ if (!srcIsFolder && srcBase.length > 3) {
128
+ const hitFiles = new Set(hits.map((h) => h.file))
129
+ const baseRe = new RegExp(`(?<![\\w-])${srcBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`)
130
+ for (const file of allFiles) {
131
+ if (hitFiles.has(file) || file === srcRel || isSkipped(file, cfg, pkgDirs)) continue
132
+ if (BINARY_EXTS.has(extname(file).toLowerCase())) continue
133
+ let content: string
134
+ try { content = readFileSync(join(repoRoot, file), 'utf8') } catch { continue }
135
+ if (baseRe.test(content)) suspicious.push(`${file}: names '${srcBase}' without a rewritable path — check it by hand`)
136
+ }
137
+ }
138
+
139
+ if (opts.apply === true) {
140
+ for (const h of hits) {
141
+ const full = join(repoRoot, h.file)
142
+ const before = readFileSync(full, 'utf8')
143
+ const after = scanPairs(before, pairs).text
144
+ if (after !== before) writeFileSync(full, after)
145
+ }
146
+ const dstDir = dirname(dstAbs)
147
+ if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true })
148
+ const mv = Bun.spawnSync(['git', 'mv', srcAbs, dstAbs], { cwd: repoRoot, stdout: 'pipe', stderr: 'pipe' })
149
+ if (mv.exitCode !== 0) renameSync(srcAbs, dstAbs) // untracked file: plain fs move
150
+ }
151
+
152
+ return { srcRel, dstRel, isFolder: srcIsFolder, hits, totalRefs, suspicious, applied: opts.apply === true }
153
+ }
154
+
155
+ export function renameCli(argv: string[]): number {
156
+ const apply = argv.includes('-x') || argv.includes('--apply')
157
+ const paths = argv.filter((a) => !a.startsWith('-'))
158
+ if (paths.length !== 2) {
159
+ console.log('usage: rvt rename <src> <dst> [-x|--apply] (dry-run by default)')
160
+ return 2
161
+ }
162
+ let r: RenameResult
163
+ try {
164
+ r = runRename(paths[0]!, paths[1]!, { apply })
165
+ } catch (e) {
166
+ console.error(String(e instanceof Error ? e.message : e))
167
+ return 1
168
+ }
169
+ console.log(`rename: ${r.srcRel} -> ${r.dstRel}`)
170
+ console.log(`type: ${r.isFolder ? 'folder (recursive)' : 'file'}`)
171
+ console.log(`refs: ${r.totalRefs} across ${r.hits.length} file(s)`)
172
+ for (const h of r.hits) console.log(` ${h.count.toString().padStart(4)} ${h.file}`)
173
+ if (r.suspicious.length > 0) {
174
+ console.log('\nwarning: possible substring overreach (inspect before applying):')
175
+ for (const s of r.suspicious) console.log(` ${s}`)
176
+ }
177
+ if (!r.applied) console.log('\ndry-run. pass -x or --apply to commit.')
178
+ else console.log(`\napplied: ${r.srcRel} -> ${r.dstRel} (${r.totalRefs} refs rewritten)`)
179
+ return 0
180
+ }
181
+
182
+ // simultaneous multi-needle replace: earliest match wins (longest needle on a
183
+ // tie) and the scan continues PAST the replacement — sequential split/join
184
+ // would let one pair's output feed the next (the package form is a substring
185
+ // of the repo form, so that corrupts content)
186
+ function scanPairs(text: string, pairs: { from: string; to: string }[]): { text: string; count: number } {
187
+ let out = ''
188
+ let i = 0
189
+ let count = 0
190
+ while (true) {
191
+ let at = -1
192
+ let hit: { from: string; to: string } | null = null
193
+ for (const p of pairs) {
194
+ if (p.from.length === 0) continue
195
+ const idx = text.indexOf(p.from, i)
196
+ if (idx === -1) continue
197
+ if (at === -1 || idx < at || (idx === at && p.from.length > (hit?.from.length ?? 0))) {
198
+ at = idx
199
+ hit = p
200
+ }
201
+ }
202
+ if (hit == null) {
203
+ out += text.slice(i)
204
+ return { text: out, count }
205
+ }
206
+ out += text.slice(i, at) + hit.to
207
+ i = at + hit.from.length
208
+ count++
209
+ }
210
+ }
211
+
212
+ // matches where the char right after the needle continues the path
213
+ // (letter, digit, _, -) — likely substring overreach into a sibling name
214
+ function findSuspiciousContexts(repoRoot: string, needle: string, hitList: { file: string }[]): string[] {
215
+ const bad = /[A-Za-z0-9_-]/
216
+ const out: string[] = []
217
+ for (const h of hitList) {
218
+ let content: string
219
+ try {
220
+ content = readFileSync(join(repoRoot, h.file), 'utf8')
221
+ } catch {
222
+ continue
223
+ }
224
+ let i = 0
225
+ while (true) {
226
+ const idx = content.indexOf(needle, i)
227
+ if (idx === -1) break
228
+ const after = content[idx + needle.length]
229
+ if (after != null && bad.test(after)) {
230
+ const lineStart = content.lastIndexOf('\n', idx) + 1
231
+ const lineEnd = content.indexOf('\n', idx)
232
+ out.push(`${h.file}: ${content.slice(lineStart, lineEnd === -1 ? content.length : lineEnd).trim()}`)
233
+ }
234
+ i = idx + needle.length
235
+ }
236
+ }
237
+ return out
238
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bun
2
+ // the source-graph verbs, as one small binary the gate spawns: imports,
3
+ // cycles, rename. They live in shipkit rather than in a dependency, because a
4
+ // tool the gate REQUIRES must never be a published version behind the repo it
5
+ // gates (field: `shipkit mv` renamed a file and rewrote zero references,
6
+ // because the copy it resolved predated the fix).
7
+ import { cyclesCli } from 'rvlib-shipkit/src/ts/cycles.ts'
8
+ import { importsCli } from 'rvlib-shipkit/src/ts/imports.ts'
9
+ import { renameCli } from 'rvlib-shipkit/src/ts/rename.ts'
10
+
11
+ const [verb, ...rest] = process.argv.slice(2)
12
+ const run = (): number => {
13
+ if (verb === 'imports') return importsCli(rest)
14
+ if (verb === 'cycles') return cyclesCli(rest)
15
+ if (verb === 'rename' || verb === 'mv') return renameCli(rest)
16
+ console.log('usage: imports [--fix] | cycles | rename <src> <dst> [--apply]')
17
+ return 2
18
+ }
19
+ process.exit(run())
@@ -0,0 +1,26 @@
1
+ // which lines sit inside a multiline template literal — shared by the import
2
+ // rewriter and the cycle scanner so one concern has one handler.
3
+ //
4
+ // the walk is a per-line backtick parity toggle over SANITIZED lines (quoted
5
+ // strings and line comments blanked first). regex literals can still carry a
6
+ // stray backtick and desync the walk, so the result is only trusted when the
7
+ // file ENDS outside a template: valid source cannot end inside one, an
8
+ // in-template EOF means the walk desynced and callers scan unmasked (a loud
9
+ // false positive beats a silent unscanned tail).
10
+
11
+ /** true per line = the line STARTS inside a multiline template literal.
12
+ * null = the parity walk desynced (EOF inside a template), do not mask. */
13
+ export function templateLineMask(lines: string[]): boolean[] | null {
14
+ let inTemplate = false
15
+ const mask = lines.map((line) => {
16
+ const startedIn = inTemplate
17
+ const sanitized = line
18
+ .replace(/'(?:\\.|[^'\\])*'/g, "''")
19
+ .replace(/"(?:\\.|[^"\\])*"/g, '""')
20
+ .replace(/(^|\s)\/\/.*$/, '$1')
21
+ const backticks = (sanitized.match(/(?<!\\)`/g) ?? []).length
22
+ if (backticks % 2 === 1) inTemplate = !inTemplate
23
+ return startedIn
24
+ })
25
+ return inTemplate ? null : mask
26
+ }