rvlib-shipkit 0.35.3 → 0.35.5

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.5
4
+
5
+ - **The `TaskDef.inputs` doc comment stated only half the safety rule.** It said undeclared means never cached, so forgetting costs speed and never correctness, which is true for forgetting a whole task and false for the mistake that actually ships a false green: a list MISSING a path the command reads. The key cannot move when that path changes, so the answer replays over a tree it no longer describes. Observed twice in the field in one day. The comment now carries the asymmetry, names `['.']` as the declaration that cannot under declare, and says that a task carries no code closure, so the script it runs belongs in `inputs` too.
6
+ - **`shipkit release` scopes its dirt check exactly like `shipkit bump`.** `bump` examined the released unit plus every package.json and lockfile a version commit can touch; `release` examined the WHOLE worktree, refused, and then named `shipkit release` as the fix, which refuses identically. Several agents share one checkout here, so a neighbour editing an unrelated package was enough to wedge a release with no way forward that the message described. The reason to refuse at all is unchanged and still applies inside the scope: `npm publish` packs the WORKING TREE rather than the commit, so a half written file inside the unit ships under a tag that describes something else. Dirt in another unit reaches neither the tarball nor the tag, and the refusal now says what it examined.
7
+
8
+ ## 0.35.4
9
+
10
+ - **The imports refusal did not follow tsconfig `extends`, so a correctly configured repo went red and was told to add a key it already had.** Inheriting `paths` from a base config is the more common way to carry them, and only `references` was handled. `extends` is now followed (relative and node-resolved, chains and cycles included), and a mapping counts only when its TARGET is on disk: `"src/*": ["./packages/core/src/*"]` maps the prefix and resolves nothing. A package prefix is no longer accepted on the package DIRECTORY existing either, since a stale installed copy missing the file is the same false green one level up. `baseUrl` is no longer proof at all: tsc follows one, but rollup, esbuild and vite do not resolve a bare specifier through it, and the field failure this guard exists for was a rollup one. A multi-segment root (`packages/core`) is named as itself rather than as `packages`.
11
+ - **The refusal prints once per REASON, not once per import site.** An unresolvable root is one repo-wide fact, and the case that hits it is a repo with ~150 imports, so the single actionable instruction was buried under 150 identical copies of itself. One line per reason now, with the count and the first two sites.
12
+ - **`shipkit mv` refused to nest a sibling directory inside an existing one.** `git mv` moves a source directory INSIDE an existing destination rather than refusing, so a page whose sibling folder collided landed its probe one level too deep while the command printed ok. Reachable only once moves could cross directories.
13
+ - **`shipkit mv` reads a link definition and a titled link as references**, resolves the `→` form only when it is EXPLICITLY relative (a bare target is a docs-root id in some trees and a file-relative path in others, and guessing corrupts one of them), requires a target to be a real FILE so prose naming a directory is not rewritten, and splices by index so a path containing `$&` is never read as a backreference.
14
+ - The `mv` header comment no longer claims the token sweep has nothing left to corrupt. A path that merely ENDS with the old path is still rewritten by that sweep, the relative pass declines it on purpose, and the comment now says so instead of telling the next reader to stop looking.
15
+ - The STATUS.md RED verdict no longer claims `1 FAIL line` when the body has none. A red exit with no FAIL line of its own is its own state and says so.
16
+
17
+ - **The contract-folder repair asks the REPO whether the dotted name is ignored, instead of trusting a regex over a comment.** The candidate names come from the SK004 policy block, and three of them (`.rv-itch`, `.rv-steam`, `.rv-discord`) carry "ignored until the listing is live, override below the block". On a repo with a live listing those folders are TRACKED on purpose, so renaming an undotted one into them would have committed the removal of real files from the index, silently, inside somebody else's gate run. Now: untracked folders rename freely, since nothing can be lost, and a TRACKED one only renames when `git check-ignore` says this repo treats the dotted name as local.
18
+ - **A repair that renamed and could not commit is REPORTED.** Silence is for a repair that completed. The hooks can refuse, `SHIPKIT_NO_AUTOCOMMIT=1` can be set, a merge can be in progress: the rename is still the right end state, and the dirt it leaves has to be traceable to the run that made it rather than surfacing as an unexplained refusal in the next bump.
19
+
3
20
  ## 0.35.3
4
21
 
