rvlib-shipkit 0.22.2 → 0.24.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 +35 -2
- package/package.json +7 -4
- package/src/cli/cmd_Asks.ts +1 -1
- package/src/cli/cmd_Mv.ts +7 -10
- package/src/cli/cmd_Start.ts +2 -2
- 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 +29 -4
- 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 +91 -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/hub-app/hub.css +9 -3
- package/src/serve/hub-app/media-svc.ts +19 -4
- package/src/serve/hub-app/media-view.tsx +22 -9
- package/src/serve/run-serve.ts +10 -7
- package/src/serve/serve-hub.ts +2 -2
- package/src/start-repo.ts +50 -8
- 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
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
|
+
}
|