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.
@@ -0,0 +1,77 @@
1
+ // SK011 — a dependency range on a SIBLING release unit must be satisfied by
2
+ // the version that sibling actually carries (build/shipkit.md, release units).
3
+ //
4
+ // The failure it exists for: one unit is edited and its dependents keep a
5
+ // range that names a version nobody published, or an older one that lacks the
6
+ // code they now call. Both are invisible until a consumer installs, which is
7
+ // the worst place to find out. `shipkit bump` moves these ranges forward on
8
+ // every bump, so a red here means someone hand-edited a range, or a bump was
9
+ // interrupted.
10
+ //
11
+ // Local protocols (link:, file:) are SK006's story, not this rule's.
12
+ import { existsSync, readFileSync } from 'fs'
13
+ import { join } from 'path'
14
+ import { resolveUnits } from '../release/units.ts'
15
+ import type { Rule } from './rule.ts'
16
+
17
+ type Pkg = {
18
+ dependencies?: Record<string, string>
19
+ peerDependencies?: Record<string, string>
20
+ optionalDependencies?: Record<string, string>
21
+ devDependencies?: Record<string, string>
22
+ }
23
+
24
+ const BLOCKS = ['dependencies', 'peerDependencies', 'optionalDependencies', 'devDependencies'] as const
25
+
26
+ /** does `version` satisfy a caret/tilde/exact range? Deliberately small: the
27
+ * ranges this repo writes are `^x.y.z`, `~x.y.z` or an exact pin, and a range
28
+ * shape it cannot read is SKIPPED rather than guessed at. */
29
+ export function satisfiesSimple(version: string, range: string): boolean | null {
30
+ const v = version.match(/^(\d+)\.(\d+)\.(\d+)/)
31
+ const r = range.trim().match(/^([\^~]?)(\d+)\.(\d+)\.(\d+)$/)
32
+ if (v == null || r == null) return null
33
+ const [, op, RA, RI, RP] = r as unknown as [string, string, string, string, string]
34
+ const [va, vi, vp] = [Number(v[1]), Number(v[2]), Number(v[3])]
35
+ const [ra, ri, rp] = [Number(RA), Number(RI), Number(RP)]
36
+ const atLeast = va > ra || (va === ra && (vi > ri || (vi === ri && vp >= rp)))
37
+ if (op === '^') {
38
+ // caret on 0.x pins the MINOR: ^0.1.4 accepts 0.1.9, never 0.2.0
39
+ if (ra === 0) return va === 0 && vi === ri && atLeast
40
+ return va === ra && atLeast
41
+ }
42
+ if (op === '~') return va === ra && vi === ri && atLeast
43
+ return va === ra && vi === ri && vp === rp
44
+ }
45
+
46
+ export const siblingUnitRanges: Rule = {
47
+ id: 'SK011',
48
+ blocking: true,
49
+ title: 'sibling unit ranges',
50
+ owner: 'build/shipkit.md',
51
+ lint: (t, env) => {
52
+ const { units } = resolveUnits(env.root, env.config)
53
+ const version = new Map(units.map((u) => [u.name, u.version]))
54
+ if (units.length < 2) return t.ok('one release unit — no sibling ranges to keep honest')
55
+ const bad: string[] = []
56
+ let checked = 0
57
+ for (const u of units) {
58
+ const p = join(u.dir, 'package.json')
59
+ if (!existsSync(p)) continue
60
+ let pkg: Pkg
61
+ try { pkg = JSON.parse(readFileSync(p, 'utf8')) as Pkg } catch { continue }
62
+ for (const block of BLOCKS)
63
+ for (const [dep, range] of Object.entries(pkg[block] ?? {})) {
64
+ const actual = version.get(dep)
65
+ if (actual == null) continue
66
+ if (/^(link|file|portal|workspace):/.test(range)) continue
67
+ const ok = satisfiesSimple(actual, range)
68
+ if (ok == null) continue
69
+ checked++
70
+ if (!ok) bad.push(`${u.id}: ${block}.${dep} wants ${range}, ${dep} is ${actual}`)
71
+ }
72
+ }
73
+ if (bad.length > 0)
74
+ t.fail(`${bad.join(' · ')} — a range naming a version its sibling does not carry breaks at INSTALL, for a consumer. \`shipkit bump\` moves these forward, so fix the bump rather than the range`)
75
+ else t.ok(`${checked} sibling range(s) match the version each unit carries`)
76
+ },
77
+ }
@@ -13,6 +13,7 @@ import { githubVisibility } from './SK007-github-visibility.ts'
13
13
  import { reviewerAgent } from './SK008-reviewer-agent.ts'
14
14
  import { commitAiTells } from './SK009-commit-ai-tells.ts'
15
15
  import { undeclaredReleaseUnit } from './SK010-undeclared-release-unit.ts'
16
+ import { siblingUnitRanges } from './SK011-sibling-unit-ranges.ts'
16
17
 
