rvlib-shipkit 0.38.0 → 1.0.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 (252) hide show
  1. package/CHANGELOG.md +371 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +216 -18
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -13,11 +13,15 @@ import { join } from 'path'
13
13
  import { fileURLToPath } from 'url'
14
14
  import { armHooks } from '../hooks/run-hooks.ts'
15
15
  import type { RepoConfig } from '../repo-config.ts'
16
- import { runFix } from '../rules/run-fix.ts'
16
+ import { runFix } from './run-fix.ts'
17
17
  import { routesToShipkit } from './canonical-scripts.ts'
18
+ import { discoverLegacyChecks } from './discover-legacy-checks.ts'
19
+ import { repoCheckFiles } from './discover-checks.ts'
18
20
  import { mutateLease, withMutateLease } from './locked-write.ts'
19
- import { repairContractDirs } from './repair-contract-dirs.ts'
21
+ import { migrateLayout } from './migrate-layout.ts'
22
+ import { renderPolicy } from '../checks/SK004-gitignore-policy.check.ts'
20
23
  import { spawnWithTimeout } from './spawn-timeout.ts'
24
+ import { gitBin } from './git-bin.ts'
21
25
 
22
26
  const FIXER_TIMEOUT_MS = 120_000
23
27
 
@@ -103,7 +107,7 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
103
107
  * actually stops another agent's work being reported here is the lease plus
104
108
  * the byte comparison, not a path list. */
