rvlib-shipkit 0.35.1 → 0.35.3
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 +20 -0
- package/package.json +1 -1
- package/src/core/machine-commit.ts +60 -0
- package/src/core/motherhome.ts +10 -39
- package/src/core/repair-contract-dirs.ts +102 -0
- package/src/core/run-fixers.ts +11 -0
- package/src/core/write-status.ts +15 -4
- package/src/init-repo.ts +30 -7
- package/src/mv/mv-md.ts +74 -6
- package/src/rules/SK004-gitignore-policy.ts +1 -1
- package/src/start-repo.ts +26 -7
- package/src/ts/imports.ts +80 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.35.3
|
|
4
|
+
|
|
5
|
+
- **`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.
|
|
6
|
+
|
|
7
|
+
## 0.35.2
|
|
8
|
+
|
|
9
|
+
- **A contract folder typed without its dot is repaired, not reported.** `rv-reflections/` instead of `.rv-reflections/` is invisible in every direction at once: the harvest scanner reads the dotted name, the SK004 ignore block covers the dotted name, and the repo filing notes into it gets silence rather than an error. Three notes sat unread for a day that way with nothing red anywhere. It is one character, a machine can be certain, and nobody gets a say in the answer, so the fix pass renames it and lands it through the machine commit path, and no agent is told: a WARN a machine can repair is a design bug. An EMPTY misnamed folder is removed instead, since there is nothing to carry. A rename takes the files OUT of the index, which is the contract rather than a side effect: those folders are gitignored under their real name, so tracking them was the missing dot's doing. The names come from the SK004 policy block itself, so a new always-ignored `.rv-*` folder is covered by declaring it there and nowhere else; the TRACKED contract dirs stay out, because their own contract check already reports them missing. BOTH names present is the one case that reaches a human, and it refuses out loud rather than merging: two folders can hold the same filename, and picking a winner is a decision.
|
|
10
|
+
- The machine commit path is one function, `core/machine-commit`, rather than a copy per caller. Same scoping as before: the working tree version of the named paths only, never the index, so a neighbour's staged work cannot ride along, no commit mid merge or mid rebase or on a detached HEAD, never a push, and `SHIPKIT_NO_AUTOCOMMIT=1` off.
|
|
11
|
+
- **The imports fixer rewrote toward a shape the repo could not resolve, then reported green over it.** It validated its own output by asking the DISK whether the target file existed, which it always did, having just resolved the path from it. Nothing asked whether the REPO could follow the specifier being written. In two repos with no `paths`, no `baseUrl` and no bundler alias, the fix pass turned a building tree into 150 `TS2307`s and a `Rollup failed to resolve import "src/setup.tsx"`, and `imports:check` printed `ok` straight afterwards. A prefix nothing maps is now never a rewrite TARGET: resolution is read from every `tsconfig*.json` at the root (the standard vite shape keeps `paths` in a REFERENCED config), and a prefix counts as mapped by a `paths` key, by a `baseUrl` it sits under, or, for a package name, by a real `node_modules` entry. A refused rewrite is an ERROR naming the mapping to add, so the run is red in every mode. Only the write is refused: a specifier already in canonical form is left alone, because this tool did not put it there and the typechecker owns that verdict.
|
|
12
|
+
|
|
13
|
+
- **`shipkit mv` moves an md page between directories.** It refused every cross-directory move, and moving a docs folder under the one that owns the router is the commonest structural move there is. It refused for a reason: sweeping path TOKENS cannot fix a reference written RELATIVE to the file that carries it, and on a tail that happens to match (`../doc/bun.md` containing `doc/bun.md`) it corrupts one. So a relative pass now runs FIRST and leaves nothing for the token sweep to hit: it retargets the links other pages aim at the page, AND the links the page carries out, since its own directory moved under it. A url, an absolute path and a bare anchor are never resolved as paths, an anchor survives, the `→` and `@` forms count as references, and the destination directory is created because `git mv` refuses into one that is not there. A move that keeps the BASENAME no longer runs `git mv X X` on the sibling probe, which is fatal and was unreachable while every move was a rename.
|
|
14
|
+
|
|
15
|
+
- **`shipkit start` called tracked files "committed" on a path where it committed nothing.** Adopting a repo with history correctly leaves that history alone, and then reported `469 file(s) committed` while 36 files sat staged: the reassuring reading, at the one moment it was false. 469 was the `git ls-files` count. The line names the measurement now, `469 file(s) tracked`, and says `36 uncommitted` beside it when there is anything to say.
|
|
16
|
+
|
|
17
|
+
- **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
|
+
|
|
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.
|
|
20
|
+
|
|
21
|
+
- **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
|
+
|
|
3
23
|
## 0.35.1
|
|
4
24
|
|
|
5
25
|
- **SK013's green line claimed a replacement that could not be named.** With the guard declared but not yet installed, the rule WARNed correctly and then printed `closes 14/14 raw runner(s), with the replacement named` directly underneath it. Both lines were about the same file and only one of them was true. The summary now reports what the file actually delivers: the guard is `declared, not yet runnable` until its script resolves.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.3",
|
|
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",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Commit what a machine just wrote, and ONLY those paths.
|
|
2
|
+
//
|
|
3
|
+
// Some files in a repo are written by the tool rather than by a person: a
|
|
4
|
+
// registry row, a folder renamed to the name the contract already uses. Left
|
|
5
|
+
// uncommitted they dirty the tree from a run nobody associates with them, and
|
|
6
|
+
// the dirt then rides into an unrelated commit or blocks a release. Nobody
|
|
7
|
+
// wants to read them either, so the machine that writes them lands them.
|
|
8
|
+
//
|
|
9
|
+
// Scoped hard, because several agents share one checkout:
|
|
10
|
+
// - `git commit -- <path>` takes the WORKING TREE version of those paths and
|
|
11
|
+
// never reads the index, so a neighbour's staged work cannot ride along.
|
|
12
|
+
// - it refuses mid merge, mid rebase, on a detached HEAD, and before the first
|
|
13
|
+
// commit, where an automatic commit is never what anyone wants.
|
|
14
|
+
// - it never pushes. Visibility is the human's call.
|
|
15
|
+
// - the hooks run, as they do for any commit.
|
|
16
|
+
//
|
|
17
|
+
// Never throws and never fails the calling command: a machine written path is
|
|
18
|
+
// not worth a red gate. `SHIPKIT_NO_AUTOCOMMIT=1` turns it off.
|
|
19
|
+
import { existsSync } from 'fs'
|
|
20
|
+
import { join, resolve } from 'path'
|
|
21
|
+
|
|
22
|
+
const git = (cwd: string, args: string[]): { ok: boolean; out: string } => {
|
|
23
|
+
const r = Bun.spawnSync(['git', ...args], { cwd })
|
|
24
|
+
return { ok: r.exitCode === 0, out: `${r.stdout.toString()}${r.stderr.toString()}`.trim() }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type MachineCommit = {
|
|
28
|
+
/** any directory inside the target repo: the git toplevel is read from it */
|
|
29
|
+
anchor: string
|
|
30
|
+
message: string
|
|
31
|
+
/** absolute paths, all inside that repo */
|
|
32
|
+
paths: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** true when a commit landed. false means "nothing was committed", never an error */
|
|
36
|
+
export function machineCommit(o: MachineCommit): boolean {
|
|
37
|
+
if (process.env['SHIPKIT_NO_AUTOCOMMIT'] === '1') return false
|
|
38
|
+
if (o.paths.length === 0) return false
|
|
39
|
+
try {
|
|
40
|
+
const top = git(resolve(o.anchor), ['rev-parse', '--show-toplevel'])
|
|
41
|
+
if (!top.ok) return false // not a git repo, nothing to land
|
|
42
|
+
const root = top.out
|
|
43
|
+
// a detached HEAD or a repo with no commit yet has nowhere sane to land
|
|
44
|
+
if (!git(root, ['symbolic-ref', '--quiet', 'HEAD']).ok) return false
|
|
45
|
+
if (!git(root, ['rev-parse', '--verify', 'HEAD']).ok) return false
|
|
46
|
+
for (const f of ['MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD'])
|
|
47
|
+
if (existsSync(join(root, '.git', f))) return false
|
|
48
|
+
const rels = o.paths.map((p) => (p.startsWith(`${root}/`) ? p.slice(root.length + 1) : p))
|
|
49
|
+
// one retry: a neighbour committing right now holds index.lock
|
|
50
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
51
|
+
const r = git(root, ['commit', '-m', o.message, '--', ...rels])
|
|
52
|
+
if (r.ok) return true
|
|
53
|
+
if (!r.out.includes('index.lock')) return false
|
|
54
|
+
Bun.sleepSync(300)
|
|
55
|
+
}
|
|
56
|
+
return false
|
|
57
|
+
} catch {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/core/motherhome.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { homedir } from 'os'
|
|
|
15
15
|
import { join, resolve } from 'path'
|
|
16
16
|
import type { RepoConfig } from '../repo-config.ts'
|
|
17
17
|
import { atomicWrite, withFileLock } from './locked-write.ts'
|
|
18
|
+
import { machineCommit } from './machine-commit.ts'
|
|
18
19
|
|
|
19
20
|
const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
|
|
20
21
|
const END = '<!-- <<< shipkit local-projects -->'
|
|
@@ -24,11 +25,6 @@ type Row = { name: string; root: string; version: string; mode: string; seen: st
|
|
|
24
25
|
|
|
25
26
|
const rowLine = (r: Row): string => `- ${r.name} — \`${r.root}\` — shipkit ${r.version} (${r.mode}) — last seen ${r.seen}`
|
|
26
27
|
|
|
27
|
-
const git = (cwd: string, args: string[]): { ok: boolean; out: string } => {
|
|
28
|
-
const r = Bun.spawnSync(['git', ...args], { cwd })
|
|
29
|
-
return { ok: r.exitCode === 0, out: `${r.stdout.toString()}${r.stderr.toString()}`.trim() }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
28
|
/**
|
|
33
29
|
* Commit the row we just wrote, and ONLY that file.
|
|
34
30
|
*
|
|
@@ -38,42 +34,17 @@ const git = (cwd: string, args: string[]): { ok: boolean; out: string } => {
|
|
|
38
34
|
* commit or blocks a release. The row is not worth a human's attention in
|
|
39
35
|
* either direction, so the machine that writes it also lands it.
|
|
40
36
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* - it refuses mid merge, mid rebase, on a detached HEAD, and before the first
|
|
45
|
-
* commit, where an automatic commit is never what anyone wants.
|
|
46
|
-
* - it never pushes. Visibility is the human's call.
|
|
47
|
-
* - the hooks run, as they do for any commit. They are text checks of about a
|
|
48
|
-
* second, and this fires only when the row's DATA changed, not every gate.
|
|
49
|
-
*
|
|
50
|
-
* Never throws and never fails the calling command: a registry row is not worth
|
|
51
|
-
* a red gate. `SHIPKIT_NO_AUTOCOMMIT=1` turns it off.
|
|
37
|
+
* The scoping, the refusals and the escape hatch live in `core/machine-commit`,
|
|
38
|
+
* which every machine written path commits through. This one fires only when
|
|
39
|
+
* the row's DATA changed, not on every gate, so the hooks it runs are affordable.
|
|
52
40
|
*/
|
|
53
41
|
function autoCommitRow(page: string, name: string): string {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// a detached HEAD or a repo with no commit yet has nowhere sane to land
|
|
61
|
-
if (!git(root, ['symbolic-ref', '--quiet', 'HEAD']).ok) return ''
|
|
62
|
-
if (!git(root, ['rev-parse', '--verify', 'HEAD']).ok) return ''
|
|
63
|
-
for (const f of ['MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD'])
|
|
64
|
-
if (existsSync(join(root, '.git', f))) return ''
|
|
65
|
-
const rel = page.startsWith(`${root}/`) ? page.slice(root.length + 1) : page
|
|
66
|
-
// one retry: a neighbour committing right now holds index.lock
|
|
67
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
68
|
-
const r = git(root, ['commit', '-m', `chore(machine): refresh local-projects row (${name})`, '--', rel])
|
|
69
|
-
if (r.ok) return ' + committed'
|
|
70
|
-
if (!r.out.includes('index.lock')) return ''
|
|
71
|
-
Bun.sleepSync(300)
|
|
72
|
-
}
|
|
73
|
-
return ''
|
|
74
|
-
} catch {
|
|
75
|
-
return ''
|
|
76
|
-
}
|
|
42
|
+
const landed = machineCommit({
|
|
43
|
+
anchor: resolve(page, '..'),
|
|
44
|
+
message: `chore(machine): refresh local-projects row (${name})`,
|
|
45
|
+
paths: [page],
|
|
46
|
+
})
|
|
47
|
+
return landed ? ' + committed' : ''
|
|
77
48
|
}
|
|
78
49
|
|
|
79
50
|
const parseRow = (line: string): Row | null => {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// A contract folder typed without its leading dot is INVISIBLE, in every
|
|
2
|
+
// direction at once: the scanner that harvests it looks for the dotted name,
|
|
3
|
+
// the gitignore policy covers the dotted name, and the repo that filed
|
|
4
|
+
// something into it gets silence rather than an error. Files sat unread for a
|
|
5
|
+
// day that way, and nothing anywhere was red.
|
|
6
|
+
//
|
|
7
|
+
// One character, no ambiguity, and nothing about it is a decision: it is a
|
|
8
|
+
// typo, so the fix pass renames it and lands it, and no agent is told. A WARN a
|
|
9
|
+
// machine can repair is a design bug, and an attention surface carries only
|
|
10
|
+
// what needs taste or hands.
|
|
11
|
+
//
|
|
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.
|
|
17
|
+
import { readdirSync, renameSync, rmdirSync } from 'fs'
|
|
18
|
+
import { join } from 'path'
|
|
19
|
+
import { POLICY_LINES } from '../rules/SK004-gitignore-policy.ts'
|
|
20
|
+
import { machineCommit } from './machine-commit.ts'
|
|
21
|
+
|
|
22
|
+
/** the always-ignored `.rv-*` contract folders, from the policy that owns them */
|
|
23
|
+
export const contractDirNames = (): string[] =>
|
|
24
|
+
POLICY_LINES.filter((l) => /^\.rv-[a-z-]+\/$/.test(l)).map((l) => l.slice(0, -1))
|
|
25
|
+
|
|
26
|
+
/** the shape of a name, ignoring the leading dot, the separators, the case and
|
|
27
|
+
* a trailing plural. Tight on purpose: `rv-reflections`, `rv_reflection` and
|
|
28
|
+
* `RV-Reflections` are the same intent, `reflections` and `docs` are not */
|
|
29
|
+
const shapeOf = (s: string): string => s.toLowerCase().replace(/^\.+/, '').replace(/[-_.]/g, '').replace(/s$/, '')
|
|
30
|
+
|
|
31
|
+
export type DirRepair = { from: string; to: string }
|
|
32
|
+
export type DirRefusal = { from: string; to: string; reason: string }
|
|
33
|
+
export type DirRepairPlan = { repairs: DirRepair[]; refusals: DirRefusal[] }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* PURE: what to rename, given the names sitting in a repo root.
|
|
37
|
+
*
|
|
38
|
+
* BOTH names present is the one case worth refusing out loud: two folders can
|
|
39
|
+
* hold two files of the same name, and merging them is a choice about which
|
|
40
|
+
* one survives. Nothing else here is a choice.
|
|
41
|
+
*/
|
|
42
|
+
export function planContractDirRepairs(entries: string[], names: string[] = contractDirNames()): DirRepairPlan {
|
|
43
|
+
const present = new Set(entries)
|
|
44
|
+
const repairs: DirRepair[] = []
|
|
45
|
+
const refusals: DirRefusal[] = []
|
|
46
|
+
for (const dotted of names) {
|
|
47
|
+
const shape = shapeOf(dotted)
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (entry === dotted || shapeOf(entry) !== shape) continue
|
|
50
|
+
if (present.has(dotted)) refusals.push({ from: entry, to: dotted, reason: 'both names exist, and merging them decides which file wins' })
|
|
51
|
+
else repairs.push({ from: entry, to: dotted })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { repairs, refusals }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const isEmptyDir = (path: string): boolean => {
|
|
58
|
+
try { return readdirSync(path).length === 0 } catch { return false }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dirsIn = (root: string): string[] => {
|
|
62
|
+
try { return readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) } catch { return [] }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type DirRepairResult = { renamed: DirRepair[]; removed: string[]; refusals: DirRefusal[] }
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Apply the plan. An EMPTY undotted folder is removed rather than renamed:
|
|
69
|
+
* there is nothing to carry over, git never tracked it, and the dotted name is
|
|
70
|
+
* created by whoever files the first note.
|
|
71
|
+
*
|
|
72
|
+
* A rename UNTRACKS whatever the undotted folder held, and that is the intended
|
|
73
|
+
* end state rather than a side effect: the dotted name is gitignored by
|
|
74
|
+
* contract, so the repo was only tracking those files by accident of the
|
|
75
|
+
* missing dot. Hence a plain rename plus a commit of the OLD path: the commit
|
|
76
|
+
* records the files leaving the index, the files themselves stay on disk under
|
|
77
|
+
* the name every scanner reads, and `git commit -- <old>` matches nothing when
|
|
78
|
+
* git never tracked them, which lands no commit and is the right answer there.
|
|
79
|
+
*/
|
|
80
|
+
export function repairContractDirs(root: string): DirRepairResult {
|
|
81
|
+
const plan = planContractDirRepairs(dirsIn(root))
|
|
82
|
+
const renamed: DirRepair[] = []
|
|
83
|
+
const removed: string[] = []
|
|
84
|
+
for (const r of plan.repairs) {
|
|
85
|
+
const from = join(root, r.from)
|
|
86
|
+
const to = join(root, r.to)
|
|
87
|
+
try {
|
|
88
|
+
if (isEmptyDir(from)) {
|
|
89
|
+
rmdirSync(from)
|
|
90
|
+
removed.push(r.from)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
renameSync(from, to)
|
|
94
|
+
renamed.push(r)
|
|
95
|
+
machineCommit({ anchor: root, message: `chore(machine): rename ${r.from} to ${r.to}`, paths: [from] })
|
|
96
|
+
} catch {
|
|
97
|
+
// a permission error or a race is not worth a red gate: the folder
|
|
98
|
+
// stays where it is and the next run tries again
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { renamed, removed, refusals: plan.refusals }
|
|
102
|
+
}
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { RepoConfig } from '../repo-config.ts'
|
|
|
16
16
|
import { runFix } from '../rules/run-fix.ts'
|
|
17
17
|
import { routesToShipkit } from './canonical-scripts.ts'
|
|
18
18
|
import { mutateLease, withMutateLease } from './locked-write.ts'
|
|
19
|
+
import { repairContractDirs } from './repair-contract-dirs.ts'
|
|
19
20
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
20
21
|
|
|
21
22
|
const FIXER_TIMEOUT_MS = 120_000
|
|
@@ -174,6 +175,16 @@ async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, ver
|
|
|
174
175
|
else if (armed.state === 'foreign' || armed.state === 'not-root') o.print(`skip: hooks (${armed.detail})`)
|
|
175
176
|
else if (armed.state === 'failed') o.print(`WARN hooks could not arm: ${armed.detail}`)
|
|
176
177
|
}
|
|
178
|
+
// a contract folder typed without its dot is a typo a machine can fix with
|
|
179
|
+
// certainty, so it is repaired and committed rather than reported. Only the
|
|
180
|
+
// ambiguous case reaches a human. Runs before the tree snapshot: the rename
|
|
181
|
+
// lands in its own commit and is never counted as a fixer rewrite
|
|
182
|
+
if (!verify) {
|
|
183
|
+
for (const r of repairContractDirs(root).refusals) {
|
|
184
|
+
o.print(`WARN ${r.from}/ and ${r.to}/ both exist — ${r.reason}`)
|
|
185
|
+
o.print(` fix: move the files into ${r.to}/ yourself, then delete ${r.from}/`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
177
188
|
const { slots, unfilled, disabled } = resolveFixers(root, config)
|
|
178
189
|
for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
|
|
179
190
|
for (const d of disabled) o.print(`skip: ${d.id} (${d.reason})`)
|
package/src/core/write-status.ts
CHANGED
|
@@ -24,11 +24,16 @@ export type StatusOutcome = { written: boolean; warn?: string }
|
|
|
24
24
|
|
|
25
25
|
const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '')
|
|
26
26
|
|
|
27
|
+
/** the task runner's closing tally (`FAIL: 1/5 failed: cycles`) is a line
|
|
28
|
+
* ABOUT the failures, not one of them. Counted alongside the task it
|
|
29
|
+
* summarised, N failing tasks always reported as N+1. */
|
|
30
|
+
const RUN_SUMMARY = /^FAIL:?\s+\d+\/\d+ failed:/
|
|
31
|
+
|
|
27
32
|
export function writeStatus(root: string, s: StatusInput): StatusOutcome {
|
|
28
33
|
const target = join(root, 'STATUS.md')
|
|
29
34
|
const r = withFileLock(target, () => {
|
|
30
35
|
const clean = s.lines.map(stripAnsi)
|
|
31
|
-
const fails = clean.filter((l) => l.startsWith('FAIL')).length
|
|
36
|
+
const fails = clean.filter((l) => l.startsWith('FAIL') && !RUN_SUMMARY.test(l)).length
|
|
32
37
|
const warns = clean.filter((l) => l.startsWith('WARN')).length
|
|
33
38
|
// only the full gate may say "clear to ship": a checks-only run proves
|
|
34
39
|
// the repo is sound, never that the build passes, and a narrowed gate
|
|
@@ -36,8 +41,11 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
|
|
|
36
41
|
// too, or a narrowed green hides behind a nag nobody reads twice
|
|
37
42
|
const full = s.fullGate === true
|
|
38
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)
|
|
39
47
|
const verdict = s.exitCode !== 0 || fails > 0
|
|
40
|
-
? `RED — ${
|
|
48
|
+
? `RED — ${shown} FAIL line${shown === 1 ? '' : 's'}. Fixes are printed under each FAIL.`
|
|
41
49
|
: warns > 0
|
|
42
50
|
? `GREEN with nags — ${warns} WARN. ${full ? 'Proceed.' : `${scope}.`}`
|
|
43
51
|
: `GREEN — ${scope}.`
|
|
@@ -45,8 +53,11 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
|
|
|
45
53
|
const now = new Date()
|
|
46
54
|
const p = (n: number): string => String(n).padStart(2, '0')
|
|
47
55
|
const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${p(now.getHours())}:${p(now.getMinutes())}`
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
// SK004 ignores AND untracks this file, so the old header, which asked
|
|
57
|
+
// to be committed, sent readers straight into the state that fixer
|
|
58
|
+
// exists to undo
|
|
59
|
+
const body = `<!-- generated by \`${s.command}\`, rewritten on every run, gitignored (SK004) -->
|
|
60
|
+
# ${s.name} status, ${stamp}
|
|
50
61
|
|
|
51
62
|
**${verdict}**
|
|
52
63
|
|
package/src/init-repo.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// from node_modules. Only bodies shipkit cannot express as a verb are
|
|
8
8
|
// scaffolded: an alias script would be a second name for one capability.
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
10
|
-
import { join } from 'path'
|
|
10
|
+
import { basename, join } from 'path'
|
|
11
11
|
import { canonicalBodies } from './core/canonical-scripts.ts'
|
|
12
12
|
import { hasTestFiles, packageJsonShape } from './rules/SK001-package-json-shape.ts'
|
|
13
13
|
import { oneWayInPolicy } from './rules/SK013-one-way-in-policy.ts'
|
|
@@ -16,24 +16,35 @@ import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
|
|
|
16
16
|
import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
|
|
17
17
|
import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
|
|
18
18
|
|
|
19
|
+
// This lands as SOURCE in the repo being wired, so its comments answer to that
|
|
20
|
+
// repo's comment style, not to this file's. Dash punctuation is banned there
|
|
21
|
+
// and a scaffold is the one comment nobody rewrites, so it teaches the style
|
|
22
|
+
// of whatever wrote it. `DASH_PUNCTUATION` plus a test gate the shape.
|
|
19
23
|
const CONFIG_TEMPLATE = (name: string): string => `import { defineRepo } from 'rvlib-shipkit'
|
|
20
24
|
|
|
21
25
|
export default defineRepo({
|
|
22
26
|
name: '${name}',
|
|
23
|
-
venues: [], // e.g. ['itch', 'steam']
|
|
24
|
-
social: [], // e.g. ['x', 'discord']
|
|
27
|
+
venues: [], // e.g. ['itch', 'steam'], selects which .rv-<venue>/ contracts are REQUIRED
|
|
28
|
+
social: [], // e.g. ['x', 'discord'], the .rv-social/<channel>/ post folders (+ .rv-discord/)
|
|
25
29
|
tasks: [], // repo commit-gate tasks; deps double as race guards
|
|
26
|
-
// .comfy-ts/ holds schema dumps naming a private model collection
|
|
30
|
+
// .comfy-ts/ holds schema dumps naming a private model collection.
|
|
27
31
|
// gitignore (SK004) is advisory, this pre-commit gate is not
|
|
28
32
|
hooks: { blockedPaths: ['.comfy-ts/'] },
|
|
29
|
-
// the mandated reviewer agent type (rule SK008)
|
|
33
|
+
// the mandated reviewer agent type (rule SK008). Uncomment with YOUR
|
|
30
34
|
// standing brief, then \`shipkit check --fix SK008\` writes the agent file:
|
|
31
35
|
// reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
|
|
32
|
-
// health checks
|
|
36
|
+
// health checks need no config. Drop a <name>.check.ts file anywhere,
|
|
33
37
|
// \`shipkit check\` auto-discovers and runs it (wire output, exit code)
|
|
34
38
|
})
|
|
35
39
|
`
|
|
36
40
|
|
|
41
|
+
/** dash punctuation, in any of the three shapes the ban covers */
|
|
42
|
+
export const DASH_PUNCTUATION = /[—–]|(?<=\s)-(?=\s)/
|
|
43
|
+
|
|
44
|
+
/** the scaffolded config, for a gate that this text stays writable into a repo
|
|
45
|
+
* that bans the dash. Exported because the template itself is private. */
|
|
46
|
+
export const scaffoldedConfig = (name: string): string => CONFIG_TEMPLATE(name)
|
|
47
|
+
|
|
37
48
|
export type InitOpts = {
|
|
38
49
|
/** in-repo-source consumers: path to shipkit's cli.ts — scripts become `bun <path> <cmd>` instead of `shipkit <cmd>` */
|
|
39
50
|
bin?: string
|
|
@@ -47,8 +58,20 @@ export type InitOpts = {
|
|
|
47
58
|
noPublish?: boolean
|
|
48
59
|
/** how `gh` runs — injected by tests, which never touch a real account */
|
|
49
60
|
gh?: Run | null
|
|
61
|
+
/** what this repo is CALLED, when the caller already knows. `shipkit start
|
|
62
|
+
* <name>` does, and a repo being adopted usually carries a package.json
|
|
63
|
+
* from whatever it was forked from, so reading the name out of that file
|
|
64
|
+
* writes a template's identity into the config every agent then trusts. */
|
|
65
|
+
name?: string
|
|
50
66
|
}
|
|
51
67
|
|
|
68
|
+
/** the repo's name, in decreasing order of how much the caller meant it: what
|
|
69
|
+
* was asked for, what the manifest claims, then the directory it lives in.
|
|
70
|
+
* The directory is a real answer; `my-repo` was a placeholder waiting to be
|
|
71
|
+
* committed by someone who did not notice it. */
|
|
72
|
+
export const repoName = (root: string, asked?: string, fromPkg?: string): string =>
|
|
73
|
+
[asked, fromPkg, basename(root)].find((n) => n != null && n !== '') ?? 'my-repo'
|
|
74
|
+
|
|
52
75
|
export async function initRepo(root: string, opts: InitOpts = {}): Promise<number> {
|
|
53
76
|
const p = opts.print ?? console.log
|
|
54
77
|
const configPath = join(root, 'repo.config.ts')
|
|
@@ -79,7 +102,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
79
102
|
// guaranteed by the refusal above: no config means a package.json is here
|
|
80
103
|
const pkgRaw = readFileSync(pkgPath, 'utf8')
|
|
81
104
|
const pkg = JSON.parse(pkgRaw) as { name?: string; scripts?: Record<string, string> }
|
|
82
|
-
const name = pkg.name
|
|
105
|
+
const name = repoName(root, opts.name, pkg.name)
|
|
83
106
|
writeFileSync(configPath, CONFIG_TEMPLATE(name))
|
|
84
107
|
p('ok: repo.config.ts created')
|
|
85
108
|
const indent = pkgRaw.match(/^(\s+)"/m)?.[1] ?? ' '
|
package/src/mv/mv-md.ts
CHANGED
|
@@ -6,20 +6,70 @@
|
|
|
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, readFileSync, writeFileSync } from 'fs'
|
|
10
|
-
import { basename, dirname, join } from 'path'
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
10
|
+
import { basename, dirname, join, relative } from 'path'
|
|
11
11
|
|
|
12
12
|
export type MvIo = { print: (l: string) => void; printErr: (l: string) => void }
|
|
13
13
|
export type MvOpts = { write?: boolean; commit?: boolean }
|
|
14
14
|
|
|
15
15
|
const sh = (cmd: string, cwd: string): string => execSync(cmd, { cwd }).toString()
|
|
16
16
|
|
|
17
|
+
const posix = (p: string): string => p.replace(/\\/g, '/')
|
|
18
|
+
const dirOf = (p: string): string => { const d = dirname(p); return d === '.' ? '' : d }
|
|
19
|
+
|
|
20
|
+
// A reference written RELATIVE to the file that carries it survives a same-dir
|
|
21
|
+
// rename untouched, which is why sweeping path TOKENS was enough while moves
|
|
22
|
+
// were confined to one directory. A move between directories invalidates both
|
|
23
|
+
// 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
|
+
const REF_FORMS: { re: RegExp; group: number }[] = [
|
|
28
|
+
{ re: /\]\(([^)\s]+)\)/g, group: 1 }, // [text](target)
|
|
29
|
+
{ re: /(→\s+)([^\s,;)`]+)/g, group: 2 }, // → target
|
|
30
|
+
{ re: /^(@)(\S+)$/gm, group: 2 }, // @target, the include line
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/** rewrite every relative reference in one file so it still points at the same
|
|
34
|
+
* thing after the move. `moves` is every path this operation relocates; a ref
|
|
35
|
+
* that names none of them still moves when the FILE moved under it. */
|
|
36
|
+
export function retargetRelativeRefs(root: string, text: string, fromDir: string, toDir: string, moves: Map<string, string>): string {
|
|
37
|
+
const fix = (target: string): string | null => {
|
|
38
|
+
// a url, an absolute path and a bare anchor are not ours to resolve
|
|
39
|
+
if (target === '' || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('/') || target.startsWith('#')) return null
|
|
40
|
+
const hash = target.indexOf('#')
|
|
41
|
+
const path = hash === -1 ? target : target.slice(0, hash)
|
|
42
|
+
const anchor = hash === -1 ? '' : target.slice(hash)
|
|
43
|
+
if (path === '') return null
|
|
44
|
+
const from = posix(join(fromDir, path))
|
|
45
|
+
// a target that climbs out of the repo is not this repo's to rewrite,
|
|
46
|
+
// and existsSync would happily confirm something on the wider disk
|
|
47
|
+
if (from.startsWith('../')) return null
|
|
48
|
+
const to = moves.get(from)
|
|
49
|
+
// nothing moved for this reference: neither its target nor its carrier
|
|
50
|
+
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
|
|
53
|
+
const next = posix(relative(toDir, to ?? from))
|
|
54
|
+
if (next === '') return null
|
|
55
|
+
return `${path.startsWith('./') && !next.startsWith('.') ? `./${next}` : next}${anchor}`
|
|
56
|
+
}
|
|
57
|
+
let out = text
|
|
58
|
+
for (const { re, group } of REF_FORMS) {
|
|
59
|
+
out = out.replace(re, (m, ...rest) => {
|
|
60
|
+
const target = rest[group - 1] as string
|
|
61
|
+
const next = fix(target)
|
|
62
|
+
return next == null ? m : m.replace(target, next)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
17
68
|
export function movePage(root: string, oldRel: string, newRel: string, io: MvIo, o: MvOpts = {}): number {
|
|
18
69
|
const die = (m: string): number => { io.printErr(`FAIL: ${m}`); return 1 }
|
|
19
70
|
if (!oldRel.endsWith('.md') || !newRel.endsWith('.md')) return die('both paths must be .md (TS moves are not this tool\'s job)')
|
|
20
71
|
if (!existsSync(join(root, oldRel))) return die(`${oldRel} does not exist`)
|
|
21
72
|
if (existsSync(join(root, newRel))) return die(`${newRel} already exists`)
|
|
22
|
-
if (dirname(oldRel) !== dirname(newRel)) return die('cross-directory moves not supported yet, same dir only')
|
|
23
73
|
|
|
24
74
|
const write = o.write === true || o.commit === true
|
|
25
75
|
const oldName = basename(oldRel, '.md')
|
|
@@ -50,12 +100,24 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
|
|
|
50
100
|
|
|
51
101
|
const tracked = sh('git ls-files', root).split('\n').filter(Boolean)
|
|
52
102
|
.filter((f) => !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|zip|gz|lockb|lock)$/i.test(f))
|
|
103
|
+
|
|
104
|
+
// every path this move relocates, old → new. The sibling dir carries its
|
|
105
|
+
// whole subtree, and the probe is renamed after the page, so its own entry
|
|
106
|
+
// is written LAST and wins over the subtree rule.
|
|
107
|
+
const moves = new Map<string, string>([[oldRel, newRel]])
|
|
108
|
+
if (existsSync(join(root, oldBase))) {
|
|
109
|
+
moves.set(oldBase, newBase)
|
|
110
|
+
for (const f of tracked) if (f.startsWith(`${oldBase}/`)) moves.set(f, `${newBase}${f.slice(oldBase.length)}`)
|
|
111
|
+
const probe = `${oldBase}/${oldName}.check.ts`
|
|
112
|
+
if (existsSync(join(root, probe))) moves.set(probe, `${newBase}/${newName}.check.ts`)
|
|
113
|
+
}
|
|
114
|
+
|
|
53
115
|
let touched = 0
|
|
54
116
|
for (const f of tracked) {
|
|
55
117
|
const p = join(root, f)
|
|
56
118
|
if (!existsSync(p)) continue
|
|
57
119
|
const before = readFileSync(p, 'utf8')
|
|
58
|
-
let after = before
|
|
120
|
+
let after = retargetRelativeRefs(root, before, dirOf(f), dirOf(moves.get(f) ?? f), moves)
|
|
59
121
|
for (const [oldTok, newTok] of pairs) after = after.replace(bound(oldTok), newTok)
|
|
60
122
|
if (after !== before) {
|
|
61
123
|
io.print(` ${f}`)
|
|
@@ -66,12 +128,18 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
|
|
|
66
128
|
io.print(`${write ? 'renamed' : 'DRY RUN'}: ${oldBase} → ${newBase}, refs in ${touched} file(s)`)
|
|
67
129
|
if (!write) { io.print('re-run with --write (or --commit) to apply'); return 0 }
|
|
68
130
|
|
|
69
|
-
// moves AFTER the sweep (the sweep rewrote files at their old paths)
|
|
131
|
+
// moves AFTER the sweep (the sweep rewrote files at their old paths).
|
|
132
|
+
// git mv refuses into a directory that is not there yet, and a move
|
|
133
|
+
// between directories is the case where it usually is not
|
|
134
|
+
mkdirSync(join(root, dirOf(newRel)), { recursive: true })
|
|
70
135
|
sh(`git mv ${JSON.stringify(oldRel)} ${JSON.stringify(newRel)}`, root)
|
|
71
136
|
if (existsSync(join(root, oldBase))) {
|
|
72
137
|
sh(`git mv ${JSON.stringify(oldBase)} ${JSON.stringify(newBase)}`, root)
|
|
138
|
+
// the probe is named after its page, so it only moves when the NAME
|
|
139
|
+
// changed. A move that keeps the name and changes the directory takes
|
|
140
|
+
// the probe along inside the dir, and `git mv X X` is a fatal
|
|
73
141
|
const oldCheck = join(newBase, `${oldName}.check.ts`)
|
|
74
|
-
if (existsSync(join(root, oldCheck))) sh(`git mv ${JSON.stringify(oldCheck)} ${JSON.stringify(join(newBase, `${newName}.check.ts`))}`, root)
|
|
142
|
+
if (oldName !== newName && existsSync(join(root, oldCheck))) sh(`git mv ${JSON.stringify(oldCheck)} ${JSON.stringify(join(newBase, `${newName}.check.ts`))}`, root)
|
|
75
143
|
}
|
|
76
144
|
|
|
77
145
|
// repo-specific re-stamps (e.g. wiki depth frontmatter) — loud on failure
|
|
@@ -18,7 +18,7 @@ const BEGIN = '# >>> shipkit gitignore policy (SK004) — managed block, edit ou
|
|
|
18
18
|
const END = '# <<< shipkit gitignore policy'
|
|
19
19
|
const CONTRIB = '# repo-contributed lines (repo.config.ts gitignore:)'
|
|
20
20
|
|
|
21
|
-
const POLICY_LINES = [
|
|
21
|
+
export const POLICY_LINES = [
|
|
22
22
|
// the leak guard reads .rv-private/banned-keywords.txt: the file listing
|
|
23
23
|
// what must never be committed cannot itself be committable
|
|
24
24
|
'.rv-private/',
|
package/src/start-repo.ts
CHANGED
|
@@ -138,7 +138,10 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
138
138
|
let unscaffolded = false
|
|
139
139
|
if (!existsSync(join(dir, 'repo.config.ts'))) {
|
|
140
140
|
if (existsSync(join(dir, 'package.json'))) {
|
|
141
|
-
|
|
141
|
+
// the name is on the command line and is the directory too, so
|
|
142
|
+
// an adopted repo never takes its identity from whatever
|
|
143
|
+
// package.json it was forked with
|
|
144
|
+
try { await initRepo(dir, { name, github: opts.github, gh: opts.gh, print, noPublish: true }) }
|
|
142
145
|
catch (e) { console.error(`WARN could not wire ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
143
146
|
} else unscaffolded = true
|
|
144
147
|
}
|
|
@@ -159,19 +162,35 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
159
162
|
return 0
|
|
160
163
|
}
|
|
161
164
|
|
|
165
|
+
/** what the closing line is allowed to say, all of it measured */
|
|
166
|
+
export type RepoFacts = { tracked: number; uncommitted: number; hasHead: boolean; origin: string | null; pushed: boolean }
|
|
167
|
+
|
|
162
168
|
/** the one closing fact: what this repo IS now, measured rather than
|
|
163
169
|
* narrated. Everything a repo still needs is reported by the gate, about a
|
|
164
|
-
* repo with code in it, on the day it matters
|
|
170
|
+
* repo with code in it, on the day it matters.
|
|
171
|
+
*
|
|
172
|
+
* `tracked` is what git INDEXES, which is not what this run committed. On an
|
|
173
|
+
* adopt path the run correctly leaves a pre-existing history alone, and
|
|
174
|
+
* calling that count "committed" told the reader their work was safe at the
|
|
175
|
+
* exact moment files were sitting staged. So the line names the measurement,
|
|
176
|
+
* and anything still uncommitted is said out loud next to it. */
|
|
177
|
+
export function summaryLine(f: RepoFacts): string {
|
|
178
|
+
const bits = [f.hasHead ? `${f.tracked} file(s) tracked${f.uncommitted > 0 ? `, ${f.uncommitted} uncommitted` : ''}` : 'not committed yet']
|
|
179
|
+
if (f.origin != null) bits.push(f.pushed ? `pushed to ${f.origin}` : 'remote wired, nothing pushed yet')
|
|
180
|
+
else bits.push('local only, no remote')
|
|
181
|
+
return bits.join(', ')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const countLines = (s: string): number => s.trim().split('\n').filter((l) => l !== '').length
|
|
185
|
+
|
|
165
186
|
async function summarize(dir: string): Promise<string> {
|
|
166
187
|
const git = runner('git', dir, 30_000)
|
|
167
|
-
const
|
|
188
|
+
const tracked = countLines((await git(['ls-files'])).out)
|
|
189
|
+
const uncommitted = countLines((await git(['status', '--porcelain'])).out)
|
|
168
190
|
const head = await git(['log', '-1', '--format=%h'])
|
|
169
191
|
// git merges stderr into stdout, so a missing remote answers with its
|
|
170
192
|
// error text: only a zero exit means that output is a url
|
|
171
193
|
const origin = await git(['remote', 'get-url', 'origin'])
|
|
172
194
|
const pushed = (await git(['rev-parse', '--verify', '@{upstream}'])).code === 0
|
|
173
|
-
|
|
174
|
-
if (origin.code === 0) bits.push(pushed ? `pushed to ${origin.out.trim()}` : `remote wired, nothing pushed yet`)
|
|
175
|
-
else bits.push('local only, no remote')
|
|
176
|
-
return bits.join(', ')
|
|
195
|
+
return summaryLine({ tracked, uncommitted, hasHead: head.code === 0, origin: origin.code === 0 ? origin.out.trim() : null, pushed })
|
|
177
196
|
}
|
package/src/ts/imports.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// bundler suffixes (?raw, ?url), template-literal specifiers, packages.
|
|
13
13
|
import { readdirSync, readFileSync, writeFileSync, existsSync, type Dirent } from 'fs'
|
|
14
14
|
import { dirname, extname, isAbsolute, join, relative, resolve } from 'path'
|
|
15
|
-
import { canonicalSpecifier, isSkipped, loadConfig, resolvePackageSpecifier, toPosix, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
|
|
15
|
+
import { canonicalSpecifier, isSkipped, loadConfig, parseJsonc, resolvePackageSpecifier, toPosix, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
|
|
16
16
|
import { templateLineMask } from 'rvlib-shipkit/src/ts/source-mask.ts'
|
|
17
17
|
|
|
18
18
|
const ASSET_EXTENSIONS = new Set([
|
|
@@ -42,7 +42,78 @@ const cachedExists = (p: string): boolean => {
|
|
|
42
42
|
}
|
|
43
43
|
return r
|
|
44
44
|
}
|
|
45
|
-
export const clearExistsCache = (): void =>
|
|
45
|
+
export const clearExistsCache = (): void => {
|
|
46
|
+
existsCache.clear()
|
|
47
|
+
resolutionCache.clear()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── can the REPO resolve what this tool is about to write? ────────────────
|
|
51
|
+
//
|
|
52
|
+
// The canonical form ('src/foo/Bar.ts', 'rvlib-x/src/foo.ts') is not a path
|
|
53
|
+
// any module resolver follows on its own: the repo MAPS it, through tsconfig
|
|
54
|
+
// `paths`, a `baseUrl` the prefix sits under, or a real package in
|
|
55
|
+
// node_modules. Nothing here validated that, and the tool's own validation
|
|
56
|
+
// asked the DISK whether the target file existed, which it always did. So in
|
|
57
|
+
// a repo with no mapping the rewrite turned a building tree into hundreds of
|
|
58
|
+
// TS2307s, and the check that ran next reported ok over exactly that state.
|
|
59
|
+
//
|
|
60
|
+
// A prefix nothing maps is therefore never a rewrite TARGET. Only the write
|
|
61
|
+
// is refused: a specifier already in canonical form is left alone and
|
|
62
|
+
// reported by nobody, because this tool did not put it there and the
|
|
63
|
+
// typechecker owns that verdict.
|
|
64
|
+
|
|
65
|
+
type RepoResolution = { prefixes: Set<string>; baseDirs: string[] }
|
|
66
|
+
const resolutionCache = new Map<string, RepoResolution>()
|
|
67
|
+
|
|
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. */
|
|
71
|
+
function repoResolution(root: string): RepoResolution {
|
|
72
|
+
const hit = resolutionCache.get(root)
|
|
73
|
+
if (hit != null) return hit
|
|
74
|
+
const prefixes = new Set<string>()
|
|
75
|
+
const baseDirs: string[] = []
|
|
76
|
+
let names: string[] = []
|
|
77
|
+
try {
|
|
78
|
+
names = readdirSync(root).filter((n) => /^tsconfig(\..+)?\.json$/.test(n))
|
|
79
|
+
} catch {
|
|
80
|
+
// unreadable root: nothing is mapped, and every rewrite refuses loudly
|
|
81
|
+
}
|
|
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 }
|
|
94
|
+
resolutionCache.set(root, r)
|
|
95
|
+
return r
|
|
96
|
+
}
|
|
97
|
+
|
|
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]!
|
|
103
|
+
|
|
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))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const unresolvableReason = (prefix: string, isPackage: boolean): string =>
|
|
114
|
+
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`
|
|
46
117
|
|
|
47
118
|
function resolveFileExtension(absPath: string): string | null {
|
|
48
119
|
if (/\.(d\.ts|tsx?|json)$/.test(absPath) && cachedExists(absPath)) return absPath
|
|
@@ -125,7 +196,13 @@ export function rewriteFile(filePath: string, cfg: RvtConfig, save: boolean): Im
|
|
|
125
196
|
pushError(lineNum, specifier, `resolved outside allowed roots (${cfg.roots.join(', ')}): ${rel}`)
|
|
126
197
|
return null
|
|
127
198
|
}
|
|
128
|
-
|
|
199
|
+
if (next === specifier) return null
|
|
200
|
+
const prefix = specifierPrefix(next, cfg)
|
|
201
|
+
if (!repoCanResolve(prefix, cfg)) {
|
|
202
|
+
pushError(lineNum, specifier, unresolvableReason(prefix, cfg.packages.some((p) => p.name === prefix)))
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
return next
|
|
129
206
|
}
|
|
130
207
|
const sub = (match: string, quote: string, specifier: string, lineNum: number, isRelative: boolean): string => {
|
|
131
208
|
const next = resolveNew(specifier, lineNum, isRelative)
|