17
18
  export const RULES: readonly Rule[] = [
18
19
  packageJsonShape,
@@ -25,6 +26,7 @@ export const RULES: readonly Rule[] = [
25
26
  reviewerAgent,
26
27
  commitAiTells,
27
28
  undeclaredReleaseUnit,
29
+ siblingUnitRanges,
28
30
  ]
29
31
 
30
32
  // THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
@@ -129,7 +129,7 @@ export class ColsSvc {
129
129
  async remark(body: Record<string, unknown>): Promise<void> {
130
130
  const text = this.remarkDraft.trim()
131
131
  if (text === '') return
132
- // draft survives a failed send — his words are never erased
132
+ // draft survives a failed send, so nothing typed is ever erased
133
133
  const ok = await this.act('remark', { ...body, text })
134
134
  if (ok) runInAction(() => { this.remarkDraft = '' })
135
135
  }
@@ -173,7 +173,7 @@ const Palette = observer(() => {
173
173
  )
174
174
  })
175
175
 
176
- // remark box on the ASSET view too — his words land in .rv-assets/notes.md
176
+ // remark box on the ASSET view too: remarks land in .rv-assets/notes.md
177
177
  // and come back as `remarks` (exchange both ways, media included)
178
178
  const AssetRemarks = observer(() => {
179
179
  const m = app.media
@@ -28,7 +28,7 @@ export function defaultHubPort(name: string): number {
28
28
 
29
29
  export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o?: { port?: number }): Promise<HubDeps> {
30
30
  // the default wiki graph exists only when the repo HAS a wiki dir: a
31
- // game repo's hub advertising a wiki route was startup noise (his catch
31
+ // game repo's hub advertising a wiki route was startup noise
32
32
  // 2026-08-05). A config-declared wikiGraph always mounts.
33
33
  const wikiGraph = c.serve?.wikiGraph != null
34
34
  ? { title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load }
@@ -109,11 +109,13 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
109
109
  try {
110
110
  const hub = serveHub(deps)
111
111
  const base = `http://localhost:${hub.server.port}`
112
- io.print(`ok: ${c.name} hub → ${base}`)
113
- // the '/' tool IS the banner line above — printing it again was noise
114
- for (const t of hub.tools) if (t.path !== '/') io.print(`ok: ${t.label} → ${base}${t.path}`)
112
+ // a banner, not a checklist: one clickable line, then what is inside,
113
+ // dimmed by indentation. Wire prefixes stay reserved for problems.
114
+ io.print(`${c.name} hub → ${base}`)
115
+ const routes = hub.tools.filter((t) => t.path !== '/').map((t) => `${t.label} ${t.path}`).join(' · ')
116
+ if (routes !== '') io.print(` also: ${routes}`)
115
117
  // the off-machine door (phone, other machines) — publicHost is config
116
- if (c.serve?.publicHost != null) io.print(`ok: public → http://${c.serve.publicHost}:${hub.server.port}/`)
118
+ if (c.serve?.publicHost != null) io.print(` phone: http://${c.serve.publicHost}:${hub.server.port}/`)
117
119
  await new Promise(() => {}) // the hub owns the process — never resolve
118
120
  return 0
119
121
  } catch (e) {
@@ -126,8 +128,9 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
126
128
  if (owner?.hub === 'shipkit' && owner.name === c.name) {
127
129
  // a re-run terminal stays as clickable as a first run — the
128
130
  // owner's /hub.json tool list is the same source of truth
129
- io.print(`ok: hub already served → http://localhost:${deps.port} (another window owns it)`)
130
- for (const t of owner.tools ?? []) if (t.path !== '/') io.print(`ok: ${t.label} → http://localhost:${deps.port}${t.path}`)
131
+ io.print(`${c.name} hub → http://localhost:${deps.port} (already running in another window)`)
132
+ const routes = (owner.tools ?? []).filter((t) => t.path !== '/').map((t) => `${t.label} ${t.path}`).join(' · ')
133
+ if (routes !== '') io.print(` also: ${routes}`)
131
134
  return 0
132
135
  }
133
136
  io.printErr(`FAIL: port ${deps.port} is taken by ${owner?.name != null ? `repo '${owner.name}'` : 'another process'} — set serve: { port } in repo.config.ts to a free one`)
@@ -99,8 +99,8 @@ export function serveHub(d: HubDeps): Hub {
99
99
  }
100
100
  else if (path === '/api/act/post-feedback') appendPostFeedback(d.root, b.channel ?? '', b.file ?? '', b.text ?? '')
101
101
  else if (path === '/api/act/remark') {
102
- // ONE remark verb, routed by collection — his words
103
- // land in the item's own truth file
102
+ // ONE remark verb, routed by collection: a remark
103
+ // lands in the item's own truth file
104
104
  const text = (b.text ?? '').trim()
105
105
  if (text === '') return new Response('empty remark', { status: 400 })
106
106
  if (b.kind === 'ask') appendAskRemark(d.root, d.asksDir, b.file ?? '', text)
package/src/start-repo.ts CHANGED
@@ -71,8 +71,8 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
71
71
  // 0 → shipkit'd with the one command. init itself stays cwd-only, like
72
72
  // git init. A brand-new empty dir gets git + its remote all the same, and
73
73
  // is told what is next, because the shipkit scaffold needs a package.json.
74
- // --skip means shipkit NEVER TOUCHES that repo (his job repo lives on
75
- // this list), not just no CLAUDE.md. And the cd contract survives a
74
+ // --skip means shipkit NEVER TOUCHES a listed repo (a day-job checkout
75
+ // belongs on that list), not just no CLAUDE.md. And the cd contract survives a
76
76
  // failed adoption: the dir always prints, wiring trouble is a WARN.
77
77
  if (!(opts.skip ?? []).includes(name)) {
78
78
  // git BEFORE the shipkit scaffold, and whether or not the dir is a
@@ -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
+ }
@@ -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
+ }