5
22
  - **`shipkit mv` no longer resolves a link that climbs out of the repo.** The cross-directory pass rewrites references by resolving them against the file that carries them, and a target like `../../outside.md` resolves to somewhere on the wider disk, which `existsSync` will happily confirm. A reference that leaves the repo is not this repo's to retarget, so it is left exactly as written.
@@ -16,7 +33,7 @@
16
33
 
17
34
  - **The STATUS.md verdict counted the runner's own tally as a failure**, so N failing tasks always reported as N+1 (`RED — 2 FAIL lines` over one). `FAIL: 1/5 failed: cycles` is a line ABOUT the failures. A red exit with no FAIL line of its own also rendered `1 FAIL lines`, because the plural followed the number counted rather than the number shown. The header no longer asks to be committed either: SK004 ignores AND untracks this file, so the invitation sent readers into the state that fixer exists to undo.
18
35
 
19
- - **`shipkit start` wrote a stale package.json's name into `repo.config.ts`.** An adopted repo usually carries the manifest of whatever it was forked from, so a repo started as `voyager` got `name: 'bun-react-template'` in the one file every agent reads to learn what this repo IS. The name is on the command line and is the directory too, and `start` passes it now. With nothing to go on at all the DIRECTORY answers, rather than the `my-repo` placeholder that was waiting for someone not to notice it.
36
+ - **`shipkit start` wrote a stale package.json's name into `repo.config.ts`.** An adopted repo usually carries the manifest of whatever it was forked from, so a repo adopted under its own name got the template's name in the one file every agent reads to learn what this repo IS. The name is on the command line and is the directory too, and `start` passes it now. With nothing to go on at all the DIRECTORY answers, rather than the `my-repo` placeholder that was waiting for someone not to notice it.
20
37
 
21
38
  - **The scaffolded `repo.config.ts` carried dash punctuation into repos that ban it.** It is authored by shipkit and lands as that repo's source, so its comments answer to that repo's comment style, and a scaffold is the one comment nobody rewrites, so it teaches the style of whatever wrote it. A test gates the shape.
22
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.35.3",
3
+ "version": "0.35.5",
4
4
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,10 +10,14 @@
10
10
  // what needs taste or hands.
11
11
  //
12
12
  // The names come from the SK004 policy block, which is the one table that
13
- // already lists the always-ignored `.rv-*` contract folders. The TRACKED
14
- // contract dirs (`.rv-identity`, `.rv-assets`) stay out of it deliberately:
15
- // their own contract check reports them missing, so those never fail silently,
16
- // and a rename that untracks a tracked folder is not a repair.
13
+ // already lists the `.rv-*` contract folders. The TRACKED contract dirs
14
+ // (`.rv-identity`, `.rv-assets`) are not in it, and a rename that untracks a
15
+ // tracked folder is not a repair, so the ignore is checked against THIS REPO
16
+ // before anything moves: the venue names in that block carry "ignored until the
17
+ // listing is live, override below the block", so a repo with a live listing
18
+ // tracks `.rv-itch/` on purpose and a rename there would delete its files from
19
+ // the index. `git check-ignore` answers for the repo instead of a regex
20
+ // answering for a comment.
17
21
  import { readdirSync, renameSync, rmdirSync } from 'fs'
18
22
  import { join } from 'path'
19
23
  import { POLICY_LINES } from '../rules/SK004-gitignore-policy.ts'
@@ -62,7 +66,18 @@ const dirsIn = (root: string): string[] => {
62
66
  try { return readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) } catch { return [] }
63
67
  }
64
68
 
65
- export type DirRepairResult = { renamed: DirRepair[]; removed: string[]; refusals: DirRefusal[] }
69
+ export type DirRepairResult = { renamed: DirRepair[]; removed: string[]; refusals: DirRefusal[]; uncommitted: DirRepair[] }
70
+
71
+ /** does THIS repo's ignore policy cover the dotted name (the index is not
72
+ * consulted: the question is about the patterns, and the path does not exist yet) */
73
+ const isIgnoredHere = (root: string, name: string): boolean =>
74
+ Bun.spawnSync(['git', 'check-ignore', '-q', '--no-index', `${name}/`], { cwd: root }).exitCode === 0
75
+
76
+ /** does git track anything under this folder today */
77
+ const isTracked = (root: string, name: string): boolean => {
78
+ const r = Bun.spawnSync(['git', 'ls-files', '--', name], { cwd: root })
79
+ return r.exitCode === 0 && r.stdout.toString().trim() !== ''
80
+ }
66
81
 