105
109
  function treeState(root: string): Map<string, string> | null {
106
- const p = Bun.spawnSync(['git', 'status', '--porcelain'], { cwd: root })
110
+ const p = Bun.spawnSync([gitBin(), 'status', '--porcelain'], { cwd: root })
107
111
  if (p.exitCode !== 0) return null
108
112
  const m = new Map<string, string>()
109
113
  for (const l of p.stdout.toString().split('\n')) {
@@ -119,6 +123,15 @@ function treeState(root: string): Map<string, string> | null {
119
123
  return m
120
124
  }
121
125
 
126
+ /** the bytes of repo.config.ts and the set of check and check files, as one
127
+ * hash. Read without git, so a repo with no history is covered too */
128
+ function shapeStamp(root: string): string {
129
+ let config = ''
130
+ try { config = readFileSync(join(root, 'repo.config.ts'), 'utf8') } catch {}
131
+ const files = [...repoCheckFiles(root), ...discoverLegacyChecks(root).map((d) => d.path)].sort()
132
+ return String(Bun.hash(`${config}\0${files.join('\0')}`))
133
+ }
134
+
122
135
  export type FixPassOpts = {
123
136
  /** also apply the structural SK rule fixers (default false) */
124
137
  rules?: boolean
@@ -135,6 +148,9 @@ export type FixPassResult = {
135
148
  touched: string[]
136
149
  /** verify mode only: a fixer had to rewrite something, so the commit is not clean */
137
150
  failed: boolean
151
+ /** repo.config.ts or a check file moved under the pass, so a config or an
152
+ * inventory read before it describes a repo that is gone */
153
+ reshaped: boolean
138
154
  }
139
155
 
140
156
  export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpts): Promise<FixPassResult> {
@@ -157,46 +173,52 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
157
173
  if (verify) {
158
174
  o.print(`FAIL: fixers never ran — ${lease.warn}`)
159
175
  o.print(' fix: nothing was verified. re-run once that process finishes, or pass --no-fix to state that on purpose')
160
- return { ran: false, touched: [], failed: true }
176
+ return { ran: false, touched: [], failed: true, reshaped: false }
161
177
  }
162
178
  o.print(`WARN fix pass skipped — ${lease.warn}`)
163
179
  o.print(' fix: nothing was repaired this run; the gate below reports the tree as it stands')
164
- return { ran: false, touched: [], failed: false }
180
+ return { ran: false, touched: [], failed: false, reshaped: false }
165
181
  }
166
182
 
167
183
  async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, verify: boolean): Promise<FixPassResult> {
184
+ // the old root layout moves under .shipkit/ before anything else reads a
185
+ // path: a move is not a decision, so it lands as one machine commit and
186
+ // only what a machine cannot decide reaches a human. Runs before the tree
187
+ // snapshot, and before hooks arm, because the hooks folder may be what moves
188
+ if (!verify) {
189
+ const m = migrateLayout(root, { config, renderGitignore: (t, legacy) => renderPolicy(t, config, legacy) })
190
+ if (m.moved.length > 0 || m.removed.length > 0) {
191
+ const what = m.moved.filter((x) => x.kind !== 'cache').map((x) => `${x.from}/`).join(' ')
192
+ if (m.uncommitted === true) {
193
+ o.print(`WARN moved ${what} under .shipkit/, and the commit did not land`)
194
+ const p = m.pending ?? []
195
+ o.print(` fix: commit exactly these paths, never a bare commit: git commit -m 'chore: move the shipkit folders under .shipkit/' -- ${p.slice(0, 12).join(' ')}${p.length > 12 ? ` (+${p.length - 12} more, listed by git status)` : ''}`)
196
+ } else if (what !== '') o.print(`ok: moved ${what} under .shipkit/${m.commit != null ? `, committed as ${m.commit}` : ''}`)
197
+ }
198
+ if (m.deferred != null) {
199
+ o.print(`WARN the move under .shipkit/ is deferred: ${m.deferred}`)
200
+ o.print(' fix: commit or discard that edit, then re-run shipkit ci')
201
+ }
202
+ for (const r of m.refusals) {
203
+ o.print(`WARN ${r.from}/ cannot move to ${r.to}/: ${r.reason}`)
204
+ o.print(` fix: move the files into ${r.to}/ yourself, then delete ${r.from}/`)
205
+ }
206
+ }
168
207
  // commit gates arm themselves on the interactive gate: a repo.config with
169
208
  // dark hooks is the half-wired state that let a whole drill session
170
- // commit ungated (2026-08-04). Runs BEFORE the tree snapshot: arming is
171
- // its own reported concern, never counted as a fixer rewrite.
209
+ // commit ungated (2026-08-04). Arming is its own reported concern, never
210
+ // counted as a fixer rewrite.
172
211
  if (o.armHooks === true && !verify) {
173
212
  const armed = armHooks(root, config)
174
- if (armed.state === 'armed') o.print(`ok: hooks armed (${armed.detail}), commit the hooks/ dir`)
213
+ if (armed.state === 'armed') o.print(`ok: hooks armed (${armed.detail}), commit the .shipkit/hooks/ dir`)
175
214
  else if (armed.state === 'foreign' || armed.state === 'not-root') o.print(`skip: hooks (${armed.detail})`)
176
215
  else if (armed.state === 'failed') o.print(`WARN hooks could not arm: ${armed.detail}`)
177
216
  }
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
- const repaired = repairContractDirs(root)
184
- for (const r of repaired.refusals) {
185
- o.print(`WARN ${r.from}/ and ${r.to}/ both exist — ${r.reason}`)
186
- o.print(` fix: move the files into ${r.to}/ yourself, then delete ${r.from}/`)
187
- }
188
- // silence is for a repair that COMPLETED. one that renamed and could
189
- // not land the commit leaves dirt nobody can trace to a run they did
190
- // not make, which is the failure the silence was meant to prevent
191
- for (const r of repaired.uncommitted) {
192
- o.print(`WARN renamed ${r.from}/ to ${r.to}/, and the commit did not land`)
193
- o.print(` fix: commit the removal of ${r.from}/ yourself — the files are in ${r.to}/`)
194
- }
195
- }
196
217
  const { slots, unfilled, disabled } = resolveFixers(root, config)
197
218
  for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
198
219
  for (const d of disabled) o.print(`skip: ${d.id} (${d.reason})`)
199
220
  const before = treeState(root)
221
+ const shapeBefore = shapeStamp(root)
200
222
 
201
223
  for (const s of slots) {
202
224
  const r = await spawnWithTimeout(s.cmd, { cwd: root, timeoutMs: FIXER_TIMEOUT_MS })
@@ -209,6 +231,7 @@ async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, ver
209
231
  if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print })
210
232
 
211
233
  const after = treeState(root)
234
+ const reshaped = shapeStamp(root) !== shapeBefore
212
235
  const touched: string[] = []
213
236
  if (before != null && after != null) {
214
237
  for (const [path, hash] of after) if (before.get(path) !== hash) touched.push(path)
@@ -227,17 +250,17 @@ async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, ver
227
250
  const list = `${touched.slice(0, 8).join(' ')}${touched.length > 8 ? ` … +${touched.length - 8}` : ''}`
228
251
  if (before == null) {
229
252
  o.print(`ok: fix pass ran, ${slots.length} fixer(s) (not a git repo — rewritten files cannot be listed)`)
230
- return { ran: true, touched, failed: false }
253
+ return { ran: true, touched, failed: false, reshaped }
231
254
  }
232
255
  if (touched.length === 0) {
233
256
  o.print(`ok: ${verify ? 'fixers verified' : 'fix pass'} clean (${slots.length} fixer(s), nothing to rewrite)`)
234
- return { ran: true, touched, failed: false }
257
+ return { ran: true, touched, failed: false, reshaped }
235
258
  }
236
259
  if (verify) {
237
260
  o.print(`FAIL: ${touched.length} file(s) are not fixer-clean: ${list}`)
238
261
  o.print(` fix: run \`shipkit ci\` locally (the fix pass rewrites them) and commit the result`)
239
- return { ran: true, touched, failed: true }
262
+ return { ran: true, touched, failed: true, reshaped }
240
263
  }
241
264
  o.print(`ok: fix pass rewrote ${touched.length} file(s): ${list}`)
242
- return { ran: true, touched, failed: false }
265
+ return { ran: true, touched, failed: false, reshaped }
243
266
  }
