rvlib-shipkit 0.35.3 → 0.35.4
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 +13 -1
- package/package.json +1 -1
- package/src/core/repair-contract-dirs.ts +38 -7
- package/src/core/run-fixers.ts +9 -1
- package/src/core/write-status.ts +6 -5
- package/src/mv/mv-md.ts +35 -9
- package/src/rules/SK013-one-way-in-policy.ts +1 -1
- package/src/ts/imports.ts +93 -34
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.35.4
|
|
4
|
+
|
|
5
|
+
- **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`.
|
|
6
|
+
- **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.
|
|
7
|
+
- **`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.
|
|
8
|
+
- **`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.
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
|
|
3
15
|
## 0.35.3
|
|
4
16
|
|
|
5
17
|
- **`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 +28,7 @@
|
|
|
16
28
|
|
|
17
29
|
- **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
30
|
|
|
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
|
|
31
|
+
- **`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
32
|
|
|
21
33
|
- **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
34
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.4",
|
|
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -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
|
-
|
|
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})`)
|
package/src/core/write-status.ts
CHANGED
|
@@ -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
|
|
45
|
-
//
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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:
|
|
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
|
|
52
|
-
if (to == 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
|
-
|
|
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')
|
|
@@ -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' : '
|
|
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
|
-
|
|
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
|
-
/**
|
|
69
|
-
*
|
|
70
|
-
|
|
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
|
|
75
|
-
const
|
|
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
|
-
|
|
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
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return
|
|
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
|
|
116
|
-
: `the repo maps nothing onto '${prefix}/': add "${prefix}/*": ["./${prefix}/*"] to compilerOptions.paths (tsconfig
|
|
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(
|
|
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
|
-
|
|
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
|
}
|