67
82
  /**
68
83
  * Apply the plan. An EMPTY undotted folder is removed rather than renamed:
@@ -81,6 +96,7 @@ export function repairContractDirs(root: string): DirRepairResult {
81
96
  const plan = planContractDirRepairs(dirsIn(root))
82
97
  const renamed: DirRepair[] = []
83
98
  const removed: string[] = []
99
+ const uncommitted: DirRepair[] = []
84
100
  for (const r of plan.repairs) {
85
101
  const from = join(root, r.from)
86
102
  const to = join(root, r.to)
@@ -90,13 +106,28 @@ export function repairContractDirs(root: string): DirRepairResult {
90
106
  removed.push(r.from)
91
107
  continue
92
108
  }
109
+ // renaming TRACKED files into an ignored path takes them out of the
110
+ // index, which is the contract for a reflections folder and data
111
+ // loss for a live venue folder the repo tracks on purpose. So the
112
+ // untracking half only happens where the repo's own ignore policy
113
+ // says those files are local. Untracked files rename freely: there
114
+ // is nothing to lose and nothing to commit
115
+ const tracked = isTracked(root, r.from)
116
+ if (tracked && !isIgnoredHere(root, r.to)) continue
93
117
  renameSync(from, to)
94
118
  renamed.push(r)
95
- machineCommit({ anchor: root, message: `chore(machine): rename ${r.from} to ${r.to}`, paths: [from] })
119
+ // a repair that half happened is not the silence that was asked
120
+ // for. the hooks can refuse, SHIPKIT_NO_AUTOCOMMIT can be set, a
121
+ // merge can be in progress: the rename is still the right end
122
+ // state, and the dirt it leaves has to be traceable to this run
123
+ // rather than surfacing as an unexplained refusal in the next bump
124
+ if (tracked && !machineCommit({ anchor: root, message: `chore(machine): rename ${r.from} to ${r.to}`, paths: [from] })) {
125
+ uncommitted.push(r)
126
+ }
96
127
  } catch {
97
128
  // a permission error or a race is not worth a red gate: the folder
98
129
  // stays where it is and the next run tries again
99
130
  }
100
131
  }
101
- return { renamed, removed, refusals: plan.refusals }
132
+ return { renamed, removed, refusals: plan.refusals, uncommitted }
102
133
  }
@@ -180,10 +180,18 @@ async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, ver
180
180
  // ambiguous case reaches a human. Runs before the tree snapshot: the rename
181
181
  // lands in its own commit and is never counted as a fixer rewrite
182
182
  if (!verify) {
183
- for (const r of repairContractDirs(root).refusals) {
183
+ const repaired = repairContractDirs(root)
184
+ for (const r of repaired.refusals) {
184
185
  o.print(`WARN ${r.from}/ and ${r.to}/ both exist — ${r.reason}`)
185
186
  o.print(` fix: move the files into ${r.to}/ yourself, then delete ${r.from}/`)
186
187
  }
188
+ // silence is for a repair that COMPLETED. one that renamed and could
189
+ // not land the commit leaves dirt nobody can trace to a run they did
190
+ // not make, which is the failure the silence was meant to prevent
191
+ for (const r of repaired.uncommitted) {
192
+ o.print(`WARN renamed ${r.from}/ to ${r.to}/, and the commit did not land`)
193
+ o.print(` fix: commit the removal of ${r.from}/ yourself — the files are in ${r.to}/`)
194
+ }
187
195
  }
188
196
  const { slots, unfilled, disabled } = resolveFixers(root, config)
189
197
  for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
@@ -32,8 +32,14 @@ export type TaskDef = {
32
32
  timeoutMs?: number
33
33
  /** CONTENT population: the repo-relative paths this task reads. Declaring
34
34
  * them is what makes the task keyable (ledger/git-key.ts). Undeclared is
35
- * the DEFAULT and means never cached, so forgetting costs speed and never
36
- * correctness. Declaring both this and `ttl` is a registration failure. */
35
+ * the DEFAULT and means never cached, so forgetting a whole task costs
36
+ * speed and never correctness. A list MISSING a path the command reads is
37
+ * the one that costs correctness: the key cannot move when that path
38
+ * changes, so the answer replays over a tree it no longer describes. Over
39
+ * declaring costs a re-run, under declaring is a false green, and `['.']`
40
+ * is the whole work tree and cannot under declare. A task carries no code
41
+ * closure, so the script it runs belongs in here too.
42
+ * Declaring both this and `ttl` is a registration failure. */
37
43
  inputs?: string[]