@@ -2,6 +2,9 @@
2
2
  // checks the registry (24h cache on disk), updates rvlib-shipkit when it is
3
3
  // behind, and RE-EXECS the same invocation so the run continues on the new
4
4
  // version. a stale toolkit must never wait for a human to police versions.
5
+ // the update runs through the manager the repo's own lock files name: on an
6
+ // npm consumer, bun would move package.json and leave package-lock.json on
7
+ // the old version, which is a pair `npm ci` refuses at deploy time.
5
8
  //
6
9
  // never fires: running from source (not under node_modules), CI env, frozen,
7
10
  // re-exec guard set, dirty package.json/lockfile (a tool must not mix its
@@ -11,6 +14,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
11
14
  import { homedir } from 'os'
12
15
  import { dirname, join } from 'path'
13
16
  import { fileURLToPath } from 'url'
17
+ import { detectPackageManager } from './package-manager.ts'
18
+ import { gitBin } from './git-bin.ts'
14
19
 
15
20
  const PKG = 'rvlib-shipkit'
16
21
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000
@@ -77,7 +82,7 @@ async function latestCached(): Promise<string | null> {
77
82
  }
78
83
 
79
84
  const manifestDirty = (root: string): boolean => {
80
- const p = Bun.spawnSync(['git', 'status', '--porcelain', '--', 'package.json', 'bun.lock', 'bun.lockb', 'package-lock.json'], { cwd: root })
85
+ const p = Bun.spawnSync([gitBin(), 'status', '--porcelain', '--', 'package.json', 'bun.lock', 'bun.lockb', 'package-lock.json'], { cwd: root })
81
86
  if (p.exitCode !== 0) return true // no git = no safe write
82
87
  return p.stdout.toString().trim() !== ''
83
88
  }
