rvlib-shipkit 0.32.0 → 0.34.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +4 -1
  3. package/src/asks/asks-queue.ts +15 -2
  4. package/src/cli/cmd_Asks.ts +2 -1
  5. package/src/cli/cmd_Keys.ts +107 -0
  6. package/src/cli.ts +2 -0
  7. package/src/core/canonical-scripts.ts +3 -1
  8. package/src/core/check-suite.ts +95 -33
  9. package/src/core/discover-checks.ts +30 -1
  10. package/src/core/floor.ts +21 -2
  11. package/src/core/locked-write.ts +170 -1
  12. package/src/core/one-way-in-policy.ts +183 -0
  13. package/src/core/run-ci.ts +42 -3
  14. package/src/core/run-fixers.ts +61 -18
  15. package/src/core/task-runner.ts +55 -6
  16. package/src/hooks/one-way-in-guard.ts +39 -0
  17. package/src/hooks/run-hooks.ts +23 -2
  18. package/src/init-repo.ts +19 -1
  19. package/src/ledger/gate-units.ts +58 -0
  20. package/src/ledger/git-key.ts +330 -0
  21. package/src/ledger/git-scan.ts +314 -0
  22. package/src/ledger/toolchain-identity.ts +15 -8
  23. package/src/release/run-bump.ts +50 -5
  24. package/src/release/unit-gate.ts +17 -4
  25. package/src/rules/SK001-package-json-shape.ts +10 -3
  26. package/src/rules/SK002-contracts-fulfillness.ts +4 -0
  27. package/src/rules/SK003-claude-md-shape.ts +7 -2
  28. package/src/rules/SK004-gitignore-policy.ts +11 -2
  29. package/src/rules/SK005-rvlib-versions.ts +28 -1
  30. package/src/rules/SK006-npm-publish-safety.ts +3 -0
  31. package/src/rules/SK008-reviewer-agent.ts +6 -2
  32. package/src/rules/SK009-commit-ai-tells.ts +3 -0
  33. package/src/rules/SK010-undeclared-release-unit.ts +2 -0
  34. package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
  35. package/src/rules/SK012-config-barrel-import.ts +6 -2
  36. package/src/rules/SK013-one-way-in-policy.ts +156 -0
  37. package/src/rules/rule-registry.ts +2 -0
  38. package/src/rules/rule.ts +22 -1
  39. package/src/rules/run-fix.ts +5 -3
  40. package/src/scaffold/files/claude-settings-local.json +0 -3
  41. package/src/scaffold/files/verify-skill.md +1 -1
  42. package/src/ts/config.ts +66 -6
  43. package/src/ts/cycles.ts +7 -92
  44. package/src/ts/source-graph.ts +158 -0
  45. package/src/ts/tsconfig-projects.ts +101 -0
  46. package/src/ts/typecheck-cli.ts +46 -0