38
44
  /** WORLD population: this task reads the network, a keychain, $PATH or the
39
45
  * clock, so nothing on disk describes its answer and a clean pass is
@@ -41,11 +41,12 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
41
41
  // too, or a narrowed green hides behind a nag nobody reads twice
42
42
  const full = s.fullGate === true
43
43
  const scope = full ? 'clear to ship' : `${s.command} ran, not the whole gate`
44
- // a red exit with no FAIL line of its own still reads RED, and the
45
- // plural follows the number SHOWN, not the number counted
46
- const shown = Math.max(fails, 1)
47
- const verdict = s.exitCode !== 0 || fails > 0
48
- ? `RED — ${shown} FAIL line${shown === 1 ? '' : 's'}. Fixes are printed under each FAIL.`
44
+ // a red exit with no FAIL line of its own is its OWN state: claiming a
45
+ // FAIL line sends the reader hunting for one that is not in the body
46
+ const verdict = fails > 0
47
+ ? `RED — ${fails} FAIL line${fails === 1 ? '' : 's'}. Fixes are printed under each FAIL.`
48
+ : s.exitCode !== 0
49
+ ? `RED — exit ${s.exitCode}, with no FAIL line of its own. The run died before it could report one.`
49
50
  : warns > 0
50
51
  ? `GREEN with nags — ${warns} WARN. ${full ? 'Proceed.' : `${scope}.`}`
51
52
  : `GREEN — ${scope}.`
package/src/mv/mv-md.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // script it runs after the moves (repo-specific re-stamps). TS module moves
7
7
  // are a different concern (a TS-aware renamer owns import graphs).
8
8
  import { execSync } from 'child_process'
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
9
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'
10
10
  import { basename, dirname, join, relative } from 'path'
11
11
 
12
12
  export type MvIo = { print: (l: string) => void; printErr: (l: string) => void }
@@ -15,18 +15,33 @@ export type MvOpts = { write?: boolean; commit?: boolean }
15
15
  const sh = (cmd: string, cwd: string): string => execSync(cmd, { cwd }).toString()
16
16
 
17
17
  const posix = (p: string): string => p.replace(/\\/g, '/')
18
+ /** a link points at a file. A directory that happens to share a prose word's
19
+ * spelling is not a reference, and existsSync alone accepts it */
20
+ const isFile = (p: string): boolean => { try { return statSync(p).isFile() } catch { return false } }
18
21
  const dirOf = (p: string): string => { const d = dirname(p); return d === '.' ? '' : d }
19
22
 
20
23
  // A reference written RELATIVE to the file that carries it survives a same-dir
21
24
  // rename untouched, which is why sweeping path TOKENS was enough while moves
22
25
  // were confined to one directory. A move between directories invalidates both
23
26
  // directions at once: the links the page carries out, and the links other
24
- // pages aim at it. Sweeping tokens cannot fix either, and on a tail that
25
- // happens to match ('../doc/bun.md' carrying 'doc/bun.md') it corrupts them,
26
- // so this pass runs FIRST and leaves nothing for the token sweep to hit.
27
+ // pages aim at it. This pass runs FIRST so the token sweep finds those already
28
+ // rewritten.
29
+ //
30
+ // KNOWN RESIDUAL, do not read this as closed: the token sweep below is a
31
+ // substring replace, so a path that merely ENDS with the old path
32
+ // ('other/doc/bun.md' referenced as '../doc/bun.md' from a file that is not
33
+ // moving) is still rewritten to a path that does not exist. This pass declines
34
+ // that case on purpose, because neither the target nor the carrier moved.
35
+ // Closing it means teaching the sweep which character spans were already
36
+ // resolved here.
37
+ //
38
+ // A target must resolve to a FILE. Directories are excluded because the arrow
39
+ // form appears in prose ('→ shipkit ci'), and a bare word that happens to name
40
+ // a directory is not a link.
27
41
  const REF_FORMS: { re: RegExp; group: number }[] = [
28
- { re: /\]\(([^)\s]+)\)/g, group: 1 }, // [text](target)
29
- { re: /(→\s+)([^\s,;)`]+)/g, group: 2 }, // → target
42
+ { re: /\]\(([^)\s]+)(?=[)\s])/g, group: 1 }, // [text](target) and [text](target "Title")
43
+ { re: /^(\[[^\]]+\]:\s*)(\S+)/gm, group: 2 }, // [ref]: target, the link definition
44
+ { re: /(→\s+)(\.\.?\/[^\s,;)`]+)/g, group: 2 }, // → target, only when EXPLICITLY relative
30
45
  { re: /^(@)(\S+)$/gm, group: 2 }, // @target, the include line
