rvlib-shipkit 0.35.1 → 0.35.2

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.2
4
+
5
+ - **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.
6
+ - 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.
7
+ - **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.
8
+
9
+ - **`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.
10
+
11
+ - **`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.
12
+
13
+ - **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.
14
+
15
+ - **`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.
16
+
17
+ - **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.
18
+
3
19
  ## 0.35.1
4
20
 
5
21
  - **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.1",
3
+ "version": "0.35.2",
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
+ }
@@ -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
- * Scoped hard, because several agents share this checkout:
42
- * - `git commit -- <path>` takes the WORKING TREE version of that one path and
43
- * never reads the index, so a neighbour's staged work cannot ride along.
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
- if (process.env['SHIPKIT_NO_AUTOCOMMIT'] === '1') return ''
55
- try {
56
- const dir = resolve(page, '..')
57
- const top = git(dir, ['rev-parse', '--show-toplevel'])
58
- if (!top.ok) return '' // the docs tree is not a git repo, nothing to land
59
- const root = top.out
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
+ }
@@ -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})`)
@@ -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 — ${Math.max(fails, 1)} FAIL line${fails === 1 ? '' : 's'}. Fixes are printed under each FAIL.`
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
- const body = `<!-- generated by \`${s.command}\` — commit me to track progress over time -->
49
- # ${s.name} status — ${stamp}
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'] — selects which .rv-<venue>/ contracts are REQUIRED
24
- social: [], // e.g. ['x', 'discord'] — .rv-social/<channel>/ post folders (+ .rv-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) — uncomment with YOUR
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: no config — drop a <name>.check.ts file anywhere,
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 ?? 'my-repo'
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,67 @@
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
+ const to = moves.get(from)
46
+ // nothing moved for this reference: neither its target nor its carrier
47
+ if (to == null && fromDir === toDir) return null
48
+ // a target that is not a real path is prose, not a link
49
+ if (to == null && !existsSync(join(root, from))) return null
50
+ const next = posix(relative(toDir, to ?? from))
51
+ if (next === '') return null
52
+ return `${path.startsWith('./') && !next.startsWith('.') ? `./${next}` : next}${anchor}`
53
+ }
54
+ let out = text
55
+ for (const { re, group } of REF_FORMS) {
56
+ out = out.replace(re, (m, ...rest) => {
57
+ const target = rest[group - 1] as string
58
+ const next = fix(target)
59
+ return next == null ? m : m.replace(target, next)
60
+ })
61
+ }
62
+ return out
63
+ }
64
+
17
65
  export function movePage(root: string, oldRel: string, newRel: string, io: MvIo, o: MvOpts = {}): number {
18
66
  const die = (m: string): number => { io.printErr(`FAIL: ${m}`); return 1 }
19
67
  if (!oldRel.endsWith('.md') || !newRel.endsWith('.md')) return die('both paths must be .md (TS moves are not this tool\'s job)')
20
68
  if (!existsSync(join(root, oldRel))) return die(`${oldRel} does not exist`)
21
69
  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
70
 
24
71
  const write = o.write === true || o.commit === true
25
72
  const oldName = basename(oldRel, '.md')
@@ -50,12 +97,24 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
50
97
 
51
98
  const tracked = sh('git ls-files', root).split('\n').filter(Boolean)
52
99
  .filter((f) => !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|zip|gz|lockb|lock)$/i.test(f))
100
+
101
+ // every path this move relocates, old → new. The sibling dir carries its
102
+ // whole subtree, and the probe is renamed after the page, so its own entry
103
+ // is written LAST and wins over the subtree rule.
104
+ const moves = new Map<string, string>([[oldRel, newRel]])
105
+ if (existsSync(join(root, oldBase))) {
106
+ moves.set(oldBase, newBase)
107
+ for (const f of tracked) if (f.startsWith(`${oldBase}/`)) moves.set(f, `${newBase}${f.slice(oldBase.length)}`)
108
+ const probe = `${oldBase}/${oldName}.check.ts`
109
+ if (existsSync(join(root, probe))) moves.set(probe, `${newBase}/${newName}.check.ts`)
110
+ }
111
+
53
112
  let touched = 0
54
113
  for (const f of tracked) {
55
114
  const p = join(root, f)
56
115
  if (!existsSync(p)) continue
57
116
  const before = readFileSync(p, 'utf8')
58
- let after = before
117
+ let after = retargetRelativeRefs(root, before, dirOf(f), dirOf(moves.get(f) ?? f), moves)
59
118
  for (const [oldTok, newTok] of pairs) after = after.replace(bound(oldTok), newTok)
60
119
  if (after !== before) {
61
120
  io.print(` ${f}`)
@@ -66,12 +125,18 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
66
125
  io.print(`${write ? 'renamed' : 'DRY RUN'}: ${oldBase} → ${newBase}, refs in ${touched} file(s)`)
67
126
  if (!write) { io.print('re-run with --write (or --commit) to apply'); return 0 }
68
127
 
69
- // moves AFTER the sweep (the sweep rewrote files at their old paths)
128
+ // moves AFTER the sweep (the sweep rewrote files at their old paths).
129
+ // git mv refuses into a directory that is not there yet, and a move
130
+ // between directories is the case where it usually is not
131
+ mkdirSync(join(root, dirOf(newRel)), { recursive: true })
70
132
  sh(`git mv ${JSON.stringify(oldRel)} ${JSON.stringify(newRel)}`, root)
71
133
  if (existsSync(join(root, oldBase))) {
72
134
  sh(`git mv ${JSON.stringify(oldBase)} ${JSON.stringify(newBase)}`, root)
135
+ // the probe is named after its page, so it only moves when the NAME
136
+ // changed. A move that keeps the name and changes the directory takes
137
+ // the probe along inside the dir, and `git mv X X` is a fatal
73
138
  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)
139
+ if (oldName !== newName && existsSync(join(root, oldCheck))) sh(`git mv ${JSON.stringify(oldCheck)} ${JSON.stringify(join(newBase, `${newName}.check.ts`))}`, root)
75
140
  }
76
141
 
77
142
  // 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
- try { await initRepo(dir, { github: opts.github, gh: opts.gh, print, noPublish: true }) }
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 files = (await git(['ls-files'])).out.trim().split('\n').filter((l) => l !== '').length
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
- const bits = [head.code === 0 ? `${files} file(s) committed` : 'not committed yet']
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 => existsCache.clear()
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
- return next === specifier ? null : next
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)