rvlib-shipkit 0.22.2 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.0
4
+
5
+ - **Release order is a TOPOSORT of the units' own dependency graph.** It was "sub-units first, root last", so a `--all` run published in declaration order and a dependent could ship naming a version the registry did not have yet. Kahn's algorithm now, stable (ties keep declaration order, root always last), and a cycle degrades to declaration order rather than dropping a unit: a release that silently skips a package is worse than one in a bad order.
6
+
7
+ - **The source-graph toolbelt lives IN shipkit** (`src/ts/`): rename, imports, cycles, and the shared config. `rvlib-ts-tools` is gone as a dependency. A gate that REQUIRES a tool must never be a published version behind the repo it gates, which is exactly how `shipkit mv` renamed a file and rewrote zero references while reporting success.
8
+ - **Fix: `shipkit mv` was blind to whole trees.** The default skip list matched a bare name against ANY path segment, so a package under a dir named `build` (or `dist`, `out`, `cjs`) was invisible to every scan. Those names now match only where output actually lands: the repo root, or a package root. `node_modules` and `.git` still match at any depth.
9
+ - **Fix: `shipkit mv` derives the package-qualified specifier by walking UP from the file**, not from the configured roots. The content scan was always repo-wide, so the specifier derivation had to be too: a repo whose packages sit outside `roots` renamed the file and rewrote nothing.
10
+ - **`shipkit mv` reports what it CANNOT rewrite.** A file naming the moved file's stem without a rewritable path (a regex-escaped path in a test, a doc line, a specifier built from parts) is listed as a near-miss instead of being silently skipped. It is never rewritten, because guessing there is how a rename corrupts a repo.
11
+
12
+ - **A bump moves its dependents' ranges forward, in the same commit**, including between units bumped in the SAME run: skipping selected units left every sibling range naming the version its dependency had just left. Bumping a unit used to leave every sibling naming the OLD version, so the tree published packages that install the wrong code or nothing at all. `shipkit bump` now rewrites the caret/tilde/exact range in every other unit that names a bumped one, and stages it with the version commit. Local protocols are left alone.
13
+ - **New rule SK011: a sibling range must be satisfied by the version that sibling carries.** The complement of the above, for a hand-edited range or an interrupted bump. It found real drift the day it landed: a peer range of `^0.1.2` on a lib that had moved to `0.2.0`, which a caret on `0.x` genuinely excludes, so consumers were installing the wrong major of it.
14
+
15
+ - **SK006 refuses a local dependency protocol in anything publishable.** `link:`, `file:`, `portal:` or `workspace:` in `dependencies`, `peerDependencies` or `optionalDependencies` cannot resolve on a consumer's machine: publishing that ships a package which fails at INSTALL, for a stranger, never for you. It scans every release UNIT, not just the repo root, because the root is usually private and the units are what publish. devDependencies are exempt (npm never installs them for a consumer), and the working shape is the future version range in the dependency plus the local wiring in `overrides`.
16
+
17
+ - **A leak hit reports a mask, never the matched string** (`kre…v1 (23 chars)`). The pattern is private by construction, and a gate that prints it verbatim leaks it into the terminal, the CI log and any paste of either.
18
+
19
+ - **A temp dir never mints a github repository.** A root under any system temp path (`TMPDIR`, `/tmp`, `/var/tmp`, either spelling of each) skips the github half with a line naming `--github` to force it. An agent rehearsing `shipkit init`/`start` in a scratch dir created three real repositories on the account, and deleting one needs a token scope the machine does not carry. Only the real account is guarded: an injected `gh` runner is a test with an account of its own.
20
+
21
+ - **MobX 7 + mobx-react-lite 5** for the TUI. It is the ONE lib still on `mobx-react-lite`, so the one still pinned to a single major: consuming rvlib-mobx's seam by `link:` makes the TUI resolve React from that lib's own node_modules, two React copies, invalid hook call. That is a `link:`-only artifact, gone once the seam is published and installed as a tarball, so the swap rides the release that picks it up. RELEASE ORDER MATTERS: this version must not ship before `comfy-ts` accepts MobX 7, and its dep range must move to that version in the same release. A comfy-ts that declares `mobx@^6` alone gets a nested copy beside our 7, the observables go invisible across the boundary, and a bare `import 'rvlib-shipkit'` throws "There are multiple, different versions of MobX active" in a consumer's tree. The `overrides` block in this package.json fixes the LOCAL tree only: overrides are honored in the install root, never through a published dependency.
22
+ - **SK005's clean-pass cache is keyed on the INSTALLED rvlib versions.** Keyed on the repo alone, a manual `bun add -d rvlib-x@N` left every run replaying `ok: rvlib-x 0.16.0 (latest) (cached 3h ago)` for 24h, naming a version the tree no longer carried and calling it latest.
23
+ - **`shipkit releases` stops calling a clean root unit edited.** `treeAt` compared what `git rev-parse <ref>` returns, the COMMIT for `HEAD` and the TAG OBJECT for an annotated tag, so a tag sitting exactly on HEAD with an empty diff warned "names contents this branch does not carry". It compares `<ref>^{tree}` now.
24
+ - **`release.after`: the step that makes a release LIVE.** `repo.config.ts release: { after: ['bun', 'run', 'up'] }` runs after the tag, the push and the GitHub release, for the ROOT unit only. Prepublish scripts cannot fill this slot: they run BEFORE the version commit, so they are build steps. It runs LAST, so a failed deploy unwinds nothing already published. It is not resumable by re-running `release` (a fully released unit refuses), so re-run the command it prints.
25
+
26
+ - **The hub banner reads like a banner.** No wire prefixes, no route-per-line checklist: one clickable line (`<repo> hub → http://localhost:<port>`), an indented `also:` line listing the real routes, and the `phone:` line when a public host is configured. Wire prefixes stay reserved for problems.
27
+
3
28
  ## 0.22.2