31
46
  ]
32
47
 
@@ -48,8 +63,8 @@ export function retargetRelativeRefs(root: string, text: string, fromDir: string
48
63
  const to = moves.get(from)
49
64
  // nothing moved for this reference: neither its target nor its carrier
50
65
  if (to == null && fromDir === toDir) return null
51
- // a target that is not a real path is prose, not a link
52
- if (to == null && !existsSync(join(root, from))) return null
66
+ // a target that is not a real FILE is prose, not a link
67
+ if (to == null && !isFile(join(root, from))) return null
53
68
  const next = posix(relative(toDir, to ?? from))
54
69
  if (next === '') return null
55
70
  return `${path.startsWith('./') && !next.startsWith('.') ? `./${next}` : next}${anchor}`
@@ -59,7 +74,11 @@ export function retargetRelativeRefs(root: string, text: string, fromDir: string
59
74
  out = out.replace(re, (m, ...rest) => {
60
75
  const target = rest[group - 1] as string
61
76
  const next = fix(target)
62
- return next == null ? m : m.replace(target, next)
77
+ // splice by INDEX, never a string replacement: a path carrying
78
+ // `$&` or `$1` would otherwise be interpreted as a backreference
79
+ if (next == null) return m
80
+ const at = m.lastIndexOf(target)
81
+ return at === -1 ? m : m.slice(0, at) + next + m.slice(at + target.length)
63
82
  })
64
83
  }
65
84
  return out
@@ -70,6 +89,13 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
70
89
  if (!oldRel.endsWith('.md') || !newRel.endsWith('.md')) return die('both paths must be .md (TS moves are not this tool\'s job)')
71
90
  if (!existsSync(join(root, oldRel))) return die(`${oldRel} does not exist`)
72
91
  if (existsSync(join(root, newRel))) return die(`${newRel} already exists`)
92
+ // git mv puts a source directory INSIDE an existing destination instead of
93
+ // refusing, so the probe lands one level too deep and the run still says
94
+ // ok. Unreachable while every move was a same-dir rename, likely the
95
+ // moment a move crosses directories
96
+ const oldSiblingDir = oldRel.slice(0, -3)
97
+ if (existsSync(join(root, oldSiblingDir)) && existsSync(join(root, newRel.slice(0, -3))))
98
+ return die(`${newRel.slice(0, -3)}/ already exists, and ${oldSiblingDir}/ would be nested inside it rather than become it — move or merge it by hand first`)
73
99
 
74
100
  const write = o.write === true || o.commit === true
75
101
  const oldName = basename(oldRel, '.md')
@@ -11,6 +11,7 @@ import { join } from 'path'
11
11
  import { runCi } from '../core/run-ci.ts'
12
12
  import { extractChangelogSection } from './changelog.ts'
13
13
  import { deriveRemote, dirtyPaths, git, remoteTagState, tagExistsLocally, type RemoteTagState } from './git-facts.ts'
14
+ import { releaseScope } from './release-scope.ts'
14
15
  import { npmPublishArgv, unitBlockers } from './unit-gate.ts'
15
16
  import { formatUnitError, resolveUnits, unitById, type ReleaseUnit } from './units.ts'
16
17
  import type { RepoConfig } from '../repo-config.ts'
@@ -172,9 +173,23 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
172
173
  if (cur.code !== 0) blockers.push('not a git repository')
173
174
  else if (cur.out !== branch) blockers.push(`releases ship from '${branch}', HEAD is on '${cur.out}'`)
174
175
 
175
- const dirty = dirtyPaths(root, config)
176
- if (dirty !== '' && o.allowDirty !== true)
177
- blockers.push(`working tree not clean (--allow-dirty overrides):\n${dirty.split('\n').slice(0, 10).join('\n')}`)
176
+ // scoped exactly like `bump`, and for the same reason: `npm publish` packs
177
+ // the WORKING TREE rather than the commit, so dirt INSIDE what ships can
178
+ // put a half written file under a tag that describes something else. Dirt
179
+ // in another unit reaches neither this tarball nor this tag. Checking the
180
+ // whole tree here made `release` refuse what `bump` allows, and then name
181
+ // `shipkit release` as the fix, which refuses identically: several agents
182
+ // share this checkout, so a neighbour editing an unrelated package was
183
+ // enough to wedge a release with no way forward that the message named.
184
+ // the sibling units matter to the SCOPE: a version write rewrites the
185
+ // rvlib range in every dependent's package.json, so those paths are inside
186
+ // this release even though they are outside this unit
187
+ const scope = releaseScope(root, resolveUnits(root, config).units, [unit])
188
+ const dirty = dirtyPaths(root, config, scope.specs ?? undefined)
189
+ if (dirty !== '' && o.allowDirty !== true) {
190
+ blockers.push(`uncommitted changes in ${scope.describe} (--allow-dirty overrides):\n${dirty.split('\n').slice(0, 10).join('\n')}`)
191
+ if (scope.specs != null) blockers.push('dirt elsewhere in the worktree is deliberately ignored, it cannot reach this tarball or this tag')
192
+ }
178
193
  if (o.skipUnitGate !== true) blockers.push(...unitBlockers(root, config, unit))
179
194
 
180
195
  let alreadyPublished = false
@@ -130,7 +130,7 @@ export const oneWayInPolicy: Rule = {
130
130
  // replacement is named while the guard cannot run is the green that
131
131
  // measured nothing, one line under the WARN that says otherwise
132
132
  if (!t.failed)
133
- t.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s)${guardRuns ? ', with the replacement named' : ' — the guard that names the replacement is declared, not yet runnable'}`)
133
+ t.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s)${guardRuns ? ', with the replacement named' : ' (the guard that names the replacement is declared, not yet runnable)'}`)
134
134
  },