@@ -0,0 +1,314 @@
1
+ // WHAT IS ON DISK RIGHT NOW, as git object ids, using git's own index.
2
+ //
3
+ // A cache key needs the identity of the files a unit reads, INCLUDING files
4
+ // that are untracked or modified. `git hash-object` per file is O(files) and
5
+ // re-reads everything; git's index already carries a stat cache that answers
6
+ // "unchanged" without reading the blob. So: a scratch index of our own, per
7
+ // path adds, one `write-tree`, one `cat-file --batch-check`.
8
+ //
9
+ // FOUR THINGS THIS FILE EXISTS TO GET RIGHT, each reproduced before it was
10
+ // written:
11
+ //
12
+ // 1. PER PATH ADDS, EXIT CODE CHECKED. `git add -A -- a.txt missing/` exits
13
+ // 128, stages NOTHING, and `write-tree` then hands back the tree of the
14
+ // LAST successful write. Measured on a scratch repo: the batched form
15
+ // returned HEAD's tree while a.txt was modified on disk, so every key
16
+ // derived from it described a file nobody had. One add per path turns that
17
+ // into one loud failure on one path.
18
+ // 2. A MISSING DECLARED PATH IS NOT A GIT ERROR. A repo without `tests/` is
19
+ // normal, and hard-failing the gate on it would be wrong, so a path that
20
+ // does not exist never reaches `git add`: it comes back in `missing` and
21
+ // the caller refuses to cache that unit. That keeps rule 1's hard failure
22
+ // for what it is actually about, git going wrong on a path that IS there.
23
+ // 3. ONLY AN IGNORED PATH IS FORCE ADDED. `git add -Af` on a broad path force
24
+ // adds node_modules and costs 17.7s; the ignored paths that genuinely hold
25
+ // declared inputs here (.rv-social/patreon/, .rv-assets/out/) cost 76ms.
26
+ // One `ls-files --others -i --directory` names them, and node_modules is
27
+ // refused force-add at any depth.
28
+ // 4. THE SCRATCH INDEX IS SHARED, SO IT IS CONTENDED. Measured: 10 parallel
29
+ // `git add` on one index file, 9 fail with `Unable to create …lock`. Three
30
+ // agents in one checkout is the normal case here, so lock contention is
31
+ // retried and only a NON lock failure is fatal.
32
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
33
+ import { isAbsolute, join, normalize } from 'path'
34
+
35
+ /** what a scan measured. Every OID is a git object id of the CURRENT disk
36
+ * content, tracked or not. */
37
+ export type ScanResult = {
38
+ /** root tree of the scratch index after the adds */
39
+ treeOid: string
40
+ /** repo-relative path → git object id (tree for a dir, blob for a file) */
41
+ oidByPath: Map<string, string>
42
+ /** declared paths that are not on disk — the caller must not cache them */
43
+ missing: string[]
44
+ /** repo-relative path → ignored content under it that is NOT in the key
45
+ * (node_modules only, by policy: toolchain-identity.ts owns installs) */
46
+ excluded: Map<string, string[]>
47
+ /** wall time of the whole scan */
48
+ ms: number
49
+ }
50
+
51
+ export class GitScanError extends Error {}
52
+
53
+ /** the scratch index FORMAT. Bump it whenever what the scan stages changes.
54
+ * The index is persistent, and anything staged into it stays there until
55
+ * something stages over it: a run that force-added a path a later version no
56
+ * longer touches would keep serving that path's content forever, frozen at
57
+ * the moment of the bad run. A version stamp beside the file turns that into
58
+ * one discarded index instead of a permanently wrong key. */
59
+ export const SCAN_INDEX_VERSION = 2
60
+
61
+ const LOCK_RE = /Unable to create '.*\.lock'|index\.lock/i
62
+ const RETRIES = 12
63
+
64
+ const git = (root: string, args: string[], indexFile: string): { code: number; out: string } => {
65
+ const p = Bun.spawnSync(['git', ...args], {
66
+ cwd: root,
67
+ stdout: 'pipe',
68
+ stderr: 'pipe',
69
+ env: { ...process.env, GIT_INDEX_FILE: indexFile },
70
+ })
71
+ return { code: p.exitCode, out: `${p.stdout.toString()}${p.stderr.toString()}` }
72
+ }
73
+
74
+ /** one git call that MUST succeed. Lock contention is retried (three agents in
75
+ * one checkout is normal); anything else throws with git's own words. */
76
+ function gitOrThrow(root: string, args: string[], indexFile: string): string {
77
+ let last = { code: -1, out: '' }
78
+ for (let attempt = 0; attempt < RETRIES; attempt++) {
79
+ last = git(root, args, indexFile)
80
+ if (last.code === 0) return last.out
81
+ if (!LOCK_RE.test(last.out)) break
82
+ Bun.sleepSync(10 + attempt * 10)
83
+ }
84
+ throw new GitScanError(`git ${args.join(' ')} exited ${last.code}: ${last.out.trim().split('\n').slice(0, 3).join(' / ')}`)
85
+ }
86
+
87
+ /** the THREE index files a scan uses, and why it is three and not one.
88
+ *
89
+ * SCRATCH is where the plain adds go, and it is COPIED FRESH from the repo's
90
+ * real index on every scan. Keeping it across runs was the obvious design and
91
+ * it was wrong twice, both measured. Anything staged into an index stays
92
+ * staged, so a path force-added by one run rode in every later run's tree,
93
+ * which made the whole-tree id a function of this machine's scan history and
94
+ * reproducible on no other checkout. And a file that stopped being tracked
95
+ * (`git rm --cached`) stayed in the scratch index, so the tree still carried
96
+ * it and a rule keyed on tracked-ness saw nothing change. The copy costs
97
+ * about 1ms and the real index is already warm, so the whole reset plus
98
+ * `git add -- .` plus `write-tree` measures 30 to 40ms, FASTER than the
99
+ * persistent index it replaces.
100
+ *
101
+ * IGNORED is a separate index for the force-adds, so a path one unit named
102
+ * can never leak into the whole-tree id another unit is keyed on. Without the
103
+ * split, one unit's key changed depending on which OTHER units were in the
104
+ * run, which is exactly the silently narrowed key the excluded list exists to
105
+ * prevent. It stays warm across runs: an entry left in it can only affect the
106
+ * path it belongs to, never the root tree.
107
+ *
108
+ * REAL is read, never written, and only to answer "does git ignore this".
109
+ * `ls-files --others` means UNTRACKED, so asking an index we force-add into
110
+ * reports a force-added path as no longer ignored, and the next run then
111
+ * plain-adds it, which exits 1 and takes the scan down. */
112
+ export function indexPaths(root: string): { scratch: string; ignored: string; real: string } {
113
+ const p = Bun.spawnSync(['git', 'rev-parse', '--absolute-git-dir'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
114
+ if (p.exitCode !== 0) throw new GitScanError(`not a git repository: ${root} (${p.stderr.toString().trim()})`)
115
+ const gitDir = p.stdout.toString().trim()
116
+ const dir = join(gitDir, 'shipkit')
117
+ mkdirSync(dir, { recursive: true })
118
+ const real = join(gitDir, 'index')
119
+ const ignored = join(dir, 'scan-ignored.idx')
120
+ const stamp = join(dir, 'scan.idx.version')
121
+ const seen = ((): string => { try { return readFileSync(stamp, 'utf8').trim() } catch { return '' } })()
122
+ if (seen !== String(SCAN_INDEX_VERSION)) {
123
+ try { rmSync(ignored, { force: true }) } catch {}
124
+ try { writeFileSync(stamp, `${SCAN_INDEX_VERSION}\n`) } catch {}
125
+ }
126
+ // per-process scratch: it is rewritten from `real` every scan anyway, so
127
+ // sharing one file across concurrent runners buys no warmth and only adds
128
+ // lock contention
129
+ const scratch = join(dir, `scan-${process.pid}.idx`)
130
+ // a run that was killed leaves its file behind, and pids are reused, so
131
+ // sweep anything an hour old rather than trusting the pid to be free
132
+ try {
133
+ const cutoff = Date.now() - 3_600_000
134
+ for (const f of readdirSync(dir))
135
+ if (/^scan-\d+\.idx(\.tmp)?$/.test(f) && statSync(join(dir, f)).mtimeMs < cutoff) rmSync(join(dir, f), { force: true })
136
+ } catch {}
137
+ // tmp + rename: a half-copied index is a corrupt index, and git's error
138
+ // for that is not the one the retry loop is meant to absorb
139
+ try {
140
+ rmSync(scratch, { force: true })
141
+ if (existsSync(real)) {
142
+ const tmp = `${scratch}.tmp`
143
+ copyFileSync(real, tmp)
144
+ renameSync(tmp, scratch)
145
+ }
146
+ } catch {
147
+ // no seed is survivable: the adds are just colder
148
+ }
149
+ return { scratch, ignored, real }
150
+ }
151
+
152
+ /** the whole work tree as one input. A rule that walks the repo (SK001's test
153
+ * file probe, SK010's `git ls-files`) genuinely reads all of it, and saying so
154
+ * is more honest than naming three of the files it happens to open. Its object
155
+ * id is the scan's root tree, so it moves on any edit anywhere. */
156
+ export const WHOLE_TREE = '.'
157
+
158
+ /** repo-relative posix, no './', no trailing slash. Throws on anything that
159
+ * escapes the repo — a key component outside the tree is a key nobody else
160
+ * can reproduce. */
161
+ export function normalizeInput(p: string): string {
162
+ const n = normalize(p).replace(/\\/g, '/').replace(/\/+$/, '').replace(/^\.\//, '')
163
+ if (n === '.' || n === '') return WHOLE_TREE
164
+ if (isAbsolute(p) || n === '..' || n.startsWith('../'))
165
+ throw new GitScanError(`input path '${p}' is outside the repo — declare a path inside it, or '.' for the whole work tree`)
166
+ return n
167
+ }
168
+
169
+ /** drop paths already covered by an ancestor in the same set: adding `wiki`
170
+ * stages `wiki/build/...` too, and a second add of the child is pure cost. */
171
+ export function dropCovered(paths: string[]): string[] {
172
+ if (paths.includes(WHOLE_TREE)) return [WHOLE_TREE]
173
+ const sorted = [...new Set(paths)].sort()
174
+ const kept: string[] = []
175
+ for (const p of sorted) if (!kept.some((k) => p === k || p.startsWith(`${k}/`))) kept.push(p)
176
+ return kept
177
+ }
178
+
179
+ const underNodeModules = (p: string): boolean => p.split('/').includes('node_modules')
180
+
181
+ /**
182
+ * stage `paths` into the scratch index and read back one git object id each.
183
+ *
184
+ * Throws `GitScanError` on any git failure that is not lock contention. The
185
+ * caller's contract on a throw is absolute: produce NO key. A tree that may be
186
+ * stale describes files nobody has, and a key built on it is a false green
187
+ * waiting for its first hit.
188
+ */
189
+ export function scanPaths(root: string, rawPaths: string[]): ScanResult {
190
+ const started = performance.now()
191
+ const { scratch: indexFile, ignored: ignoredIndex, real: realIndex } = indexPaths(root)
192
+ const wanted = [...new Set(rawPaths.map(normalizeInput))].sort()
193
+ const missing = wanted.filter((p) => !existsSync(join(root, p)))
194
+ const present = wanted.filter((p) => existsSync(join(root, p)))
195
+ const toAdd = dropCovered(present)
196
+ const named = wanted.filter((p) => p !== WHOLE_TREE)
197
+
198
+ // ignored content under what we are about to stage. One call, and it also
199
+ // answers "is this declared path itself ignored": `--directory` collapses
200
+ // a dir whose ENTIRE content is ignored, so the answer comes back as the
201
+ // dir with a trailing slash.
202
+ const ignored = toAdd.length === 0
203
+ ? []
204
+ : gitOrThrow(root, ['ls-files', '--others', '-i', '--exclude-standard', '--directory', '--', ...toAdd], realIndex)
205
+ .split('\n')
206
+ .map((l) => l.trim().replace(/\/+$/, ''))
207
+ .filter((l) => l !== '')
208
+ // ONLY a path a declaration NAMES is force-added. Ignored content that
209
+ // merely sits under a declared path is reported and left out.
210
+ //
211
+ // This is the whole of defense 3, and the wide reading of it is a disaster
212
+ // measured twice: with `.` declared (which every repo-walking rule
213
+ // declares), "ignored content under a declared path" is every ignored path
214
+ // in the repo, so one run force-staged two entire worktrees, the log dir,
215
+ // the private model dumps and the prompt corpora, for 680ms. Naming the
216
+ // path is what makes it an input.
217
+ //
218
+ // Installs are refused on top of that, at any depth AND as an ancestor:
219
+ // `--directory` collapses a dir whose entire content is ignored, so
220
+ // force-adding that one entry stages the node_modules inside it.
221
+ const declared = new Set(wanted)
222
+ const denied = ignored.filter(underNodeModules)
223
+ const forceAdd = ignored.filter((p) => declared.has(p) && !underNodeModules(p) && !denied.some((d) => d.startsWith(`${p}/`)))
224
+ // a path git itself calls ignored must NOT get the plain add: `git add --
225
+ // <ignored>` exits 1 and takes the whole scan down with it
226
+ const ignoredSet = new Set(ignored)
227
+ for (const p of toAdd) if (!ignoredSet.has(p)) gitOrThrow(root, ['add', '--', p], indexFile)
228
+ // force-adds land in their OWN index, so a path one unit named can never
229
+ // ride inside the whole-tree id another unit is keyed on
230
+ for (const p of forceAdd) gitOrThrow(root, ['add', '-Af', '--', p], ignoredIndex)
231
+
232
+ const treeOid = gitOrThrow(root, ['write-tree'], indexFile).trim()
233
+ const oidByPath = new Map<string, string>()
234
+ // the work tree's own id IS the tree the adds just produced — there is no
235
+ // `<tree>:` path to ask git for
236
+ if (wanted.includes(WHOLE_TREE)) oidByPath.set(WHOLE_TREE, treeOid)
237
+ const forceSet = new Set(forceAdd)
238
+ const plain = named.filter((p) => !forceSet.has(p))
239
+ for (const [tree, paths, index] of [[treeOid, plain, indexFile], [null, forceAdd, ignoredIndex]] as const) {
240
+ if (paths.length === 0) continue
241
+ const root_ = tree ?? gitOrThrow(root, ['write-tree'], index).trim()
242
+ const query = paths.map((p) => `${root_}:${p}`).join('\n')
243
+ const p = Bun.spawnSync(['git', 'cat-file', '--batch-check'], {
244
+ cwd: root,
245
+ stdin: Buffer.from(`${query}\n`),
246
+ stdout: 'pipe',
247
+ stderr: 'pipe',
248
+ env: { ...process.env, GIT_INDEX_FILE: index },
249
+ })
250
+ if (p.exitCode !== 0) throw new GitScanError(`git cat-file --batch-check exited ${p.exitCode}: ${p.stderr.toString().trim()}`)
251
+ const lines = p.stdout.toString().split('\n').filter((l) => l !== '')
252
+ if (lines.length !== paths.length)
253
+ throw new GitScanError(`git cat-file --batch-check answered ${lines.length} of ${paths.length} paths`)
254
+ paths.forEach((path, i) => {
255
+ const oid = lines[i]!.split(' ')[0] ?? ''
256
+ // 'missing' / 'ambiguous' come back in place of an id — a path git
257
+ // cannot name is a path we cannot key on
258
+ if (/^[0-9a-f]{40}$/.test(oid)) oidByPath.set(path, oid)
259
+ else if (!missing.includes(path)) missing.push(path)
260
+ })
261
+ }
262
+
263
+ const excluded = new Map<string, string[]>()
264
+ for (const d of ignored) {
265
+ // EVERY owner that covers it, not the first one found, and membership
266
+ // must not depend on what OTHER units declared. `.` sorts first and
267
+ // covers everything, so a `find` here handed the whole exclusion list
268
+ // to the whole-tree unit and left every narrower unit claiming it was
269
+ // blind to nothing. Filtering force-added paths out globally had the
270
+ // same shape: one unit naming `ign` deleted `excluded=ign` from a
271
+ // whole-tree unit that is still blind to it. Measured both ways, one
272
+ // unit producing two keys for one unchanged tree, and the component
273
+ // that vanished is the one that exists to stop a silently narrowed key.
274
+ for (const w of wanted) {
275
+ if (!(w === WHOLE_TREE || d === w || d.startsWith(`${w}/`))) continue
276
+ // the one owner that is NOT blind: it named the path itself, so it
277
+ // got the content through the force-add
278
+ if (d === w && forceSet.has(d)) continue
279
+ excluded.set(w, [...(excluded.get(w) ?? []), d])
280
+ }
281
+ }
282
+ return { treeOid, oidByPath, missing: [...new Set(missing)].sort(), excluded, ms: performance.now() - started }
283
+ }
284
+
285
+ /**
286
+ * blob ids for a list of FILES, one process, no index, no lock.
287
+ *
288
+ * The scratch index is the right tool for a declared input, which is usually a
289
+ * directory and usually unchanged (git's stat cache answers without reading).
290
+ * It is the wrong tool for the module-graph closure: those are individual
291
+ * files, they belong to no declared path, and staging each one would cost a
292
+ * `git add` spawn per file. `hash-object --stdin-paths` reads them all in one
293
+ * call and returns exactly what a blob of that content would be.
294
+ *
295
+ * A file that is not on disk is skipped and reported, never guessed at.
296
+ */
297
+ export function hashFiles(root: string, files: string[]): { oids: Map<string, string>; missing: string[] } {
298
+ const oids = new Map<string, string>()
299
+ const wanted = [...new Set(files.map(normalizeInput))].sort()
300
+ const missing = wanted.filter((f) => !existsSync(join(root, f)))
301
+ const present = wanted.filter((f) => existsSync(join(root, f)))
302
+ if (present.length === 0) return { oids, missing }
303
+ const p = Bun.spawnSync(['git', 'hash-object', '--stdin-paths'], {
304
+ cwd: root,
305
+ stdin: Buffer.from(`${present.join('\n')}\n`),
306
+ stdout: 'pipe',
307
+ stderr: 'pipe',
308
+ })
309
+ if (p.exitCode !== 0) throw new GitScanError(`git hash-object exited ${p.exitCode}: ${p.stderr.toString().trim().split('\n').slice(0, 3).join(' / ')}`)
310
+ const lines = p.stdout.toString().split('\n').filter((l) => l !== '')
311
+ if (lines.length !== present.length) throw new GitScanError(`git hash-object answered ${lines.length} of ${present.length} files`)
312
+ present.forEach((f, i) => oids.set(f, lines[i]!.trim()))
313
+ return { oids, missing }
314
+ }
@@ -56,8 +56,8 @@ function installedNames(nm: string): string[] {
56
56
  return names.sort()
57
57
  }
58
58
 
59
- /** the gate binary's own source, content-hashed. Only reached when the cli
60
- * lives OUTSIDE the repo, where the git tree key cannot see it. */
59
+ /** the gate binary's own source, content-hashed. This is what makes editing
60
+ * shipkit invalidate every key in every repo, in-repo copy or dependency. */
61
61
  function hashCliTree(h: ReturnType<typeof createHash>, cli: string): void {
62
62
  const srcRoot = resolve(cli, '..')
63
63
  const files: string[] = []
@@ -94,12 +94,19 @@ export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain
94
94
  // the runtime. Free, and a bun upgrade genuinely changes behaviour
95
95
  h.update(`bun=${Bun.version}\n`)
96
96
 
97
- // the gate binary. In-repo, the git tree key already covers every byte of
98
- // it, so the literal is enough and the walk is skipped
99
- const cli = realpathSync(cliEntry)
100
- const cliInRepo = inRepo(root, cli)
101
- h.update(`cli=${cliInRepo ? 'in-repo' : cli}\n`)
102
- if (!cliInRepo) hashCliTree(h, cli)
97
+ // the gate binary, ALWAYS content-hashed, and its path never folded in.
98
+ //
99
+ // The earlier shape wrote `cli=in-repo` for a binary inside the repo and
100
+ // leaned on the git tree key to cover it. Both halves were wrong, and both
101
+ // were measured. In a CONSUMER repo the binary sits in `node_modules`,
102
+ // which is path-inside the root and inside no git tree, so the tree key
103
+ // covered none of it. And between a checkout that HOSTS the binary and a
104
+ // worktree that runs the same one through the global shim, one wrote
105
+ // `in-repo` and the other wrote an absolute path, so two checkouts running
106
+ // one identical program could never agree on a key. Content-hashing costs
107
+ // 3.5 to 9.2ms on a 163-file tree, and it is the only form that is the
108
+ // same value wherever the caller stands.
109
+ hashCliTree(h, realpathSync(cliEntry))
103
110
 
104
111
  // what is installed. The lockfile states the intent; the tree states the fact
105
112
  const lock = join(root, 'bun.lock')
@@ -26,7 +26,7 @@
26
26
  // every selected unit's package.json and changelog. The publish part is
27
27
  // RESUMABLE instead, because an npm publish cannot be undone and a rollback
28
28
  // that cannot roll back is a lie.
29
- import { existsSync, readFileSync, writeFileSync } from 'fs'
29
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'fs'
30
30
  import { homedir } from 'os'
31
31
  import { join, relative } from 'path'
32
32
  import { declaredKind, hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
@@ -136,6 +136,26 @@ function mergeIntoReleaseBranch(
136
136
  }
137
137
  }
138
138
 
139
+ /** where a branch is checked out, or null. `git worktree list` is the only
140
+ * source that sees the OTHER checkouts: a linked worktree and its main
141
+ * checkout share one object store and one branch namespace, and git refuses
142
+ * to check a branch out twice. Three agents in one checkout, one of them in a
143
+ * worktree, is the normal case here, so this is asked before any ref moves. */
144
+ function branchCheckedOutAt(root: string, git: Git, branch: string): string | null {
145
+ const r = git(['worktree', 'list', '--porcelain'])
146
+ if (r.code !== 0) return null
147
+ let path: string | null = null
148
+ for (const line of r.out.split('\n')) {
149
+ if (line.startsWith('worktree ')) path = line.slice('worktree '.length).trim()
150
+ else if (line.trim() === `branch refs/heads/${branch}`) {
151
+ if (path == null) continue
152
+ try { if (realpathSync(path) === realpathSync(root)) return null } catch { /* a stale worktree entry: treat it as foreign */ }
153
+ return path
154
+ }
155
+ }
156
+ return null
157
+ }
158
+
139
159
  /** the rehearsal's answer to the same two questions, from the object database:
140
160
  * has the release branch diverged from its remote, and would the merge
141
161
  * conflict. Nothing is checked out and no ref moves, which is the whole point
@@ -189,6 +209,10 @@ function probeMergeIntoReleaseBranch(
189
209
  return { refuse: false, inconclusive: stale }
190
210
  }
191
211
 
212
+ /** a rehearsal moves no ref and checks nothing out, so a branch held by
213
+ * another worktree cannot stop it */
214
+ const rehearsalOnly = (o: BumpOpts): boolean => o.dryRun === true
215
+
192
216
  type Selected = { unit: ReleaseUnit; version: string; changelogRel: string; kind: BumpKind }
193
217
 
194
218
  export async function runBump(root: string, config: RepoConfig, kind: BumpKind | null, o: BumpOpts = {}): Promise<number> {
@@ -242,7 +266,11 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
242
266
  const paths = dirtyPathList(preDirty)
243
267
  if (paths.every((p) => machine.has(p))) {
244
268
  gitAt(root, ['add', '--', ...paths])
245
- const c = gitAt(root, ['commit', '-m', 'machine rows refresh before release'])
269
+ // PATHSPEC on the commit too, not just the add. Three agents in
270
+ // one checkout share one index, so a bare `git commit` here takes
271
+ // whatever a neighbour staged a second ago and ships it under a
272
+ // message about machine rows.
273
+ const c = gitAt(root, ['commit', '-m', 'machine rows refresh before release', '--', ...paths])
246
274
  if (c.code === 0) print('ok: machine-written rows committed (they re-dirty on every gate run)')
247
275
  else print(`WARN machine-row auto-commit failed (exit ${c.code}), the refusal below says what to do:\n${c.out.split('\n').slice(-6).join('\n')}`)
248
276
  } else {
@@ -263,6 +291,20 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
263
291
 
264
292
  const wantMerge = cur !== branch && o.noMerge !== true && (config.release?.mergeFromBranch ?? true)
265
293
  if (cur !== branch && !wantMerge) { print(`FAIL: releases ship from '${branch}', HEAD is on '${cur}'`); return 1 }
294
+ // the merge CHECKS OUT the release branch here, and git refuses to have
295
+ // one branch checked out in two worktrees. Left to git the run dies on a
296
+ // raw `fatal: 'main' is already used by worktree at …`, which reads like a
297
+ // shipkit bug. Worse, in the other direction, a bump run in the checkout
298
+ // that DOES hold the branch moves the HEAD of an agent working in it.
299
+ // Refuse by name, and say which checkout owns the branch.
300
+ if (wantMerge && !rehearsalOnly(o)) {
301
+ const held = branchCheckedOutAt(root, git, branch)
302
+ if (held != null) {
303
+ print(`FAIL: '${branch}' is checked out at ${held}, so this worktree cannot merge into it`)
304
+ print(` fix: bump from ${held}, or \`shipkit bump --no-merge\` after merging '${cur}' into '${branch}' by hand there`)
305
+ return 1
306
+ }
307
+ }
266
308
  const remote = deriveRemote(root)
267
309
  if ('error' in remote) { print(`FAIL: ${remote.error}`); return 1 }
268
310
  if (wantMerge) print(`note: on '${cur}', so '${cur}' merges into '${branch}' first`)
@@ -482,12 +524,15 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
482
524
  }
483
525
  }
484
526
 
485
- const add = git(['add', ...files])
527
+ const add = git(['add', '--', ...files])
486
528
  if (add.code !== 0) return failKeep('git add failed', 'the version files are written but unstaged. git status, then commit them')
487
529
  const label = selected.map((s) => (s.unit.id === 'root' ? s.version : `${s.unit.id}@${s.version}`)).join(', ')
488
530
  // the hooks already ran through the gate above; --no-verify is NOT used,
489
- // so the repo's own commit gates still apply to this commit
490
- const commit = git(['commit', '-m', `release ${label}`])
531
+ // so the repo's own commit gates still apply to this commit.
532
+ // PATHSPEC, because the index is shared with every other agent in this
533
+ // checkout: a bare commit here tags whatever a neighbour happened to have
534
+ // staged, and the tag then describes a tree nobody gated.
535
+ const commit = git(['commit', '-m', `release ${label}`, '--', ...files])
491
536
  if (commit.code !== 0)
492
537
  return failKeep(`commit failed — ${commit.out}`, 'the version files are written but uncommitted. fix the gate, commit, then `shipkit release`')
493
538
  print(`ok: committed release ${label}`)
@@ -6,6 +6,7 @@
6
6
  import { existsSync } from 'fs'
7
7
  import { join } from 'path'
8
8
  import { dirtyPaths } from './git-facts.ts'
9
+ import { resolveTypecheckProjects } from '../ts/tsconfig-projects.ts'
9
10
  import type { ReleaseUnit } from './units.ts'
10
11
  import type { RepoConfig } from '../repo-config.ts'
11
12
 
@@ -56,10 +57,22 @@ export function unitBlockers(root: string, config: RepoConfig, unit: ReleaseUnit
56
57
  const tsc = resolveTsc(root, unit)
57
58
  if (tsc == null) out.push(`unit '${unit.id}': tsc not resolvable — bun add -d typescript at the repo root`)
58
59
  else {
59
- const r = Bun.spawnSync([tsc, '--noEmit', '-p', unit.tsconfig], { cwd: root })
60
- if (r.exitCode !== 0) {
61
- const lines = `${r.stdout.toString()}${r.stderr.toString()}`.trim().split('\n').slice(0, 8).join('\n')
62
- out.push(`unit '${unit.id}': does not typecheck under its own tsconfig — it cannot be published\n${lines}`)
60
+ // a solution-style tsconfig (`files: []` plus `references`) makes
61
+ // tsc compile ZERO files and exit 0, which would publish a package
62
+ // whose only proof of soundness was that nobody looked
63
+ let projects: ReturnType<typeof resolveTypecheckProjects> | null = null
64
+ try { projects = resolveTypecheckProjects(unit.tsconfig) }
65
+ catch (e) { out.push(`unit '${unit.id}': ${String((e as Error)?.message ?? e)}`) }
66
+ for (const m of projects?.missing ?? [])
67
+ out.push(`unit '${unit.id}': its tsconfig references '${m}', which does not exist, so whatever that project covered is unchecked`)
68
+ if (projects != null && projects.vacuous)
69
+ out.push(`unit '${unit.id}': its tsconfig compiles no files of its own and reaches no project that does, so a typecheck over it proves nothing — it cannot be published`)
70
+ for (const p of projects?.projects ?? []) {
71
+ const r = Bun.spawnSync([tsc, '--noEmit', '-p', p], { cwd: root })
72
+ if (r.exitCode !== 0) {
73
+ const lines = `${r.stdout.toString()}${r.stderr.toString()}`.trim().split('\n').slice(0, 8).join('\n')
74
+ out.push(`unit '${unit.id}': does not typecheck under ${p} — it cannot be published\n${lines}`)
75
+ }
63
76
  }
64
77
  }
65
78
  }
@@ -8,12 +8,14 @@
8
8
  // tool body they used to hold sits at `<id>:raw`. That is what makes a cache
9
9
  // worth having: `bun run test` is the reflex, and the reflex has to land in
10
10
  // shipkit rather than beside it.
11
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'
11
+ import { existsSync, readdirSync, readFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import { canonicalBodies, conditionalBodies, inferBin, ROUTED_SCRIPTS, rawName, routedBody, routesToShipkit } from '../core/canonical-scripts.ts'
14
14
  import type { CheckCtx } from '../core/check-ctx.ts'
15
- import { resolveFloor, type RepoConfig } from '../repo-config.ts'
15
+ import { resolveFloor } from '../core/floor.ts'
16
+ import type { RepoConfig } from '../repo-config.ts'
16
17
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
18
+ import { atomicWrite } from '../core/locked-write.ts'
17
19
 
18
20
  type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string> }
19
21
 
@@ -165,6 +167,11 @@ export const packageJsonShape: Rule = {
165
167
  blocking: true,
166
168
  title: 'package.json',
167
169
  owner: OWNER,
170
+ // the whole tree: package.json is most of it, but the baseline-script
171
+ // check also walks the repo for a `*.test.ts` and probes `.rv-comfy`
172
+ // and `deploy.sh`, so naming three files would key it on less than it
173
+ // reads
174
+ inputs: ['.'],
168
175
  lint: (t: CheckCtx, env: RuleEnv) => evaluatePackageJson(t, env.root, env.config),
169
176
  // mechanical repairs only: field flips + scripts whose canonical body the
170
177
  // vocabulary page states. Bodies it does NOT state (assets, comfy:up) are
@@ -229,7 +236,7 @@ export const packageJsonShape: Rule = {
229
236
  }
230
237
  if (changes.length > 0) {
231
238
  const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
232
- writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
239
+ atomicWrite(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
233
240
  }
234
241
  const summary = [
235
242
  changes.length > 0 ? changes.join(', ') : 'nothing mechanical to change',
@@ -14,6 +14,10 @@ export const contractsFulfillness: Rule = {
14
14
  blocking: true,
15
15
  title: 'contracts',
16
16
  owner: 'meta/contracts.md',
17
+ // the contract engine is pure filesystem (no fetch, no spawn anywhere in
18
+ // contracts/), and WHICH folders it reads depends on the venues declared,
19
+ // so the honest input is the work tree
20
+ inputs: ['.'],
17
21
  lint: async (t, env) => {
18
22
  applyContractsResult(t, await evaluateContractsOnce(activeContracts(env.config), contractsConfigFrom(env.config, env.root)), contractsFulfillness)
19
23
  },
@@ -7,10 +7,11 @@
7
7
  // start --include` flags own which base include a machine wires in. A repo
8
8
  // with neither CLAUDE.md nor agent/ is not a two-line-contract repo and
9
9
  // passes.
10
- import { existsSync, readFileSync, writeFileSync } from 'fs'
10
+ import { existsSync, readFileSync } from 'fs'
11
11
  import { join } from 'path'
12
12
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
13
13
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
14
+ import { atomicWrite } from '../core/locked-write.ts'
14
15
 
15
16
  const AGENT_LINE = '@./agent/index.md'
16
17
  const LOCAL_LINE = '@./CLAUDE.local.md'
@@ -20,6 +21,10 @@ export const claudeMdShape: Rule = {
20
21
  blocking: true,
21
22
  title: 'CLAUDE.md',
22
23
  owner: 'meta/agent-folder.md',
24
+ // CLAUDE.md plus whether agent/index.md exists — and a repo that has
25
+ // neither is the case this rule exists for, so the tree rather than two
26
+ // paths that may not be there
27
+ inputs: ['.'],
23
28
  lint: async (t, env) => {
24
29
  const claudePath = join(env.root, 'CLAUDE.md')
25
30
  const hasAgent = existsSync(join(env.root, 'agent/index.md'))
@@ -59,7 +64,7 @@ export const claudeMdShape: Rule = {
59
64
  if (!existsSync(claudePath)) return { changed: false, summary: 'CLAUDE.md missing — creation needs the base include (shipkit start --include), not autofixable' }
60
65
  const cur = readFileSync(claudePath, 'utf8')
61
66
  if (cur.split('\n').includes(AGENT_LINE)) return { changed: false, summary: `${AGENT_LINE} already routed — remaining findings need a human` }
62
- writeFileSync(claudePath, `${cur.endsWith('\n') || cur === '' ? cur : `${cur}\n`}${AGENT_LINE}\n`)
67
+ atomicWrite(claudePath, `${cur.endsWith('\n') || cur === '' ? cur : `${cur}\n`}${AGENT_LINE}\n`)
63
68
  return { changed: true, summary: `${AGENT_LINE} appended as the last line` }
64
69
  },
65
70
  }
@@ -8,11 +8,12 @@
8
8
  // PLACE — lines below it (e.g. a live venue's `!.rv-itch/` override, where
9
9
  // last-match-wins) keep their position; everything outside the block is
10
10
  // never touched. `shipkit init` writes the same block through this fixer.
11
- import { existsSync, readFileSync, writeFileSync } from 'fs'
11
+ import { existsSync, readFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import type { RepoConfig } from '../repo-config.ts'
14
14
  import { GENERATED_IGNORE_LINES, trackedGeneratedPaths, untrackGenerated } from './untrack-generated.ts'
15
15
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
16
+ import { atomicWrite } from '../core/locked-write.ts'
16
17
 
17
18
  const BEGIN = '# >>> shipkit gitignore policy (SK004) — managed block, edit outside only'
18
19
  const END = '# <<< shipkit gitignore policy'
@@ -33,6 +34,11 @@ const POLICY_LINES = [
33
34
  '.rv-discord/',
34
35
  'CLAUDE.local.md',
35
36
  'tmp/',
37
+ // atomicWrite lands every file via `.<name>.tmp-<pid>` next to its target,
38
+ // so a run killed mid write leaves one behind ANYWHERE in the tree, and an
39
+ // agent finds untracked debris it did not create. The ignore row ships in
40
+ // the same policy that started writing them.
41
+ '.*.tmp-*',
36
42
  // comfy-ts state (schema dumps, drafts) and per-workflow lora tags name a
37
43
  // private model collection; shipkit itself writes .comfy-ts/ at hub boot,
38
44
  // so every consumer repo needs both ignored before its first commit
@@ -92,7 +98,7 @@ function writeBlock(root: string, config: RuleEnv['config']): void {
92
98
  out = kept.length > 0 ? [...kept, '', ...block] : block
93
99
  }
94
100
  const eol = f?.crlf === true ? '\r\n' : '\n'
95
- writeFileSync(p, `${out.join(eol)}${out.at(-1) === '' ? '' : eol}`)
101
+ atomicWrite(p, `${out.join(eol)}${out.at(-1) === '' ? '' : eol}`)
96
102
  }
97
103
 
98
104
  export const gitignorePolicy: Rule = {
@@ -100,6 +106,9 @@ export const gitignorePolicy: Rule = {
100
106
  blocking: true,
101
107
  title: '.gitignore',
102
108
  owner: 'meta/contracts.md',
109
+ // .gitignore plus what git currently TRACKS, which the tree carries and a
110
+ // single path does not
111
+ inputs: ['.'],
103
112
  lint: (t, env) => {
104
113
  // a generated path still in the index is the half-applied state the
105
114
  // managed block cannot fix on its own