4
29
 
5
30
  - **Frozen gates never write the motherhome row.** The bump preflight ate the machine dirt, then the gate INSIDE the bump re-wrote the local-projects row and the post-gate cleanliness check refused, the same wart's second head. A frozen run is release-shaped: it reports nothing to the motherhome, so the tree it proves clean stays clean. Interactive ci/check keep reporting.
@@ -7,12 +32,12 @@
7
32
 
8
33
  ## 0.22.1
9
34
 
10
- - **Fix: the cache stamp is baked in at RUN time, replays print it verbatim.** 0.22.0 annotated only the REPLAY, so a fresh run and a cache hit rendered different strings and STATUS.md still diffed on every alternation (his catch, the second one on this line). A TTL clean pass now writes "(cached at <stamp> until <stamp>)" into the line once, live output and every replay are byte-identical, and the line changes only when a re-validation happens. Proven: fresh-then-cached runs produce a zero-diff STATUS body.
35
+ - **Fix: the cache stamp is baked in at RUN time, replays print it verbatim.** 0.22.0 annotated only the REPLAY, so a fresh run and a cache hit rendered different strings and STATUS.md still diffed on every alternation. A TTL clean pass now writes "(cached at <stamp> until <stamp>)" into the line once, live output and every replay are byte-identical, and the line changes only when a re-validation happens. Proven: fresh-then-cached runs produce a zero-diff STATUS body.
11
36
 
12
37
  ## 0.22.0
13
38
 
14
39
  - **Fix: the machine-row auto-commit could not see STATUS.md.** The git helper trims its whole output, so the FIRST porcelain line loses its leading space and the old column-3 slice read "TATUS.md", making the preflight silently decline the exact dirt it was built to eat. `dirtyPathList` parses status prefixes robustly now (renames and quoted paths included, regression-tested on the trimmed-first-line case), and a decline is never silent: the preflight names the non-machine paths it refused.
15
- - **Cached check lines carry an absolute stamp** (`cached 2026-08-05 09:12:34`), never a relative age: "cached 34m ago" re-rendered on every run and turned committed STATUS.md checkpoints into noise diffs that startled agents (his call 2026-08-05). The line now changes only when the cache actually refreshes.
40
+ - **Cached check lines carry an absolute stamp** (`cached 2026-08-05 09:12:34`), never a relative age: "cached 34m ago" re-rendered on every run and turned committed STATUS.md checkpoints into noise diffs. The line now changes only when the cache actually refreshes.
16
41
 
17
42
  - **The pre-commit gate times itself.** Past ~1.5s it WARNs naming the fix (move slow preCommit commands to ci tasks), because "a commit takes ~1s" was a stated rule with no tripwire, and a repo drifting back to 40-second commits would have told nobody.
18
43
  - **The bootstrap is a CHAIN, and it ends at a push.** `shipkit start` and `shipkit init` now run git init → **first commit** → PRIVATE github repo → **push**, in that order. A repo wired but never pushed had backed up nothing, which was the whole point. The chain runs in two phases around whatever the verb scaffolds, so `CLAUDE.md`, `repo.config.ts` and the package.json scripts ride INSIDE the first commit instead of trailing it as dirt.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.22.2",
3
+ "version": "0.23.0",
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",
@@ -24,17 +24,20 @@
24
24
  "@types/react-dom": "^19.2.3",
25
25
  "@types/sanitize-html": "^2.16.1"
26
26
  },
27
+ "overrides": {
28
+ "mobx": "^7.0.0",
29
+ "mobx-react-lite": "^5.0.0"
30
+ },
27
31
  "dependencies": {
28
32
  "@napi-rs/canvas": "^1.0.2",
29
33
  "clipanion": "^4.0.0-rc.4",
30
34
  "comfy-ts": "^2.4.2",
31
35
  "ink": "^7.1.1",
32
36
  "marked": "^18.0.7",
33
- "mobx": "^6.16.1",
34
- "mobx-react-lite": "^4.1.1",
37
+ "mobx": "^7.0.0",
38
+ "mobx-react-lite": "^5.0.0",
35
39
  "react": "^19.2.7",
36
40
  "react-dom": "^19.2.7",
37
- "rvlib-ts-tools": "^0.3.0",
38
41
  "sanitize-html": "^2.17.6",
39
42
  "sharp": "^0.35.3"
40
43
  }