135
135
  // MERGES, never rewrites: this file also carries the attribution silencer,
136
136
  // a repo's own allow list and its own hooks, and clobbering those to fix a
package/src/ts/imports.ts CHANGED
@@ -62,58 +62,99 @@ export const clearExistsCache = (): void => {
62
62
  // reported by nobody, because this tool did not put it there and the
63
63
  // typechecker owns that verdict.
64
64
 
65
- type RepoResolution = { prefixes: Set<string>; baseDirs: string[] }
65
+ /** one tsconfig `paths` entry, its targets already resolved against the config
66
+ * file that DECLARED them. tsc resolves them there rather than at the root,
67
+ * which is what makes `extends` work at all. */
68
+ type PathEntry = { key: string; targets: string[] }
69
+ type RepoResolution = { paths: PathEntry[] }
66
70
  const resolutionCache = new Map<string, RepoResolution>()
67
71
 
68
- /** every prefix the repo's own tsconfigs map, plus the baseUrls they set.
69
- * Every `tsconfig*.json` at the root, because the standard vite shape keeps
70
- * `paths` in a REFERENCED config and leaves the entry one empty. */
72
+ /** the tsconfig an `extends` names: relative, or a package under node_modules.
73
+ * `.json` is implied the way tsc implies it. */
74
+ function resolveExtends(fromDir: string, root: string, spec: string): string | null {
75
+ const bases = spec.startsWith('.')
76
+ ? [resolve(fromDir, spec)]
77
+ : [join(root, 'node_modules', spec), join(root, 'node_modules', spec, 'tsconfig.json')]
78
+ for (const b of bases) for (const c of [b, `${b}.json`]) if (cachedExists(c)) return c
79
+ return null
80
+ }
81
+
82
+ /** every `paths` entry the repo declares, following `extends` as tsc does.
83
+ * Every `tsconfig*.json` at the ROOT, because the standard vite shape keeps
84
+ * its mappings in a REFERENCED config and leaves the entry one empty.
85
+ *
86
+ * `baseUrl` is deliberately NOT proof. tsc follows one, but rollup, esbuild
87
+ * and vite do not resolve a bare specifier through baseUrl alone, and the
88
+ * field failure this guard exists for was a rollup one. Accepting it would
89
+ * re-open the same bug in a narrower shape. */
71
90
  function repoResolution(root: string): RepoResolution {
72
91
  const hit = resolutionCache.get(root)
73
92
  if (hit != null) return hit
74
- const prefixes = new Set<string>()
75
- const baseDirs: string[] = []
93
+ const paths: PathEntry[] = []
94
+ const seen = new Set<string>()
95
+ const visit = (file: string): void => {
96
+ if (seen.has(file) || !cachedExists(file)) return // a cycle terminates here
97
+ seen.add(file)
98
+ let parsed: { extends?: unknown; compilerOptions?: { paths?: Record<string, unknown> } }
99
+ try {
100
+ parsed = parseJsonc(readFileSync(file, 'utf8')) as typeof parsed
101
+ } catch {
102
+ return // an unparseable tsconfig maps nothing, it does not map everything
103
+ }
104
+ const dir = dirname(file)
105
+ for (const [key, targets] of Object.entries(parsed.compilerOptions?.paths ?? {}))
106
+ if (Array.isArray(targets)) paths.push({ key, targets: targets.filter((t): t is string => typeof t === 'string').map((t) => toPosix(resolve(dir, t))) })
107
+ // an INHERITED mapping is the more common way to carry `paths`, and a
108
+ // repo that has one is correctly configured: refusing there would be
109
+ // this tool telling a working repo to add a key it already has
110
+ for (const e of Array.isArray(parsed.extends) ? parsed.extends : [parsed.extends])
111
+ if (typeof e === 'string') { const next = resolveExtends(dir, root, e); if (next != null) visit(next) }
112
+ }
76
113
  let names: string[] = []
77
114
  try {
78
115
  names = readdirSync(root).filter((n) => /^tsconfig(\..+)?\.json$/.test(n))
79
116
  } catch {
80
117
  // unreadable root: nothing is mapped, and every rewrite refuses loudly
81
118
  }
82
- for (const n of names) {
83
- let parsed: { compilerOptions?: { paths?: Record<string, unknown>; baseUrl?: unknown } }
84
- try {
85
- parsed = parseJsonc(readFileSync(join(root, n), 'utf8')) as typeof parsed
86
- } catch {
87
- continue // an unparseable tsconfig maps nothing, it does not map everything
88
- }
89
- const co = parsed.compilerOptions
90
- for (const k of Object.keys(co?.paths ?? {})) prefixes.add(k.endsWith('/*') ? k.slice(0, -2) : k)
91
- if (typeof co?.baseUrl === 'string') baseDirs.push(co.baseUrl)
92
- }
93
- const r = { prefixes, baseDirs }
119
+ for (const n of names) visit(join(root, n))
120
+ const r = { paths }
94
121
  resolutionCache.set(root, r)
95
122
  return r
96
123
  }
97
124
 
98
- /** the prefix a canonical specifier is rooted at: a discovered package's NAME
99
- * (a scoped one carries a slash of its own, so splitting on '/' is wrong),
100
- * else the leading segment */
101
- const specifierPrefix = (spec: string, cfg: RvtConfig): string =>
102
- cfg.packages.find((p) => spec === p.name || spec.startsWith(`${p.name}/`))?.name ?? spec.split('/')[0]!
125
+ /** can the repo resolve THIS specifier? The mapped target is checked on disk
126
+ * rather than the prefix alone: `"src/*": ["./packages/core/src/*"]` maps the
127
+ * prefix and still resolves nothing, and a stale `node_modules/<pkg>` missing
128
+ * the file is the same false green one level up. */
129
+ function repoCanResolve(spec: string, cfg: RvtConfig): boolean {
130
+ for (const { key, targets } of repoResolution(cfg.root).paths) {
131
+ if (key.endsWith('/*')) {
132
+ const p = key.slice(0, -2)
133
+ if (!spec.startsWith(`${p}/`)) continue
134
+ const rest = spec.slice(p.length + 1)
135
+ for (const t of targets) if (cachedExists(t.replace('*', rest))) return true
136
+ } else if (spec === key) {
137
+ for (const t of targets) if (cachedExists(t)) return true
138
+ }
139
+ }
140
+ // plain node resolution, which is what an npm consumer of the published
141
+ // source uses. The FILE, never merely the package directory
142
+ return cachedExists(join(cfg.root, 'node_modules', spec))
143
+ }
103
144
 
104
- function repoCanResolve(prefix: string, cfg: RvtConfig): boolean {
105
- const { prefixes, baseDirs } = repoResolution(cfg.root)
106
- if (prefixes.has(prefix)) return true
107
- if (baseDirs.some((b) => cachedExists(join(cfg.root, b, prefix)))) return true
108
- // a package name also answers to plain node resolution, which is what an
109
- // npm consumer of the published source will use
110
- return cachedExists(join(cfg.root, 'node_modules', prefix))
145
+ /** what the refusal NAMES: a discovered package (a scoped name carries a slash
146
+ * of its own), else the longest configured root the specifier sits under, so
147
+ * a multi-segment root like `packages/core` is not reported as `packages`. */
148
+ function specifierPrefix(spec: string, cfg: RvtConfig): string {
149
+ const pkg = cfg.packages.find((p) => spec === p.name || spec.startsWith(`${p.name}/`))
150
+ if (pkg != null) return pkg.name
151
+ return [...cfg.roots].sort((a, b) => b.length - a.length).find((r) => spec.startsWith(`${r}/`)) ?? spec.split('/')[0]!
111
152
  }
112
153
 
113
154
  const unresolvableReason = (prefix: string, isPackage: boolean): string =>
114
155
  isPackage
115
- ? `nothing resolves '${prefix}': install or link the package, or map "${prefix}/*" in compilerOptions.paths (tsconfig.json). Rewriting first would resolve on disk and nowhere else`
116
- : `the repo maps nothing onto '${prefix}/': add "${prefix}/*": ["./${prefix}/*"] to compilerOptions.paths (tsconfig.json), plus the matching bundler alias. Rewriting first would resolve on disk and nowhere else`
156
+ ? `nothing resolves '${prefix}': install or link the package, or map "${prefix}/*" in compilerOptions.paths. Rewriting first would resolve on disk and nowhere else`
157
+ : `the repo maps nothing onto '${prefix}/': add "${prefix}/*": ["./${prefix}/*"] to compilerOptions.paths (any tsconfig at the root, or one it extends), plus the matching bundler alias. Rewriting first would resolve on disk and nowhere else`
117
158
 
118
159
  function resolveFileExtension(absPath: string): string | null {
119
160
  if (/\.(d\.ts|tsx?|json)$/.test(absPath) && cachedExists(absPath)) return absPath
@@ -198,7 +239,7 @@ export function rewriteFile(filePath: string, cfg: RvtConfig, save: boolean): Im
198
239
  }
199
240
  if (next === specifier) return null
200
241
  const prefix = specifierPrefix(next, cfg)
201
- if (!repoCanResolve(prefix, cfg)) {
242
+ if (!repoCanResolve(next, cfg)) {
202
243
  pushError(lineNum, specifier, unresolvableReason(prefix, cfg.packages.some((p) => p.name === prefix)))
203
244
  return null
204
245
  }
@@ -311,6 +352,17 @@ export function runImports(opts: { save?: boolean; files?: string[]; cfg?: RvtCo
311
352
  return run
312
353
  }
313
354
 
355
+ /** one entry per distinct reason, sites in the order they were found */
356
+ function groupByReason(errors: ImportsRun['errors']): Map<string, ImportsRun['errors']> {
357
+ const out = new Map<string, ImportsRun['errors']>()
358
+ for (const e of errors) {
359
+ const at = out.get(e.reason)
360
+ if (at == null) out.set(e.reason, [e])
361
+ else at.push(e)
362
+ }
363
+ return out
364
+ }
365
+
314
366
  // modes: (none)=preview · --check = CI gate (exit 1 if anything would change)
315
367
  // · --fix = rewrite in place · <file...> = fix exactly these files (on-save hook)
316
368
  export function importsCli(argv: string[]): number {
@@ -326,7 +378,14 @@ export function importsCli(argv: string[]): number {
326
378
  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`)
327
379
  if (run.scanned === 0)
328
380
  console.log(` scanned 0 files — roots (${run.roots.join(', ')}) hold no .ts/.tsx; this gate is checking NOTHING${check ? ', which fails the gate' : ''}`)
329
- for (const e of run.errors) console.log(` ${e.file}:${e.line} -> '${e.specifier}' (${e.reason})`)
381
+ // grouped by REASON: an unresolvable root is ONE repo-wide fact, and the
382
+ // field case is ~150 imports, so a line per site buries the one instruction
383
+ // under its own repetition
384
+ for (const [reason, sites] of groupByReason(run.errors)) {
385
+ console.log(` ${reason}`)
386
+ const shown = sites.slice(0, 2).map((e) => `${e.file}:${e.line} -> '${e.specifier}'`).join(', ')
387
+ console.log(` ${sites.length} import(s), e.g. ${shown}${sites.length > 2 ? `, +${sites.length - 2} more` : ''}`)
388
+ }
330
389
  if (check && run.changes > 0) console.log('non-absolute imports found — run `rvt imports --fix` and commit the result.')
331
390
  return run.errors.length > 0 || (check && (run.changes > 0 || run.scanned === 0)) ? 1 : 0
332
391
  }