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/config.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// shared config for every rvt tool. Convention: the repo uses absolute
|
|
2
|
+
// import specifiers with explicit extensions — repo-root paths
|
|
3
|
+
// ('src/foo/Bar.ts') in single-package repos; in multi-package repos, files
|
|
4
|
+
// living inside a sub-package (own package.json with a name) are addressed by
|
|
5
|
+
// package name instead ('rvlib-x/src/foo.ts'), so published source resolves
|
|
6
|
+
// identically for npm consumers. Sub-packages are auto-discovered by walking
|
|
7
|
+
// the roots. Config sources, in order:
|
|
8
|
+
// 1. package.json "rvt" field: { roots?: string[], skip?: string[] }
|
|
9
|
+
// (roots entries may be top-level globs like "rv-*")
|
|
10
|
+
// 2. tsconfig "paths" keys ("src/*" → root 'src') when roots are not set
|
|
11
|
+
// 3. fallback: ['src', 'scripts']
|
|
12
|
+
// skip entries are repo-relative dir prefixes; the defaults below always apply.
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
|
|
16
|
+
export type RvtPackage = {
|
|
17
|
+
name: string // package.json "name" — the import root for files inside
|
|
18
|
+
dir: string // repo-relative posix dir of the package
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type RvtConfig = {
|
|
22
|
+
root: string // absolute repo root
|
|
23
|
+
roots: string[] // repo-relative dirs to crawl / allowed specifier roots
|
|
24
|
+
skip: string[] // repo-relative dir prefixes to never touch
|
|
25
|
+
packages: RvtPackage[] // discovered sub-packages, longest dir first
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** never source, at any depth */
|
|
29
|
+
export const ALWAYS_SKIP = ['node_modules', '.git']
|
|
30
|
+
/** build OUTPUT, and only where output lands: the repo root or a package root.
|
|
31
|
+
* Matched at any depth these swallow real source, and a tree like
|
|
32
|
+
* `wiki/build/<lib>/src` becomes invisible to every scan (field: a rename
|
|
33
|
+
* that rewrote zero references and reported success) */
|
|
34
|
+
export const ARTIFACT_SKIP = ['dist', 'build', 'cjs', 'out']
|
|
35
|
+
export const DEFAULT_SKIP = [...ALWAYS_SKIP, ...ARTIFACT_SKIP]
|
|
36
|
+
|
|
37
|
+
export function findRepoRoot(cwd: string = process.cwd()): string {
|
|
38
|
+
const p = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { cwd, stdout: 'pipe', stderr: 'ignore' })
|
|
39
|
+
const out = p.exitCode === 0 ? p.stdout.toString().trim() : ''
|
|
40
|
+
return out !== '' ? out : cwd
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// tsconfig files are JSONC — strip comments + trailing commas, loosely
|
|
44
|
+
function parseJsonc(text: string): unknown {
|
|
45
|
+
const noComments = text
|
|
46
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
47
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1') // keep 'http://...' intact
|
|
48
|
+
return JSON.parse(noComments.replace(/,\s*([}\]])/g, '$1'))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function rootsFromTsconfig(root: string): string[] {
|
|
52
|
+
for (const name of ['tsconfig.base.json', 'tsconfig.json']) {
|
|
53
|
+
const p = join(root, name)
|
|
54
|
+
if (!existsSync(p)) continue
|
|
55
|
+
try {
|
|
56
|
+
const cfg = parseJsonc(readFileSync(p, 'utf8')) as { compilerOptions?: { paths?: Record<string, unknown> } }
|
|
57
|
+
const keys = Object.keys(cfg.compilerOptions?.paths ?? {})
|
|
58
|
+
// "src/*" → 'src'; ignore package-fixup mappings like "some-dep"
|
|
59
|
+
const roots = keys.filter((k) => k.endsWith('/*')).map((k) => k.slice(0, -2)).filter((k) => !k.includes('/') && existsSync(join(root, k)))
|
|
60
|
+
if (roots.length > 0) return roots
|
|
61
|
+
} catch {
|
|
62
|
+
// unparseable tsconfig → fall through to the next source
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// "rv-*" style entries expand against top-level dirs; plain entries pass through
|
|
69
|
+
function expandRootGlobs(root: string, roots: string[]): string[] {
|
|
70
|
+
const out: string[] = []
|
|
71
|
+
for (const r of roots) {
|
|
72
|
+
if (!r.includes('*')) {
|
|
73
|
+
out.push(r)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
const re = new RegExp(`^${r.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`)
|
|
77
|
+
for (const e of readdirSync(root)) {
|
|
78
|
+
// statSync try: a dangling top-level symlink must not crash every rvt command
|
|
79
|
+
let isDir = false
|
|
80
|
+
try {
|
|
81
|
+
isDir = statSync(join(root, e)).isDirectory()
|
|
82
|
+
} catch {}
|
|
83
|
+
if (re.test(e) && isDir) out.push(e)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...new Set(out)].sort()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// sub-package discovery: any dir under the roots with a named package.json is
|
|
90
|
+
// its own import root (files inside address each other via the package name)
|
|
91
|
+
function discoverPackages(root: string, roots: string[], skip: string[]): RvtPackage[] {
|
|
92
|
+
const found: RvtPackage[] = []
|
|
93
|
+
const cfg = { root, roots, skip, packages: [] }
|
|
94
|
+
const walk = (rel: string): void => {
|
|
95
|
+
const abs = join(root, rel)
|
|
96
|
+
let entries
|
|
97
|
+
try {
|
|
98
|
+
entries = readdirSync(abs, { withFileTypes: true })
|
|
99
|
+
} catch {
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
const pkgFile = join(abs, 'package.json')
|
|
103
|
+
if (existsSync(pkgFile)) {
|
|
104
|
+
try {
|
|
105
|
+
const name = (JSON.parse(readFileSync(pkgFile, 'utf8')) as { name?: string }).name
|
|
106
|
+
if (name != null && name !== '') found.push({ name, dir: rel })
|
|
107
|
+
} catch {
|
|
108
|
+
// unparseable sub-package manifest → not a package boundary
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const e of entries) {
|
|
112
|
+
if (e.isDirectory() && !isSkipped(`${rel}/${e.name}`, cfg)) walk(`${rel}/${e.name}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const r of roots) if (existsSync(join(root, r))) walk(r)
|
|
116
|
+
// longest dir first: nested packages resolve to the innermost boundary
|
|
117
|
+
return found.sort((a, b) => b.dir.length - a.dir.length)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function loadConfig(cwd: string = process.cwd()): RvtConfig {
|
|
121
|
+
const root = findRepoRoot(cwd)
|
|
122
|
+
let roots: string[] = []
|
|
123
|
+
let skip: string[] = []
|
|
124
|
+
const pkgPath = join(root, 'package.json')
|
|
125
|
+
if (existsSync(pkgPath)) {
|
|
126
|
+
try {
|
|
127
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { rvt?: { roots?: string[]; skip?: string[] } }
|
|
128
|
+
roots = pkg.rvt?.roots ?? []
|
|
129
|
+
skip = pkg.rvt?.skip ?? []
|
|
130
|
+
} catch {
|
|
131
|
+
// unparseable package.json → defaults
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (roots.length === 0) roots = rootsFromTsconfig(root)
|
|
135
|
+
if (roots.length === 0) roots = ['src', 'scripts'].filter((r) => existsSync(join(root, r)))
|
|
136
|
+
roots = expandRootGlobs(root, roots)
|
|
137
|
+
const mergedSkip = [...DEFAULT_SKIP, ...skip]
|
|
138
|
+
const packages = discoverPackages(root, roots, mergedSkip)
|
|
139
|
+
// a package named like a root (or another package) hijacks that specifier
|
|
140
|
+
// prefix — resolution would silently diverge from tsc/bun; be loud
|
|
141
|
+
const seen = new Set<string>(roots)
|
|
142
|
+
for (const p of packages) {
|
|
143
|
+
if (seen.has(p.name)) console.error(`rvt WARN: package name '${p.name}' (${p.dir}) collides with a root or another package — its specifiers may resolve wrong`)
|
|
144
|
+
seen.add(p.name)
|
|
145
|
+
}
|
|
146
|
+
return { root, roots, skip: mergedSkip, packages }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// innermost package containing a repo-relative path, or null
|
|
150
|
+
export function packageFor(relPath: string, cfg: RvtConfig): RvtPackage | null {
|
|
151
|
+
const n = toPosix(relPath)
|
|
152
|
+
return cfg.packages.find((p) => n === p.dir || n.startsWith(`${p.dir}/`)) ?? null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// canonical specifier for a repo-relative file: package-name form when the
|
|
156
|
+
// file lives inside a sub-package, repo-root form otherwise
|
|
157
|
+
export function canonicalSpecifier(relPath: string, cfg: RvtConfig): string {
|
|
158
|
+
const pkg = packageFor(relPath, cfg)
|
|
159
|
+
return pkg == null ? toPosix(relPath) : `${pkg.name}${toPosix(relPath).slice(pkg.dir.length)}`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// resolve a package-name specifier ('rvlib-x/src/a.ts') back to a
|
|
163
|
+
// repo-relative path, or null when no discovered package matches
|
|
164
|
+
export function resolvePackageSpecifier(spec: string, cfg: RvtConfig): string | null {
|
|
165
|
+
const pkg = cfg.packages.find((p) => spec === p.name || spec.startsWith(`${p.name}/`))
|
|
166
|
+
return pkg == null ? null : `${pkg.dir}${spec.slice(pkg.name.length)}`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const toPosix = (p: string): string => p.replace(/\\/g, '/')
|
|
170
|
+
|
|
171
|
+
// entries with a slash ('src/legacy') match as repo-relative prefixes. A bare
|
|
172
|
+
// name matches any segment when it is never source (node_modules, .git), and
|
|
173
|
+
// only at a package boundary when it names build OUTPUT: `dist` under a
|
|
174
|
+
// package root is output, `wiki/build/<lib>/src` is source.
|
|
175
|
+
export function isSkipped(relPath: string, cfg: RvtConfig, packageDirs: readonly string[] = []): boolean {
|
|
176
|
+
const n = toPosix(relPath)
|
|
177
|
+
const segs = n.split('/')
|
|
178
|
+
return cfg.skip.some((s) => {
|
|
179
|
+
if (s.includes('/')) return n === s || n.startsWith(`${s}/`)
|
|
180
|
+
if (!ARTIFACT_SKIP.includes(s)) return segs.includes(s)
|
|
181
|
+
// output dir: at the repo root, or directly inside a known package
|
|
182
|
+
if (segs[0] === s) return true
|
|
183
|
+
return packageDirs.some((d) => n === `${d}/${s}` || n.startsWith(`${d}/${s}/`))
|
|
184
|
+
})
|
|
185
|
+
}
|
package/src/ts/cycles.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// zero-dep circular import detector. The repo uses absolute repo-root import
|
|
2
|
+
// specifiers ('src/...') with explicit .ts extensions, so resolution is string
|
|
3
|
+
// identity: crawl .ts/.tsx files, regex out the runtime import specifiers
|
|
4
|
+
// (type-only imports don't count), build the graph, tarjan the SCCs, enumerate
|
|
5
|
+
// elementary cycles. Runs on a bare checkout, no install needed.
|
|
6
|
+
import { readdirSync, readFileSync } from 'fs'
|
|
7
|
+
import { dirname, join } from 'path'
|
|
8
|
+
import { isSkipped, loadConfig, resolvePackageSpecifier, toPosix, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
|
|
9
|
+
import { templateLineMask } from 'rvlib-shipkit/src/ts/source-mask.ts'
|
|
10
|
+
|
|
11
|
+
const CYCLE_CAP = 500 // bail loudly instead of exploding on a pathological graph
|
|
12
|
+
|
|
13
|
+
function crawl(cfg: RvtConfig, dir: string, out: string[]): void {
|
|
14
|
+
let entries
|
|
15
|
+
try {
|
|
16
|
+
entries = readdirSync(join(cfg.root, dir), { withFileTypes: true })
|
|
17
|
+
} catch {
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const rel = `${dir}/${entry.name}`
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
if (!isSkipped(rel, cfg)) crawl(cfg, rel, out)
|
|
24
|
+
} else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
|
|
25
|
+
out.push(rel)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// strip comments (line comments only when preceded by whitespace, so
|
|
31
|
+
// 'http://...' survives) and type-only import/export statements, then extract
|
|
32
|
+
// the remaining runtime specifiers
|
|
33
|
+
export function extractSpecs(content: string): string[] {
|
|
34
|
+
// template-literal content is text, a codegen emitting import lines inside
|
|
35
|
+
// a backtick block is not an import of this module. single-line templates
|
|
36
|
+
// strip in place, multiline ones blank via the shared mask (null mask =
|
|
37
|
+
// desynced walk, scan everything rather than eat code between backticks)
|
|
38
|
+
const rawLines = content.split('\n')
|
|
39
|
+
const mask = templateLineMask(rawLines)
|
|
40
|
+
const demasked = mask == null ? content : rawLines.map((l, i) => (mask[i] ? '' : l)).join('\n')
|
|
41
|
+
const cleaned = demasked
|
|
42
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
43
|
+
.replace(/(^|\s)\/\/[^\n]*/g, '$1')
|
|
44
|
+
.replace(/`(?:\\.|[^`\\\n])*`/g, '``')
|
|
45
|
+
.replace(/\b(?:import|export)\s+type\s[^'"]*?from\s*(['"])[^'"\n]*\1/g, '')
|
|
46
|
+
const specs: string[] = []
|
|
47
|
+
// import x from / export { x } from — skipped when every specifier is `type X` (erased at runtime)
|
|
48
|
+
for (const m of cleaned.matchAll(/\b(?:import|export)\s([^'";]*?)from\s*['"]([^'"\n]+)['"]/g)) {
|
|
49
|
+
const clause = (m[1] ?? '').trim()
|
|
50
|
+
if (/^\{[^{}]*\}$/.test(clause)) {
|
|
51
|
+
const items = clause.slice(1, -1).split(',').map((s) => s.trim()).filter((s) => s !== '')
|
|
52
|
+
if (items.length > 0 && items.every((s) => /^type\s/.test(s))) continue
|
|
53
|
+
}
|
|
54
|
+
if (m[2] != null) specs.push(m[2])
|
|
55
|
+
}
|
|
56
|
+
for (const re of [
|
|
57
|
+
/\bimport\s*['"]([^'"\n]+)['"]/g, // side-effect import
|
|
58
|
+
/\b(?:import|require)\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g, // dynamic import / require
|
|
59
|
+
]) {
|
|
60
|
+
for (const m of cleaned.matchAll(re)) {
|
|
61
|
+
if (m[1] != null) specs.push(m[1])
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return specs
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveSpec(cfg: RvtConfig, fromFile: string, spec: string, fileSet: Set<string>): string | null {
|
|
68
|
+
let base: string
|
|
69
|
+
const viaPackage = resolvePackageSpecifier(spec, cfg)
|
|
70
|
+
if (spec.startsWith('./') || spec.startsWith('../')) {
|
|
71
|
+
base = toPosix(join(dirname(fromFile), spec))
|
|
72
|
+
} else if (viaPackage != null) {
|
|
73
|
+
base = viaPackage // package-name specifier → the sub-package's dir
|
|
74
|
+
} else if (cfg.roots.some((r) => spec.startsWith(`${r}/`))) {
|
|
75
|
+
base = spec
|
|
76
|
+
} else {
|
|
77
|
+
return null // third-party
|
|
78
|
+
}
|
|
79
|
+
for (const candidate of [base, base.replace(/\.js$/, '.ts'), `${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`]) {
|
|
80
|
+
if (fileSet.has(candidate)) return candidate
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// tarjan strongly connected components, iterative to survive deep graphs
|
|
86
|
+
function tarjanSCCs(nodes: string[], adj: Map<string, string[]>): string[][] {
|
|
87
|
+
const index = new Map<string, number>()
|
|
88
|
+
const low = new Map<string, number>()
|
|
89
|
+
const onStack = new Set<string>()
|
|
90
|
+
const stack: string[] = []
|
|
91
|
+
const sccs: string[][] = []
|
|
92
|
+
let counter = 0
|
|
93
|
+
for (const start of nodes) {
|
|
94
|
+
if (index.has(start)) continue
|
|
95
|
+
const work: [string, number][] = [[start, 0]]
|
|
96
|
+
while (work.length > 0) {
|
|
97
|
+
const frame = work[work.length - 1]!
|
|
98
|
+
const v = frame[0]
|
|
99
|
+
if (frame[1] === 0) {
|
|
100
|
+
index.set(v, counter)
|
|
101
|
+
low.set(v, counter)
|
|
102
|
+
counter++
|
|
103
|
+
stack.push(v)
|
|
104
|
+
onStack.add(v)
|
|
105
|
+
}
|
|
106
|
+
const neighbors = adj.get(v) ?? []
|
|
107
|
+
let recursed = false
|
|
108
|
+
while (frame[1] < neighbors.length) {
|
|
109
|
+
const w = neighbors[frame[1]]!
|
|
110
|
+
frame[1]++
|
|
111
|
+
if (!index.has(w)) {
|
|
112
|
+
work.push([w, 0])
|
|
113
|
+
recursed = true
|
|
114
|
+
break
|
|
115
|
+
} else if (onStack.has(w)) {
|
|
116
|
+
low.set(v, Math.min(low.get(v)!, index.get(w)!))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (recursed) continue
|
|
120
|
+
if (low.get(v) === index.get(v)) {
|
|
121
|
+
const scc: string[] = []
|
|
122
|
+
while (true) {
|
|
123
|
+
const w = stack.pop()!
|
|
124
|
+
onStack.delete(w)
|
|
125
|
+
scc.push(w)
|
|
126
|
+
if (w === v) break
|
|
127
|
+
}
|
|
128
|
+
sccs.push(scc)
|
|
129
|
+
}
|
|
130
|
+
work.pop()
|
|
131
|
+
if (work.length > 0) {
|
|
132
|
+
const parent = work[work.length - 1]![0]
|
|
133
|
+
low.set(parent, Math.min(low.get(parent)!, low.get(v)!))
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return sccs
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// elementary cycles inside one SCC: dfs from each node, only through nodes ranked >= start
|
|
141
|
+
function cyclesInSCC(scc: string[], adj: Map<string, string[]>): string[][] {
|
|
142
|
+
const rank = new Map(scc.map((n, i) => [n, i]))
|
|
143
|
+
const cycles: string[][] = []
|
|
144
|
+
for (const start of scc) {
|
|
145
|
+
const startRank = rank.get(start)!
|
|
146
|
+
const path: string[] = [start]
|
|
147
|
+
const onPath = new Set([start])
|
|
148
|
+
const dfs = (v: string): void => {
|
|
149
|
+
if (cycles.length >= CYCLE_CAP) return
|
|
150
|
+
for (const w of adj.get(v) ?? []) {
|
|
151
|
+
const r = rank.get(w)
|
|
152
|
+
if (r == null || r < startRank) continue
|
|
153
|
+
if (w === start) cycles.push([...path])
|
|
154
|
+
else if (!onPath.has(w)) {
|
|
155
|
+
path.push(w)
|
|
156
|
+
onPath.add(w)
|
|
157
|
+
dfs(w)
|
|
158
|
+
path.pop()
|
|
159
|
+
onPath.delete(w)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
dfs(start)
|
|
164
|
+
}
|
|
165
|
+
return cycles
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function findCircularDependencies(cfg: RvtConfig = loadConfig()): string[][] {
|
|
169
|
+
const files: string[] = []
|
|
170
|
+
for (const root of cfg.roots) crawl(cfg, root, files)
|
|
171
|
+
files.sort()
|
|
172
|
+
const fileSet = new Set(files)
|
|
173
|
+
const adj = new Map<string, string[]>()
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
const targets = new Set<string>()
|
|
176
|
+
for (const spec of extractSpecs(readFileSync(join(cfg.root, file), 'utf8'))) {
|
|
177
|
+
const target = resolveSpec(cfg, file, spec, fileSet)
|
|
178
|
+
if (target != null && target !== file) targets.add(target)
|
|
179
|
+
}
|
|
180
|
+
adj.set(file, [...targets].sort())
|
|
181
|
+
}
|
|
182
|
+
const cycles: string[][] = []
|
|
183
|
+
for (const scc of tarjanSCCs(files, adj)) {
|
|
184
|
+
if (scc.length < 2) continue
|
|
185
|
+
cycles.push(...cyclesInSCC(scc.sort(), adj))
|
|
186
|
+
}
|
|
187
|
+
if (cycles.length >= CYCLE_CAP) {
|
|
188
|
+
throw new Error(`cycle enumeration hit the cap (${CYCLE_CAP}); graph is pathologically cyclic`)
|
|
189
|
+
}
|
|
190
|
+
return cycles
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// exit 0 when cycles <= max, 1 otherwise — the arg is a CI ratchet
|
|
194
|
+
export function cyclesCli(argv: string[]): number {
|
|
195
|
+
const parsed = Number(argv[0] ?? '1')
|
|
196
|
+
const max = Number.isFinite(parsed) && parsed >= 0 ? parsed : 1 // 0 is a legit ratchet
|
|
197
|
+
const started = performance.now()
|
|
198
|
+
const cycles = findCircularDependencies()
|
|
199
|
+
cycles.forEach((cycle, ix) => {
|
|
200
|
+
console.log(`Cycle ${ix + 1}:`)
|
|
201
|
+
cycle.forEach((node, cix) => console.log(` ${cix + 1}. ${node}`))
|
|
202
|
+
})
|
|
203
|
+
console.log(`${cycles.length} cycles found in ${Math.round(performance.now() - started)}ms (max allowed: ${max})`)
|
|
204
|
+
return cycles.length > max ? 1 : 0
|
|
205
|
+
}
|
|
@@ -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
|
+
}
|