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.
- package/CHANGELOG.md +32 -2
- package/package.json +7 -4
- package/src/cli/cmd_Asks.ts +1 -1
- package/src/cli/cmd_Mv.ts +7 -10
- package/src/collections/actions.ts +3 -3
- package/src/collections/collection-types.ts +1 -1
- package/src/collections/providers.ts +2 -2
- package/src/core/check-suite.ts +3 -3
- package/src/core/run-check.ts +3 -1
- package/src/core/run-ci.ts +7 -3
- package/src/core/run-fixers.ts +3 -4
- package/src/git-bootstrap.ts +24 -1
- package/src/hooks/leak-guard.ts +6 -0
- package/src/hooks/run-hooks.ts +3 -3
- package/src/reflect/prompt-corpus.ts +1 -1
- package/src/release/run-bump.ts +29 -0
- package/src/release/run-release.ts +22 -0
- package/src/release/run-releases.ts +5 -1
- package/src/release/units.ts +48 -2
- package/src/repo-config.ts +5 -4
- package/src/rules/SK005-rvlib-versions.ts +13 -1
- package/src/rules/SK006-npm-publish-safety.ts +37 -3
- package/src/rules/SK011-sibling-unit-ranges.ts +77 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/serve/hub-app/cols-svc.ts +1 -1
- package/src/serve/hub-app/hub-app.tsx +1 -1
- package/src/serve/run-serve.ts +15 -6
- package/src/serve/serve-hub.ts +7 -6
- package/src/start-repo.ts +2 -2
- package/src/ts/config.ts +185 -0
- package/src/ts/cycles.ts +205 -0
- package/src/ts/imports.ts +255 -0
- package/src/ts/rename.ts +238 -0
- package/src/ts/source-graph-cli.ts +19 -0
- package/src/ts/source-mask.ts +26 -0
|
@@ -14,9 +14,23 @@
|
|
|
14
14
|
// and sanctioned sub-lib publishing must not turn this rule red.
|
|
15
15
|
import { existsSync, readFileSync } from 'fs'
|
|
16
16
|
import { join } from 'path'
|
|
17
|
+
import { resolveUnits } from '../release/units.ts'
|
|
17
18
|
import type { Rule } from './rule.ts'
|
|
18
19
|
|
|
19
|
-
type Pkg = {
|
|
20
|
+
type Pkg = {
|
|
21
|
+
name?: string
|
|
22
|
+
private?: boolean
|
|
23
|
+
publishConfig?: unknown
|
|
24
|
+
scripts?: Record<string, string>
|
|
25
|
+
dependencies?: Record<string, string>
|
|
26
|
+
peerDependencies?: Record<string, string>
|
|
27
|
+
optionalDependencies?: Record<string, string>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** protocols that resolve to a path on THIS machine. In a block a consumer
|
|
31
|
+
* installs, they are unresolvable: the published package is broken on
|
|
32
|
+
* arrival, and it fails at install time for a stranger, never here. */
|
|
33
|
+
const LOCAL_PROTOCOL = /^(link|file|portal|workspace):/
|
|
20
34
|
|
|
21
35
|
export const npmPublishSafety: Rule = {
|
|
22
36
|
id: 'SK006',
|
|
@@ -28,7 +42,27 @@ export const npmPublishSafety: Rule = {
|
|
|
28
42
|
if (!existsSync(p)) return t.ok('no package.json — nothing publishable')
|
|
29
43
|
let pkg: Pkg
|
|
30
44
|
try { pkg = JSON.parse(readFileSync(p, 'utf8')) as Pkg } catch { return t.note('package.json unreadable — SK001 reports it') }
|
|
31
|
-
|
|
45
|
+
// every block a CONSUMER resolves, for every unit that can publish:
|
|
46
|
+
// the ROOT is usually private, so scanning it alone guards nothing
|
|
47
|
+
const localHits: string[] = []
|
|
48
|
+
for (const u of [{ dir: env.root, id: 'root' }, ...resolveUnits(env.root, env.config).units.filter((x) => x.rel !== '')]) {
|
|
49
|
+
const up = join(u.dir, 'package.json')
|
|
50
|
+
if (!existsSync(up)) continue
|
|
51
|
+
let upkg: Pkg
|
|
52
|
+
try { upkg = JSON.parse(readFileSync(up, 'utf8')) as Pkg } catch { continue }
|
|
53
|
+
if (upkg.private === true) continue
|
|
54
|
+
for (const block of ['dependencies', 'peerDependencies', 'optionalDependencies'] as const)
|
|
55
|
+
for (const [dep, range] of Object.entries(upkg[block] ?? {}))
|
|
56
|
+
if (LOCAL_PROTOCOL.test(range)) localHits.push(`${u.id}: ${block}.${dep} = ${range}`)
|
|
57
|
+
}
|
|
58
|
+
if (localHits.length > 0)
|
|
59
|
+
t.fail(
|
|
60
|
+
`${localHits.join(', ')} — a local protocol cannot resolve on a consumer's machine, so publishing that ships a package which fails at install. Declare the version it WILL be, and keep the local wiring in overrides (npm ignores those from a dependency) or in devDependencies`,
|
|
61
|
+
)
|
|
62
|
+
if (pkg.name?.startsWith('rvlib-') === true) {
|
|
63
|
+
if (localHits.length === 0) t.ok('rvlib-* package — publishes by design (distribute/npm owns it)')
|
|
64
|
+
return
|
|
65
|
+
}
|
|
32
66
|
if (pkg.private !== true) return t.note('private:true missing — SK001 reports it; publish machinery not judged until the lock is on')
|
|
33
67
|
const machinery: string[] = []
|
|
34
68
|
if (pkg.publishConfig != null) machinery.push('publishConfig')
|
|
@@ -38,6 +72,6 @@ export const npmPublishSafety: Rule = {
|
|
|
38
72
|
machinery.push(`scripts.publish (runs npm/bun publish)`)
|
|
39
73
|
if (machinery.length > 0)
|
|
40
74
|
t.fail(`private package carries publish machinery: ${machinery.join(', ')} — dead weight now, a loaded gun the day private flips; delete it or make the repo a published lib on purpose`)
|
|
41
|
-
else t.ok(`private package — no publishConfig / prepublish* / npm-publishing 'publish' script on the root`)
|
|
75
|
+
else if (localHits.length === 0) t.ok(`private package — no publishConfig / prepublish* / npm-publishing 'publish' script on the root, no local dependency protocol in any unit`)
|
|
42
76
|
},
|
|
43
77
|
}
|
|
@@ -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
|
|
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
|
|
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
|
package/src/serve/run-serve.ts
CHANGED
|
@@ -27,9 +27,14 @@ export function defaultHubPort(name: string): number {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o?: { port?: number }): Promise<HubDeps> {
|
|
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
|
|
32
|
+
// 2026-08-05). A config-declared wikiGraph always mounts.
|
|
30
33
|
const wikiGraph = c.serve?.wikiGraph != null
|
|
31
34
|
? { title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load }
|
|
32
|
-
:
|
|
35
|
+
: existsSync(join(root, 'wiki'))
|
|
36
|
+
? { title: `${c.name} wiki`, load: () => scanMdGraph({ root }) }
|
|
37
|
+
: null
|
|
33
38
|
const agentRoot = join(root, 'agent')
|
|
34
39
|
const agentGraph = existsSync(agentRoot)
|
|
35
40
|
? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
|
|
@@ -104,10 +109,13 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
104
109
|
try {
|
|
105
110
|
const hub = serveHub(deps)
|
|
106
111
|
const base = `http://localhost:${hub.server.port}`
|
|
107
|
-
|
|
108
|
-
|
|
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}`)
|
|
109
117
|
// the off-machine door (phone, other machines) — publicHost is config
|
|
110
|
-
if (c.serve?.publicHost != null) io.print(`
|
|
118
|
+
if (c.serve?.publicHost != null) io.print(` phone: http://${c.serve.publicHost}:${hub.server.port}/`)
|
|
111
119
|
await new Promise(() => {}) // the hub owns the process — never resolve
|
|
112
120
|
return 0
|
|
113
121
|
} catch (e) {
|
|
@@ -120,8 +128,9 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
120
128
|
if (owner?.hub === 'shipkit' && owner.name === c.name) {
|
|
121
129
|
// a re-run terminal stays as clickable as a first run — the
|
|
122
130
|
// owner's /hub.json tool list is the same source of truth
|
|
123
|
-
io.print(
|
|
124
|
-
|
|
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}`)
|
|
125
134
|
return 0
|
|
126
135
|
}
|
|
127
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`)
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -19,7 +19,8 @@ export type HubDeps = {
|
|
|
19
19
|
name: string
|
|
20
20
|
/** 0 = random (tests); real launches pass repo.config/default */
|
|
21
21
|
port: number
|
|
22
|
-
|
|
22
|
+
/** null = this repo has no wiki to graph — the route stays unmounted */
|
|
23
|
+
wikiGraph: HubGraph | null
|
|
23
24
|
/** null = no agent/ dir */
|
|
24
25
|
agentGraph: HubGraph | null
|
|
25
26
|
/** the media API (mounted under /media/*); null only if the route
|
|
@@ -44,7 +45,7 @@ export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; stop
|
|
|
44
45
|
// so they can never disagree
|
|
45
46
|
export function hubTools(d: Pick<HubDeps, 'wikiGraph' | 'agentGraph'>): HubTool[] {
|
|
46
47
|
const tools: HubTool[] = [{ path: '/', label: 'hub', desc: 'THE page — media · asks · creds · posts · health · graphs' }]
|
|
47
|
-
tools.push({ path: '/graph/wiki', label: 'wiki graph', desc: d.wikiGraph.title })
|
|
48
|
+
if (d.wikiGraph != null) tools.push({ path: '/graph/wiki', label: 'wiki graph', desc: d.wikiGraph.title })
|
|
48
49
|
if (d.agentGraph != null) tools.push({ path: '/graph/agent', label: 'agent graph', desc: d.agentGraph.title })
|
|
49
50
|
return tools
|
|
50
51
|
}
|
|
@@ -98,8 +99,8 @@ export function serveHub(d: HubDeps): Hub {
|
|
|
98
99
|
}
|
|
99
100
|
else if (path === '/api/act/post-feedback') appendPostFeedback(d.root, b.channel ?? '', b.file ?? '', b.text ?? '')
|
|
100
101
|
else if (path === '/api/act/remark') {
|
|
101
|
-
// ONE remark verb, routed by collection
|
|
102
|
-
//
|
|
102
|
+
// ONE remark verb, routed by collection: a remark
|
|
103
|
+
// lands in the item's own truth file
|
|
103
104
|
const text = (b.text ?? '').trim()
|
|
104
105
|
if (text === '') return new Response('empty remark', { status: 400 })
|
|
105
106
|
if (b.kind === 'ask') appendAskRemark(d.root, d.asksDir, b.file ?? '', text)
|
|
@@ -124,8 +125,8 @@ export function serveHub(d: HubDeps): Hub {
|
|
|
124
125
|
return new Response(proc.stdout, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
|
|
125
126
|
}
|
|
126
127
|
const page = (s: string): Response => new Response(withHome(s), { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
127
|
-
if (path === '/graph/wiki') return page(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }))
|
|
128
|
-
if (path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
|
|
128
|
+
if (d.wikiGraph != null && path === '/graph/wiki') return page(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }))
|
|
129
|
+
if (d.wikiGraph != null && path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
|
|
129
130
|
if (d.agentGraph != null && path === '/graph/agent') return page(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }))
|
|
130
131
|
if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
|
|
131
132
|
// the media API namespace — the studio route table, ONE prefix strip
|
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
|
|
75
|
-
//
|
|
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
|
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
|
+
}
|