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,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
+ }