@@ -67,7 +67,7 @@ export class AskNewCommand extends Command {
67
67
  kind = Option.String('--kind', { required: false, description: 'ask kind: action | credential | decision | purchase' })
68
68
  blocks = Option.String('--blocks', { required: false, description: 'what this ask blocks (task id, page, plain words)' })
69
69
  check = Option.String('--check', { required: false, description: 'shell probe that exits 0 once the ask is done (enables auto-close)' })
70
- why = Option.String('--why', { required: false, description: 'one line of context — why this is worth his time' })
70
+ why = Option.String('--why', { required: false, description: 'one line of context: why this is worth a human interrupt' })
71
71
 
72
72
  async execute(): Promise<number> {
73
73
  try {
package/src/cli/cmd_Mv.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  // shipkit mv, ONE rename command for the whole repo. An md page routes to
2
2
  // mv/mv-md.ts (page + sibling asset dir + check.ts + every tracked ref);
3
- // anything else routes to rvlib-ts-tools' rename, which rewrites every path
3
+ // anything else runs shipkit's own rename, which rewrites every path
4
4
  // reference in the code. Renaming used to mean knowing WHICH tool applied,
5
5
  // and knowing that is exactly the kind of doubt the toolkit exists to remove.
6
6
  import { Command, Option } from 'clipanion'
7
- import { fileURLToPath } from 'url'
8
7
  import { movePage } from '../mv/mv-md.ts'
9
- import { spawnWithTimeout } from '../core/spawn-timeout.ts'
8
+ import { renameCli } from '../ts/rename.ts'
10
9
 
11
10
  export class MvCommand extends Command {
12
11
  static override paths = [['mv']]
@@ -37,13 +36,11 @@ export class MvCommand extends Command {
37
36
  this.context.stderr.write('FAIL: --commit is md-only for now — run with --write, then commit\n')
38
37
  return 1
39
38
  }
40
- let cli: string
41
- try { cli = fileURLToPath(import.meta.resolve('rvlib-ts-tools/src/cli.ts')) }
42
- catch { this.context.stderr.write('FAIL: rvlib-ts-tools is unresolvable — reinstall shipkit deps\n'); return 1 }
43
- const r = await spawnWithTimeout(['bun', cli, 'rename', this.oldPath, this.newPath, ...(write ? ['--apply'] : [])], { cwd: process.cwd(), timeoutMs: 120_000 })
44
- this.context.stdout.write(r.out)
45
- if (!write && r.code === 0) this.context.stdout.write('note: dry run — pass --write to apply\n')
46
- return r.code
39
+ // in-process: the rename lives HERE now, so `shipkit mv` can
40
+ // never be a version behind the tool it delegates to
41
+ const code = renameCli([this.oldPath, this.newPath, ...(write ? ['--apply'] : [])])
42
+ if (!write && code === 0) this.context.stdout.write('note: dry run — pass --write to apply\n')
43
+ return code
47
44
  }
48
45
  return movePage(process.cwd(), this.oldPath, this.newPath, {
49
46
  print: (l) => this.context.stdout.write(`${l}\n`),
@@ -55,16 +55,16 @@ export function appendPostFeedback(root: string, channel: string, file: string,
55
55
  writeFileSync(p, /^## Feedback\s*$/m.test(md) ? `${md.trimEnd()}\n${line}\n` : `${md.trimEnd()}\n\n## Feedback\n${line}\n`)
56
56
  }
57
57
 
58
- /** his asset remark → `.rv-assets/notes.md` (git-tracked; the studio
58
+ /** an asset remark → `.rv-assets/notes.md` (git-tracked; the studio
59
59
  * workspace is gitignored, so notes live beside kit.ts) */
60
60
  export function appendAssetNote(root: string, assetId: string, text: string): void {
61
61
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(assetId)) throw new Error('bad asset id')
62
62
  const p = join(root, '.rv-assets', 'notes.md')
63
63
  const line = `- ${today()} [${assetId}]: ${text.replace(/\n+/g, ' ').trim()}`
64
- writeFileSync(p, existsSync(p) ? `${readFileSync(p, 'utf8').trimEnd()}\n${line}\n` : `# asset notes — his remarks, read by agents at boot\n\n${line}\n`)
64
+ writeFileSync(p, existsSync(p) ? `${readFileSync(p, 'utf8').trimEnd()}\n${line}\n` : `# asset notes, read by agents at boot\n\n${line}\n`)
65
65
  }
66
66
 
67
- /** his remark on an ask → appended to the ask file, dated, non-destructive.
67
+ /** a remark on an ask → appended to the ask file, dated, non-destructive.
68
68
  * Accepts exactly one optional `done/` prefix (archived asks stay
69
69
  * annotatable); any other slash or traversal is a bad path. */
70
70
  export function appendAskRemark(root: string, asksDir: string, file: string, text: string): void {
@@ -28,7 +28,7 @@ export type Instance = {
28
28
  file?: string
29
29
  /** thumbnail URL for media tiles */
30
30
  img?: string
31
- /** his latest remarks from the item's own file — agents boot on these */
31
+ /** the latest remarks from the item's own file, agents boot on these */
32
32
  remarks?: string[]
33
33
  /** the truth file's markdown BODY (frontmatter stripped) — clamped card
34
34
  * in the grid, full in the item view; capped server-side, never a dump */
@@ -92,7 +92,7 @@ const askMd = (path: string): string | undefined => {
92
92
  return body.length > MD_CAP ? `${[...body].slice(0, MD_CAP).join('')}…` : body
93
93
  }
94
94
 
95
- /** his latest dated remark lines from a file's tail — the boot briefing */
95
+ /** the latest dated remark lines from a file's tail, the boot briefing */
96
96
  const lastRemarks = (path: string, max = 3): string[] | undefined => {
97
97
  if (!existsSync(path)) return undefined
98
98
  const lines = readFileSync(path, 'utf8').split('\n').filter((l) => /^- \d{4}-\d{2}-\d{2}: /.test(l))
@@ -212,7 +212,7 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
212
212
  }
213
213
  }
214
214
 
215
- /** `.rv-assets/notes.md` — his dated asset remarks, one git-tracked file:
215
+ /** `.rv-assets/notes.md`, dated asset remarks in one git-tracked file:
216
216
  * `- YYYY-MM-DD [asset-id]: text` */
217
217
  const assetNotes = (root: string): Map<string, string[]> => {
218
218
  const p = join(root, '.rv-assets', 'notes.md')
@@ -146,9 +146,9 @@ export class CheckSuite {
146
146
  // the stamp was baked in when the check RAN (below): a replay
147
147
  // prints the stored lines VERBATIM, so the live run and every
148
148
  // later replay render the same string, and the line changes
149
- // only when a re-validation happens (his call 2026-08-05,
150
- // second round: annotating replays but not live runs still
151
- // diffed on every fresh-vs-cached alternation)
149
+ // only when a re-validation happens. Annotating replays but
150
+ // not live runs still diffed on every fresh-vs-cached
151
+ // alternation, so neither side annotates
152
152
  out.push(...entry.lines)
153
153
  this.onResult?.(r.name, { lines: entry.lines, status: 'pass', cached: true }) // only clean passes are cached
154
154
  continue
@@ -21,7 +21,7 @@ const FIXER_TIMEOUT_MS = 120_000
21
21
  export type FixerSlot = {
22
22
  id: string
23
23
  cmd: string[]
24
- source: 'package.json script' | 'built-in (rvlib-ts-tools)'
24
+ source: 'package.json script' | 'built-in (shipkit)'
25
25
  }
26
26
 
27
27
  export type FixerResolution = {
@@ -68,9 +68,8 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
68
68
  else if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
69
69
  else {
70
70
  let cli: string | null = null
71
- try { cli = fileURLToPath(import.meta.resolve('rvlib-ts-tools/src/cli.ts')) } catch { cli = null }
72
- if (cli == null) unfilled.push({ id: 'imports:fix', reason: `no 'imports:fix' script and rvlib-ts-tools is unresolvable` })
73
- else slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (rvlib-ts-tools)' })
71
+ cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
72
+ slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (shipkit)' })
74
73
  }
75
74
  return { slots, unfilled, disabled }
76
75
  }
@@ -15,6 +15,7 @@
15
15
  // unverifiable path (no gh, not authenticated, offline) is a WARN that
16
16
  // leaves the local repo wired and names the next move.
17
17
  import { existsSync, realpathSync } from 'fs'
18
+ import { tmpdir } from 'os'
18
19
  import { basename, join } from 'path'
19
20
  import { spawnWithTimeout } from './core/spawn-timeout.ts'
20
21
 
@@ -76,6 +77,20 @@ const runner = (bin: string, root: string, timeoutMs: number): Run => async (cmd
76
77
 
77
78
  const gitIn = (root: string): Run => runner('git', root, 120_000)
78
79
 
80
+ /** the system temp tree, resolved through the symlink macOS puts in front of
81
+ * it. A repo under here is a rehearsal by construction */
82
+ export const isScratch = (root: string): boolean => {
83
+ const real = (p: string): string => { try { return realpathSync(p) } catch { return p } }
84
+ // every temp root, not just the one TMPDIR names: macOS answers
85
+ // /var/folders there while agents and harnesses scratch under /tmp, and a
86
+ // repo minted from /private/tmp is exactly as unwanted. Both spellings of
87
+ // both sides too, since /var resolves to /private/var and a path that
88
+ // does not exist yet cannot be resolved at all
89
+ const tmps = [tmpdir(), real(tmpdir()), '/tmp', '/private/tmp', '/var/tmp', '/private/var/tmp']
90
+ const roots = [root, real(root)]
91
+ return tmps.some((t) => roots.some((r) => `${r}/`.startsWith(`${t}/`)))
92
+ }
93
+
79
94
  const samePath = (a: string, b: string): boolean => {
80
95
  try {
81
96
  return realpathSync(a) === realpathSync(b)
@@ -152,7 +167,15 @@ export async function publishRepo(root: string, opts: PublishOpts = {}): Promise
152
167
  let gh: Run | null
153
168
  if (opts.gh !== undefined) gh = opts.gh
154
169
  else if ((process.env.SHIPKIT_NO_GITHUB ?? '') !== '') return void p('skip: github — SHIPKIT_NO_GITHUB is set')
155
- else gh = Bun.which('gh') == null ? null : runner('gh', root, 60_000)
170
+ else if (opts.github !== true && isScratch(root)) {
171
+ // a scratch dir is somebody TRYING the verb, never starting a project.
172
+ // Two repositories were minted exactly that way, and deleting one
173
+ // needs a token scope this machine does not carry, so the cost of
174
+ // guessing wrong is not symmetric. Only the REAL account is guarded:
175
+ // an injected runner is a test with an account of its own
176
+ p(`skip: github — ${root} is a temp dir, not a project`)
177
+ return void p(' fix: pass --github if you really want a repo for it')
178
+ } else gh = Bun.which('gh') == null ? null : runner('gh', root, 60_000)
156
179
  if (gh == null) {
157
180
  p('WARN no remote wired: gh cli not found')
158
181
  return void p(' fix: install gh, `gh auth login`, then re-run this command')
@@ -9,6 +9,12 @@ import { join } from 'path'
9
9
 
10
10
  export type LeakHit = { where: string; pattern: string }
11
11
 
12
+ /** what a hit REPORTS. The matched string is private by construction, and a
13
+ * gate that prints it verbatim puts it in a terminal, a CI log and any paste
14
+ * of either. Enough to recognize, not enough to be quotable. */
15
+ export const maskPattern = (p: string): string =>
16
+ p.startsWith('re:') || p.length <= 6 ? p : `${p.slice(0, 3)}…${p.slice(-2)} (${p.length} chars)`
17
+
12
18
  export type LeakGuardResult =
13
19
  | { status: 'skipped'; reason: string }
14
20
  | { status: 'clean'; patterns: number }
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
7
7
  import { dirname, join } from 'path'
8
8
  import { fileURLToPath } from 'url'
9
9
  import { commitMessageOffenses } from './commit-message.ts'
10
- import { globalKeywordFile, runLeakGuard } from './leak-guard.ts'
10
+ import { globalKeywordFile, maskPattern, runLeakGuard } from './leak-guard.ts'
11
11
  import { runRootIdentity } from './root-identity.ts'
12
12
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
13
13
  import type { RepoConfig } from '../repo-config.ts'
@@ -143,7 +143,7 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
143
143
  ok: false,
144
144
  lines: [
145
145
  `FAIL: leak-guard matched ${r.hits.length} banned pattern(s) — this commit would leak private content`,
146
- ...r.hits.map((x) => ` ${x.where}: ${x.pattern}`),
146
+ ...r.hits.map((x) => ` ${x.where}: ${maskPattern(x.pattern)}`),
147
147
  ` fix: remove the content, or edit the keyword file if the pattern is wrong`,
148
148
  ],
149
149
  })
@@ -223,7 +223,7 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
223
223
  if (!(h.disable ?? []).includes('leak-guard')) {
224
224
  const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, text: [{ label: 'commit message', content: message }] })
225
225
  if (r.status === 'hit')
226
- out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map((x) => ` ${x.where}: ${x.pattern}`)] })
226
+ out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map((x) => ` ${x.where}: ${maskPattern(x.pattern)}`)] })
227
227
  }
228
228
  for (const c of h.commitMsg ?? []) {
229
229
  const r = await spawnWithTimeout([...c.cmd, messageFile], { cwd: root, timeoutMs: 60_000 })
@@ -90,7 +90,7 @@ type RawEntry = {
90
90
  message?: { role?: unknown; content?: unknown }
91
91
  }
92
92
 
93
- /** His words out of one transcript line, or null when the line is not a prompt
93
+ /** the typed prompt out of one transcript line, or null when the line is not one
94
94
  * he typed. isSidechain = a subagent prompt the AGENT wrote; isMeta and
95
95
  * isCompactSummary = harness-generated. Content is a string for a plain prompt,
96
96
  * an array of blocks when the message carried pastes or attachments (the text
@@ -430,6 +430,35 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
430
430
  print(`ok: ${pkgRel} ${s.version}, ${s.changelogRel} section promoted`)
431
431
  }
432
432
 
433
+ // every OTHER unit that depends on a bumped one moves its range forward, in
434
+ // the SAME commit. A sibling left naming the old version is a package that
435
+ // installs the wrong code, or nothing at all, and rule SK011 fails on it.
436
+ const bumpedNames = new Map(selected.map((s) => [s.unit.name, s.version]))
437
+ for (const u of all) {
438
+ // every unit, INCLUDING the bumped ones: with --all they are all
439
+ // selected, and skipping them left each sibling range naming the
440
+ // version its dependency just left. Only the self-reference is skipped
441
+ if (u.name != null && bumpedNames.has(u.name) && bumpedNames.size === 1) continue
442
+ const rel = u.rel === '' ? 'package.json' : `${u.rel}/package.json`
443
+ const before = readFileSync(`${root}/${rel}`, 'utf8')
444
+ if (!/"rvlib-[^"]*"\s*:\s*"[\^~]?\d/.test(before) && !selected.some((x) => x.unit.id === u.id)) continue
445
+ let after = before
446
+ const moved: string[] = []
447
+ for (const [name, version] of bumpedNames) {
448
+ if (name === u.name) continue
449
+ // the RANGE VALUE only, and only a caret/tilde/exact one: a local
450
+ // protocol is a working-copy choice (SK006) and stays untouched
451
+ const re = new RegExp(`("${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\s*:\\s*")([\\^~]?)\\d+\\.\\d+\\.\\d+(")`, 'g')
452
+ const next = after.replace(re, (_m, head: string, op: string, tail: string) => `${head}${op === '' ? '' : op}${version}${tail}`)
453
+ if (next !== after) { moved.push(`${name} → ${version}`); after = next }
454
+ }
455
+ if (after !== before) {
456
+ writeFileSync(`${root}/${rel}`, after)
457
+ files.push(rel)
458
+ print(`ok: ${rel} follows the bump (${moved.join(', ')})`)
459
+ }
460
+ }
461
+
433
462
  const add = git(['add', ...files])
434
463
  if (add.code !== 0) return failKeep('git add failed', 'the version files are written but unstaged. git status, then commit them')
435
464
  const label = selected.map((s) => (s.unit.id === 'root' ? s.version : `${s.unit.id}@${s.version}`)).join(', ')
@@ -50,6 +50,14 @@ export type ReleaseConfig = {
50
50
  * branch first, with a merge commit. Default true. A branch is a GATE, so
51
51
  * this is deliberately reachable ONLY by running bump by hand. */
52
52
  mergeFromBranch?: boolean
53
+ /** what makes the release LIVE, run after the tag, the push and the
54
+ * GitHub release, for the ROOT unit only (`['bun', 'run', 'up']` for a
55
+ * repo that deploys). A released web app that never deployed is not
56
+ * shipped, and prepublish scripts cannot do this: they run BEFORE the
57
+ * version commit, so they are build steps, not deploys. It is the last
58
+ * thing that happens, so a failure here loses nothing that was already
59
+ * published: it prints how to re-run the deploy alone. */
60
+ after?: string[]
53
61
  }
54
62
 
55
63
  /** the parts of the release preflight that do not depend on the new version
@@ -198,6 +206,7 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
198
206
  ...(tagExists() ? [] : [`git tag ${tag}`]),
199
207
  `git push + push tag ${tag}`,
200
208
  ...(wantGh ? [`gh release create ${tag}`] : []),
209
+ ...(unit.rel === '' && (rc.after?.length ?? 0) > 0 ? [`post-release: ${rc.after!.join(' ')}`] : []),
201
210
  ]
202
211
 
203
212
  print(`note: ${unit.id} · ${name}@${version} → tag ${tag} · ${wantNpm ? 'npm' : unit.isPrivate ? 'PRIVATE (no npm)' : 'no npm target'} · ${notes === '' ? 'no notes' : `${notes.split('\n').length} line(s) of notes`}`)
@@ -273,6 +282,19 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
273
282
  print(`ok: GitHub release ${tag} live`)
274
283
  }
275
284
  print(`ok: ${name}@${version} released${wantNpm ? ' (npm + tag' : ' (tag'}${wantGh ? ' + github release)' : ')'}`)
285
+ // LAST, and root-unit only: everything above is already published, so
286
+ // a failed deploy is reported without unwinding any of it
287
+ const after = unit.rel === '' ? config.release?.after : undefined
288
+ if (after != null && after.length > 0) {
289
+ print(`ok: running the post-release step — ${after.join(' ')}`)
290
+ const a = Bun.spawnSync(after, { cwd: root, stdout: 'inherit', stderr: 'inherit' })
291
+ if (a.exitCode !== 0) {
292
+ print(`FAIL: post-release step failed (exit ${a.exitCode}) — ${name}@${version} IS released, it is not live`)
293
+ print(` fix: ${after.join(' ')}`)
294
+ return 1
295
+ }
296
+ print(`ok: post-release step done — ${name}@${version} is live`)
297
+ }
276
298
  return 0
277
299
  } finally {
278
300
  rmSync(work, { recursive: true, force: true })
@@ -61,9 +61,13 @@ export function notesAnchor(root: string, changelogRel: string, version: string)
61
61
  * counts: a folder rename must not read as a content change. A tag can
62
62
  * predate the move, so a miss falls back to finding the directory by name
63
63
  * inside that ref's tree, or drift goes silently unassessed. */
64
+ /** the TREE a ref names for this unit, which is what "same contents" means.
65
+ * `^{tree}` is load-bearing on the root unit: a bare rev-parse yields the
66
+ * COMMIT for HEAD and the TAG OBJECT for an annotated tag, two oids that
67
+ * never match, so a tag sitting exactly on HEAD reported as edited. */
64
68
  function treeAt(root: string, u: ReleaseUnit, ref: string): string | null {
65
69
  const rev = (path: string): string | null => {
66
- const r = git(root, ['rev-parse', path === '' ? ref : `${ref}:${path}`])
70
+ const r = git(root, ['rev-parse', path === '' ? `${ref}^{tree}` : `${ref}:${path}`])
67
71
  return r.code === 0 ? r.out : null
68
72
  }
69
73
  const direct = rev(u.rel)
@@ -79,7 +79,15 @@ const SUB_TAG = '{name}@{version}'
79
79
  const render = (template: string, p: { name: string; version: string }): string =>
80
80
  template.replaceAll('{name}', p.name).replaceAll('{version}', p.version)
81
81
 
82
- type Pkg = { name?: string; version?: string; private?: boolean; scripts?: Record<string, string> }
82
+ type Pkg = {
83
+ name?: string
84
+ version?: string
85
+ private?: boolean
86
+ scripts?: Record<string, string>
87
+ dependencies?: Record<string, string>
88
+ peerDependencies?: Record<string, string>
89
+ optionalDependencies?: Record<string, string>
90
+ }
83
91
 
84
92
  function readPkg(dir: string): { pkg: Pkg } | { error: string } {
85
93
  const p = join(dir, 'package.json')
@@ -216,8 +224,46 @@ function build(p: {
216
224
  /** publish order: sub-units first, the root last. The root depends on what
217
225
  * ships inside it, so a consumer that sees the root tag can already resolve
218
226
  * every sub-unit it names. */
227
+ /** publish order: a unit ships AFTER every sibling it depends on, because a
228
+ * dependent published first names a version the registry does not have yet.
229
+ * Declaration order is not that order, and when it happens to be, it is luck.
230
+ * Kahn's algorithm, stable: ties keep declaration order, the root is always
231
+ * last, and a CYCLE degrades to declaration order rather than dropping units
232
+ * (a release that silently skips a package is worse than one in a bad order). */
219
233
  export function releaseOrder(units: ReleaseUnit[]): ReleaseUnit[] {
220
- return [...units.filter((u) => u.id !== 'root'), ...units.filter((u) => u.id === 'root')]
234
+ const subs = units.filter((u) => u.id !== 'root')
235
+ const root = units.filter((u) => u.id === 'root')
236
+ const byName = new Map(subs.filter((u) => u.name != null).map((u) => [u.name, u]))
237
+ const deps = new Map<string, Set<string>>()
238
+ for (const u of subs) {
239
+ const need = new Set<string>()
240
+ const read = readPkg(u.dir)
241
+ const manifest: Pkg = 'error' in read ? {} : read.pkg
242
+ for (const block of ['dependencies', 'peerDependencies', 'optionalDependencies'] as const)
243
+ for (const dep of Object.keys(manifest[block] ?? {})) {
244
+ const sib = byName.get(dep)
245
+ if (sib != null && sib.id !== u.id) need.add(sib.id)
246
+ }
247
+ deps.set(u.id, need)
248
+ }
249
+ const out: ReleaseUnit[] = []
250
+ const placed = new Set<string>()
251
+ let progress = true
252
+ while (progress && out.length < subs.length) {
253
+ progress = false
254
+ for (const u of subs) {
255
+ if (placed.has(u.id)) continue
256
+ const need = deps.get(u.id) ?? new Set()
257
+ if ([...need].every((d) => placed.has(d))) {
258
+ out.push(u)
259
+ placed.add(u.id)
260
+ progress = true
261
+ }
262
+ }
263
+ }
264
+ // a dependency cycle between units: keep every unit, in declaration order
265
+ for (const u of subs) if (!placed.has(u.id)) out.push(u)
266
+ return [...out, ...root]
221
267
  }
222
268
 
223
269
  export const unitById = (units: ReleaseUnit[], id: string): ReleaseUnit | undefined => units.find((u) => u.id === id)
@@ -173,7 +173,7 @@ export function contractsTask(c: RepoConfig, root: string): TaskDef {
173
173
 
174
174
  const FLOOR_IDS = ['lint', 'typecheck', 'test', 'imports:check', 'cycles'] as const
175
175
 
176
- export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (rvlib-ts-tools)' | 'built-in (tsc)'
176
+ export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)'
177
177
  export type FloorResolution = {
178
178
  tasks: TaskDef[]
179
179
  /** slots disabled by `defaults: { x: false }` — always printed */
@@ -190,11 +190,12 @@ export type FloorResolution = {
190
190
  function builtinFloorTask(id: string, root: string): { task: TaskDef; source: FloorSource } | string {
191
191
  if (id === 'imports:check' || id === 'cycles') {
192
192
  let cli: string
193
- try { cli = fileURLToPath(import.meta.resolve('rvlib-ts-tools/src/cli.ts')) }
194
- catch { return `no '${id}' script and rvlib-ts-tools is unresolvable — reinstall shipkit's deps` }
193
+ // shipkit's OWN module, resolved from this file: no dependency to be
194
+ // out of date with, and no resolution that can fail in a consumer tree
195
+ cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
195
196
  return {
196
197
  task: { id, cmd: id === 'cycles' ? ['bun', cli, 'cycles'] : ['bun', cli, 'imports', '--check'] },
197
- source: 'built-in (rvlib-ts-tools)',
198
+ source: 'built-in (shipkit)',
198
199
  }
199
200
  }
200
201
  if (id === 'typecheck') {
@@ -22,7 +22,10 @@
22
22
  // registry unreachable → loud skip, never red (offline is not a defect)
23
23
  // Committing the bump stays with the working agent — shipkit never commits.
24
24
  // Clean passes cache 24h (rule ttl), so the registry is fetched at most
25
- // daily per repo; skip/WARN/FAIL runs are never cached. peerDependencies
25
+ // daily per repo; skip/WARN/FAIL runs are never cached. The key carries the
26
+ // INSTALLED versions, so a manual `bun add -d rvlib-x@N` busts it: keyed on
27
+ // the repo alone, the replayed ok: line named a version the tree no longer
28
+ // carried and called it latest. peerDependencies
26
29
  // are NOT scanned: keeping a published lib's peer ranges consistent after
27
30
  // a bump stays the agent's job.
28
31
  import { existsSync, readFileSync } from 'fs'
@@ -52,6 +55,15 @@ export const rvlibVersions: Rule = {
52
55
  title: 'rvlib versions',
53
56
  owner: 'build/shipkit.md',
54
57
  ttl: '24h',
58
+ cacheKey: (env) => {
59
+ let pkg: Pkg
60
+ try { pkg = JSON.parse(readFileSync(join(env.root, 'package.json'), 'utf8')) as Pkg } catch { return 'unreadable' }
61
+ return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })
62
+ .filter((n) => n.startsWith('rvlib-'))
63
+ .sort()
64
+ .map((n) => `${n}@${installedVersion(env.root, n) ?? 'absent'}`)
65
+ .join(',')
66
+ },
55
67
  lint: async (t, env: RuleEnv) => {
56
68
  let pkg: Pkg
57
69
  try { pkg = JSON.parse(readFileSync(join(env.root, 'package.json'), 'utf8')) as Pkg } catch { return t.note('no readable package.json — rvlib versions not applicable') }
@@ -14,9 +14,23 @@
14
14
  // and sanctioned sub-lib publishing must not turn this rule red.
15
15
  import { existsSync, readFileSync } from 'fs'
16
16
  import { join } from 'path'
17
+ import { resolveUnits } from '../release/units.ts'
17
18
  import type { Rule } from './rule.ts'
18
19
 
19
- type Pkg = { name?: string; private?: boolean; publishConfig?: unknown; scripts?: Record<string, string> }
20
+ type Pkg = {
21
+ name?: string
22
+ private?: boolean
23
+ publishConfig?: unknown
24
+ scripts?: Record<string, string>
25
+ dependencies?: Record<string, string>
26
+ peerDependencies?: Record<string, string>
27
+ optionalDependencies?: Record<string, string>
28
+ }
29
+
30
+ /** protocols that resolve to a path on THIS machine. In a block a consumer
31
+ * installs, they are unresolvable: the published package is broken on
32
+ * arrival, and it fails at install time for a stranger, never here. */
33
+ const LOCAL_PROTOCOL = /^(link|file|portal|workspace):/
20
34
 
21
35
  export const npmPublishSafety: Rule = {
22
36
  id: 'SK006',
@@ -28,7 +42,27 @@ export const npmPublishSafety: Rule = {
28
42
  if (!existsSync(p)) return t.ok('no package.json — nothing publishable')
29
43
  let pkg: Pkg
30
44
  try { pkg = JSON.parse(readFileSync(p, 'utf8')) as Pkg } catch { return t.note('package.json unreadable — SK001 reports it') }
31
- if (pkg.name?.startsWith('rvlib-') === true) return t.ok('rvlib-* package — publishes by design (distribute/npm owns it)')
45
+ // every block a CONSUMER resolves, for every unit that can publish:
46
+ // the ROOT is usually private, so scanning it alone guards nothing
47
+ const localHits: string[] = []
48
+ for (const u of [{ dir: env.root, id: 'root' }, ...resolveUnits(env.root, env.config).units.filter((x) => x.rel !== '')]) {
49
+ const up = join(u.dir, 'package.json')
50
+ if (!existsSync(up)) continue
51
+ let upkg: Pkg
52
+ try { upkg = JSON.parse(readFileSync(up, 'utf8')) as Pkg } catch { continue }
53
+ if (upkg.private === true) continue
54
+ for (const block of ['dependencies', 'peerDependencies', 'optionalDependencies'] as const)
55
+ for (const [dep, range] of Object.entries(upkg[block] ?? {}))
56
+ if (LOCAL_PROTOCOL.test(range)) localHits.push(`${u.id}: ${block}.${dep} = ${range}`)
57
+ }
58
+ if (localHits.length > 0)
59
+ t.fail(
60
+ `${localHits.join(', ')} — a local protocol cannot resolve on a consumer's machine, so publishing that ships a package which fails at install. Declare the version it WILL be, and keep the local wiring in overrides (npm ignores those from a dependency) or in devDependencies`,
61
+ )
62
+ if (pkg.name?.startsWith('rvlib-') === true) {
63
+ if (localHits.length === 0) t.ok('rvlib-* package — publishes by design (distribute/npm owns it)')
64
+ return
65
+ }
32
66
  if (pkg.private !== true) return t.note('private:true missing — SK001 reports it; publish machinery not judged until the lock is on')
33
67
  const machinery: string[] = []
34
68
  if (pkg.publishConfig != null) machinery.push('publishConfig')
@@ -38,6 +72,6 @@ export const npmPublishSafety: Rule = {
38
72
  machinery.push(`scripts.publish (runs npm/bun publish)`)
39
73
  if (machinery.length > 0)
40
74
  t.fail(`private package carries publish machinery: ${machinery.join(', ')} — dead weight now, a loaded gun the day private flips; delete it or make the repo a published lib on purpose`)
41
- else t.ok(`private package — no publishConfig / prepublish* / npm-publishing 'publish' script on the root`)
75
+ else if (localHits.length === 0) t.ok(`private package — no publishConfig / prepublish* / npm-publishing 'publish' script on the root, no local dependency protocol in any unit`)
42
76
  },
43
77
  }