@@ -99,8 +104,9 @@ export async function maybeSelfUpdateAndRestart(root: string): Promise<void> {
99
104
  const plan = planSelfUpdate({ ...base, latest: await latestCached(), manifestDirty: manifestDirty(root) })
100
105
  if (plan.action === 'warn') { console.error(`WARN self-update: ${plan.reason}`); return }
101
106
  if (plan.action !== 'update') return
102
- console.error(`ok: rvlib-shipkit ${plan.installed} → ${plan.latest}, self-updating (bun update --latest ${PKG})`)
103
- const up = Bun.spawnSync(['bun', 'update', '--latest', PKG], { cwd: root, stdout: 'inherit', stderr: 'inherit' })
107
+ const cmd = detectPackageManager(root).update([PKG])
108
+ console.error(`ok: rvlib-shipkit ${plan.installed} → ${plan.latest}, self-updating (${cmd.join(' ')})`)
109
+ const up = Bun.spawnSync(cmd, { cwd: root, stdout: 'inherit', stderr: 'inherit' })
104
110
  if (up.exitCode !== 0) { console.error(`WARN self-update failed (exit ${up.exitCode}), continuing on ${plan.installed}`) ; return }
105
111
  console.error(`ok: restarting on ${plan.latest} — commit package.json + lockfile with your work`)
106
112
  const child = Bun.spawnSync([process.execPath, ...process.argv.slice(1)], {
@@ -0,0 +1,83 @@
1
+ // THE MACHINE POOL: every shipkit process on this machine (every repo, every
2
+ // agent, the hub) measures at most parallelCap() checks at once. One lock file
3
+ // per slot, `slot-<n>.lock` under ~/.cache/shipkit/slots/, taken through the
4
+ // mutate lease's non blocking form (locked-write.ts tryLease): a slot whose
5
+ // holder died is free at once, and a live holder heartbeats it.
6
+ //
7
+ // The one constraint a reader breaks first: a process started BY a check
8
+ // (SHIPKIT_SLOT_HELD=1 in its environment) takes no slot. It already runs on
9
+ // its parent's, and a parent waiting on a child that waits for a slot the
10
+ // parent holds is a deadlock the moment every slot is such a parent.
11
+ import { mkdirSync, readdirSync } from 'fs'
12
+ import { homedir } from 'os'
13
+ import { join } from 'path'
14
+ import { leaseHolder, tryLease } from './locked-write.ts'
15
+ import { PARALLEL_ENV, parallelCap, parallelCapInfo } from './parallel.ts'
16
+
17
+ /** where the slots live, for a test that must not touch the machine's pool */
18
+ export const SLOTS_DIR_ENV = 'SHIPKIT_SLOTS_DIR'
19
+ /** set for every process a check spawns: it runs on its parent's slot */
20
+ export const SLOT_HELD_ENV = 'SHIPKIT_SLOT_HELD'
21
+
22
+ export type SlotPool = { dir: string; size: number }
23
+
24
+ export type SlotHolder = { slot: number; pid: number; repo: string; check: string; since: number }
25
+
26
+ const SLOT_POLL_MS = 25
27
+
28
+ export const poolDir = (): string => {
29
+ const env = process.env[SLOTS_DIR_ENV]
30
+ return env != null && env !== '' ? env : join(homedir(), '.cache', 'shipkit', 'slots')
31
+ }
32
+
33
+ /** this process's pool, or null when it runs inside a check that holds one */
34
+ export function machinePool(): SlotPool | null {
35
+ return process.env[SLOT_HELD_ENV] === '1' ? null : { dir: poolDir(), size: parallelCap() }
36
+ }
37
+
38
+ /** who holds which slot right now, dead holders left out. Every slot file in
39
+ * the dir is read, so a slot taken under a larger size still shows. */
40
+ export function poolOccupancy(pool: SlotPool = { dir: poolDir(), size: parallelCap() }): { size: number; dir: string; held: SlotHolder[] } {
41
+ const held: SlotHolder[] = []
42
+ let names: string[] = []
43
+ try { names = readdirSync(pool.dir) } catch { /* no pool yet: nobody holds anything */ }
44
+ for (const name of names) {
45
+ const m = /^slot-(\d+)\.lock$/.exec(name)
46
+ if (m == null) continue
47
+ const h = leaseHolder(join(pool.dir, `slot-${m[1]}`))
48
+ if (h != null) held.push({ slot: Number(m[1]), pid: h.pid, repo: h.meta.repo ?? '?', check: h.meta.check ?? h.label, since: h.at })
49
+ }
50
+ return { size: pool.size, dir: pool.dir, held: held.sort((a, b) => a.slot - b.slot) }
51
+ }
52
+
53
+ /** what `shipkit ci` prints about the pool, one line (two with a bad env) */
54
+ export function poolLines(): string[] {
55
+ const info = parallelCapInfo()
56
+ const pool = machinePool()
57
+ const why = info.source === 'env' ? `${PARALLEL_ENV} sets it` : `this machine's cores, 2 to 8; ${PARALLEL_ENV} sets it`
58
+ return [
59
+ ...(info.problem == null ? [] : [`WARN ${info.problem}`]),
60
+ pool == null
61
+ ? `ok: parallel: inside a check that holds a machine slot, so this run takes none (up to ${info.cap} at once in this process)`
62
+ : `ok: parallel: one machine pool of ${info.cap} slot(s) shared by every shipkit process, ${poolOccupancy(pool).held.length} held now (${why})`,
63
+ ]
64
+ }
65
+
66
+ /** wait for a free slot and hold it until release(). null = abort said stop. */
67
+ export async function takeSlot(
68
+ pool: SlotPool,
69
+ who: { repo: string; check: string },
70
+ o: { abort?: () => boolean; pollMs?: number; onWait?: (held: SlotHolder[]) => void } = {},
71
+ ): Promise<{ slot: number; release: () => void } | null> {
72
+ mkdirSync(pool.dir, { recursive: true })
73
+ let heard = false
74
+ for (;;) {
75
+ for (let slot = 0; slot < pool.size; slot++) {
76
+ const lease = tryLease(join(pool.dir, `slot-${slot}`), who.check, { repo: who.repo, check: who.check })
77
+ if (lease != null) return { slot, release: lease.release }
78
+ }
79
+ if (o.abort?.() === true) return null
80
+ if (!heard) { heard = true; o.onWait?.(poolOccupancy(pool).held) }
81
+ await Bun.sleep(o.pollMs ?? SLOT_POLL_MS)
82
+ }
83
+ }
@@ -3,8 +3,16 @@
3
3
  // Resolves the binary first (Bun.spawn throws on a missing one), streams
4
4
  // stdout+stderr, SIGTERM at timeoutMs, SIGKILL after a grace period so a
5
5
  // SIGTERM-ignoring child can never hang the caller.
6
+ //
7
+ // The kill walks the whole process TREE, because the cmd is usually a wrapper
8
+ // (`bun scripts/test.ts` spawning `bun test`) and Bun.spawn offers no process
9
+ // group to signal: signalling the wrapper alone leaves the process doing the
10
+ // work orphaned and spinning, with nothing left holding a handle on it.
6
11
 
7
12
  import { existsSync } from 'fs'
13
+ import { gitBin } from './git-bin.ts'
14
+ import { trackChild, untrackChild } from './instance.ts'
15
+ import { descendantPids } from './process-tree.ts'
8
16
 
9
17
  export type SpawnOutcome = {
10
18
  code: number
@@ -26,12 +34,28 @@ export type SpawnTimeoutOpts = {
26
34
  onSpawn?: (proc: ReturnType<typeof Bun.spawn>) => void
27
35
  }
28
36
 
37
+ /** signal the descendants, then the child itself through its own handle (the
38
+ * handle knows the child exited, a bare pid does not). A pid that already
39
+ * went is the normal case here, never an error. Returns what it signalled, so
40
+ * a later pass can reach a survivor that reparenting has hidden. A remembered
41
+ * pid could be recycled inside that pass's grace window, which is the price
42
+ * of a runtime with no process group to signal. */
43
+ export function killTree(proc: { pid: number; kill: (s?: NodeJS.Signals) => void }, signal: NodeJS.Signals, known?: number[]): number[] {
44
+ const pids = [...new Set([...(known ?? []), ...descendantPids(proc.pid)])]
45
+ for (const pid of pids) {
46
+ try { process.kill(pid, signal) } catch {}
47
+ }
48
+ try { proc.kill(signal) } catch {}
49
+ return pids
50
+ }
51
+
29
52
  export async function spawnWithTimeout(cmd: string[], opts: SpawnTimeoutOpts): Promise<SpawnOutcome> {
30
53
  const head = cmd[0]
31
54
  if (head == null) return { code: -1, out: 'empty command', timedOut: false, spawnFailed: true }
32
55
  // path-style heads verified too — Bun.spawn on a missing path throws,
33
56
  // which must surface as a result, never a crash
34
- const bin = head.includes('/') ? head : Bun.which(head)
57
+ const name = head === 'git' ? gitBin() : head
58
+ const bin = name.includes('/') ? name : Bun.which(name)
35
59
  if (bin == null || !existsSync(bin)) return { code: -1, out: `${head}: not found`, timedOut: false, spawnFailed: true }
36
60
  let proc: ReturnType<typeof Bun.spawn>
37
61
  try {
@@ -44,10 +68,16 @@ export async function spawnWithTimeout(cmd: string[], opts: SpawnTimeoutOpts): P
44
68
  } catch (e) {
45
69
  return { code: -1, out: `spawn error: ${String(e).slice(0, 200)}`, timedOut: false, spawnFailed: true }
46
70
  }
71
+ // listed while it runs, so a later image of this pid can reap it
72
+ trackChild(proc.pid)
47
73
  opts.onSpawn?.(proc)
48
74
  let timedOut = false
49
- const timer = setTimeout(() => { timedOut = true; try { proc.kill() } catch {} }, opts.timeoutMs)
50
- const killer = setTimeout(() => { try { proc.kill('SIGKILL') } catch {} }, opts.timeoutMs + (opts.killGraceMs ?? 2000))
75
+ // what the tree looked like at SIGTERM time: a survivor is reparented the
76
+ // moment its parent dies, so the SIGKILL pass can no longer walk down to
77
+ // it from the child
78
+ let tree: number[] = []
79
+ const timer = setTimeout(() => { timedOut = true; tree = killTree(proc, 'SIGTERM') }, opts.timeoutMs)
80
+ const killer = setTimeout(() => { killTree(proc, 'SIGKILL', tree) }, opts.timeoutMs + (opts.killGraceMs ?? 2000))
51
81
  let out = ''
52
82
  const eat = async (stream: ReadableStream<Uint8Array>): Promise<void> => {
53
83
  const dec = new TextDecoder()
@@ -59,6 +89,7 @@ export async function spawnWithTimeout(cmd: string[], opts: SpawnTimeoutOpts): P
59
89
  }
60
90
  await Promise.all([eat(proc.stdout as ReadableStream<Uint8Array>), eat(proc.stderr as ReadableStream<Uint8Array>)])
61
91
  const code = await proc.exited
92
+ untrackChild(proc.pid)
62
93
  clearTimeout(timer)
63
94
  clearTimeout(killer)
64
95
  return { code, out, timedOut, spawnFailed: false }