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.
- package/CHANGELOG.md +27 -2
- package/package.json +7 -4
- package/src/cli/cmd_Asks.ts +1 -1
- package/src/cli/cmd_Mv.ts +7 -10
- package/src/collections/actions.ts +3 -3
- package/src/collections/collection-types.ts +1 -1
- package/src/collections/providers.ts +2 -2
- package/src/core/check-suite.ts +3 -3
- package/src/core/run-fixers.ts +3 -4
- package/src/git-bootstrap.ts +24 -1
- package/src/hooks/leak-guard.ts +6 -0
- package/src/hooks/run-hooks.ts +3 -3
- package/src/reflect/prompt-corpus.ts +1 -1
- package/src/release/run-bump.ts +29 -0
- package/src/release/run-release.ts +22 -0
- package/src/release/run-releases.ts +5 -1
- package/src/release/units.ts +48 -2
- package/src/repo-config.ts +5 -4
- package/src/rules/SK005-rvlib-versions.ts +13 -1
- package/src/rules/SK006-npm-publish-safety.ts +37 -3
- package/src/rules/SK011-sibling-unit-ranges.ts +77 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/serve/hub-app/cols-svc.ts +1 -1
- package/src/serve/hub-app/hub-app.tsx +1 -1
- package/src/serve/run-serve.ts +10 -7
- package/src/serve/serve-hub.ts +2 -2
- package/src/start-repo.ts +2 -2
- package/src/ts/config.ts +185 -0
- package/src/ts/cycles.ts +205 -0
- package/src/ts/imports.ts +255 -0
- package/src/ts/rename.ts +238 -0
- package/src/ts/source-graph-cli.ts +19 -0
- package/src/ts/source-mask.ts +26 -0
|
@@ -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
|
+
}
|
package/src/ts/rename.ts
ADDED
|
@@ -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
|
+
}
|