rvlib-shipkit 0.22.1 → 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,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
+ }