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
@@ -108,9 +108,16 @@ const PROGRAM_WORD = /^[\w./@+-]+$/
108
108
  * bodies are dense and multi-line, so this is not a corner case here, it is
109
109
  * the most common shape of command an agent runs. */
110
110
  export function shellSegments(command: string): string[] {
111
+ return shellParts(command).map((p) => p.seg)
112
+ }
113
+
114
+ /** each segment with the operator that ENDS it: `|` for a pipe (`|&` too),
115
+ * `;` for every other separator, `` after the last one. A redirection's `&`
116
+ * (`2>&1`, `&>log`) and the `|` of `>|` stay inside their segment */
117
+ export function shellParts(command: string): { seg: string; op: '|' | ';' | '' }[] {
111
118
  // everything after a heredoc opener is the document, never a command
112
119
  const shell = command.split(/<<-?\s*['"]?\w+/)[0] ?? command
113
- const out: string[] = []
120
+ const out: { seg: string; op: '|' | ';' | '' }[] = []
114
121
  let cur = ''
115
122
  let quote: '"' | "'" | null = null
116
123
  for (let i = 0; i < shell.length; i++) {
@@ -122,19 +129,80 @@ export function shellSegments(command: string): string[] {
122
129
  continue
123
130
  }
124
131
  if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue }
132
+ const prev = shell[i - 1]
133
+ const next = shell[i + 1]
134
+ if ((ch === '&' && (prev === '>' || prev === '<' || next === '>')) || (ch === '|' && prev === '>')) { cur += ch; continue }
125
135
  if (ch === '\n' || ch === ';' || ch === '|' || ch === '&') {
136
+ let op: '|' | ';' = ';'
126
137
  // `&&` and `||` are one operator, and a lone `&` backgrounds
127
- if ((ch === '&' || ch === '|') && shell[i + 1] === ch) i++
128
- out.push(cur)
138
+ if ((ch === '&' || ch === '|') && next === ch) i++
139
+ else if (ch === '|') {
140
+ op = '|'
141
+ if (next === '&') i++
142
+ }
143
+ out.push({ seg: cur, op })
129
144
  cur = ''
130
145
  continue
131
146
  }
132
147
  cur += ch
133
148
  }
134
- out.push(cur)
149
+ out.push({ seg: cur, op: '' })
135
150
  return out
136
151
  }
137
152
 
153
+ /** the words of a segment from its program on: leading `VAR=value`
154
+ * assignments, then wrapper words with their own flags and durations
155
+ * (`timeout 60 bun test`), dropped repeatedly */
156
+ function programWords(seg: string): string[] {
157
+ let s = seg.trim().replace(/^[(){}\s]+/, '')
158
+ let wrapped = false
159
+ for (;;) {
160
+ const next = s.replace(/^\w+=(?:"[^"]*"|'[^']*'|\S*)\s+/, '')
161
+ if (next !== s) { s = next; continue }
162
+ const w = /^(\S+)\s+/.exec(s)
163
+ if (w != null && WRAPPERS.has(w[1]!.split('/').pop() ?? '')) { s = s.slice(w[0].length); wrapped = true; continue }
164
+ if (wrapped && w != null && /^(?:-\S*|\d+[smhd]?)$/.test(w[1]!)) { s = s.slice(w[0].length); continue }
165
+ break
166
+ }
167
+ return s.split(/\s+/).filter((w) => w !== '')
168
+ }
169
+
170
+ /** true when the words run a GATE: `shipkit ci`, the cli by path
171
+ * (`bun <dir>/cli.ts ci`), or the `test` and `ci` package scripts. One suite
172
+ * while iterating (`bun run test:raw <file>`) is not a gate */
173
+ function isGate(words: string[]): boolean {
174
+ const [a, b, c] = words.map((w) => w.split('/').pop() ?? w)
175
+ if (a === 'shipkit') return b === 'ci'
176
+ if ((a === 'bunx' || a === 'npx') && b === 'shipkit') return c === 'ci'
177
+ if (a === 'cli.ts') return b === 'ci'
178
+ if (a === 'bun' || a === 'node') {
179
+ if (b === 'cli.ts') return c === 'ci'
180
+ if (b === 'run') return c === 'test' || c === 'ci'
181
+ }
182
+ return false
183
+ }
184
+
185
+ /** the segment of a command that pipes a gate into something else, or null.
186
+ * A pipeline exits with its LAST command's code, so `shipkit ci | tail`
187
+ * reports the green of tail over a red gate. A `>` redirect keeps the gate's
188
+ * own exit code and stays allowed, and so does a pipeline under
189
+ * `set -o pipefail` */
190
+ export function pipedGateIn(command: string): string | null {
191
+ const parts = shellParts(command)
192
+ if (parts.some((p) => /^\s*set\s.*\bpipefail\b/.test(p.seg))) return null
193
+ for (const p of parts) {
194
+ if (p.op !== '|') continue
195
+ const words = programWords(p.seg)
196
+ if (words[0] != null && PROGRAM_WORD.test(words[0]) && isGate(words)) return p.seg.trim()
197
+ }
198
+ return null
199
+ }
200
+
201
+ /** what a piped gate refusal says: why, what to type instead, how to lift it */
202
+ export function pipedGateRefusal(segment: string): string {
203
+ return `\`${segment.slice(0, 80)}\` pipes a gate: a pipeline exits with its LAST command's code, so a red gate reads green. Run the gate bare (a \`> file\` redirect keeps its exit code and is fine), then read its output in a separate call. Lifting this: put \`set -o pipefail;\` first, or remove the one-way-in-guard hook from .claude/settings.json (agent/process.md, never pipe a gating command).`
204
+ }
205
+
138
206
  /** the raw runner this shell command reaches for, plus the segment that
139
207
  * offended, or null.
140
208
  *
@@ -156,19 +224,7 @@ export function rawRunnerHit(command: string, only?: ReadonlySet<string>): { run
156
224
  .filter((r) => only == null || only.has(r.head))
157
225
  .sort((a, b) => b.head.length - a.head.length)
158
226
  for (const seg of shellSegments(command)) {
159
- let s = seg.trim().replace(/^[(){}\s]+/, '')
160
- // leading VAR=value assignments, then a wrapper word, repeatedly. A
161
- // wrapper's own flags and durations go with it (`timeout 60 bun test`)
162
- let wrapped = false
163
- for (;;) {
164
- const next = s.replace(/^\w+=(?:"[^"]*"|'[^']*'|\S*)\s+/, '')
165
- if (next !== s) { s = next; continue }
166
- const w = /^(\S+)\s+/.exec(s)
167
- if (w != null && WRAPPERS.has(w[1]!.split('/').pop() ?? '')) { s = s.slice(w[0].length); wrapped = true; continue }
168
- if (wrapped && w != null && /^(?:-\S*|\d+[smhd]?)$/.test(w[1]!)) { s = s.slice(w[0].length); continue }
169
- break
170
- }
171
- const words = s.split(/\s+/).filter((w) => w !== '')
227
+ const words = programWords(seg)
172
228
  const first = words[0]
173
229
  if (first == null || !PROGRAM_WORD.test(first)) continue
174
230
  // a path-qualified binary counts: ./node_modules/.bin/tsc IS tsc
@@ -0,0 +1,45 @@
1
+ // the words every surface prints for what a run did with a check. One word
2
+ // per outcome, shared, so the cli, the hub and the docs never drift apart.
3
+ // ran the body executed this run
4
+ // cached the answer came back from the cache, nothing executed
5
+ // never cached the check declares nothing a cache key can describe
6
+ // attached another run was executing the same question, this one took its answer
7
+
8
+ export const RAN = 'ran'
9
+ export const CACHED = 'cached'
10
+ export const NEVER_CACHED = 'never cached'
11
+ export const ATTACHED = 'attached'
12
+
13
+ export type Outcome = typeof RAN | typeof CACHED
14
+
15
+ /** the one word for a finished check: `replayed` is the run record's column */
16
+ export const outcomeWord = (replayed: boolean): Outcome => (replayed ? CACHED : RAN)
17
+
18
+ /** `1 check`, `2 checks` */
19
+ export const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`
20
+
21
+ export type OutcomeCounts = { cached: number; ran: number; attached?: number; neverCached?: number }
22
+
23
+ /** `41 cached, 8 ran`, plus `2 attached to another run` and `9 never cached`
24
+ * when given. Attached shows only when some check attached */
25
+ export function outcomeCounts(c: OutcomeCounts): string {
26
+ const parts = [`${c.cached} ${CACHED}`, `${c.ran} ${RAN}`]
27
+ if ((c.attached ?? 0) > 0) parts.push(`${c.attached} ${ATTACHED} to another run`)
28
+ if (c.neverCached != null) parts.push(`${c.neverCached} ${NEVER_CACHED}`)
29
+ return parts.join(', ')
30
+ }
31
+
32
+ /** a check's last recorded verdict in words, the same on the hub tree and in
33
+ * the feed line `shipkit needs` prints */
34
+ export const checkVerdictWords = (status: 'pass' | 'warn' | 'fail' | 'skip' | null, o: { replayed: boolean; advisory: boolean }): string =>
35
+ status === 'fail'
36
+ ? o.advisory ? 'failed, does not block the gate' : 'failed'
37
+ : status === 'warn' ? 'warned'
38
+ : status === 'skip' ? 'skipped'
39
+ : status === 'pass' ? (o.replayed ? `passed, ${CACHED}` : 'passed') : 'never ran here'
40
+
41
+ /** the stamp on the first line of a cached check: `(cached, 5.6s saved)` */
42
+ export const cachedStamp = (savedMs: number): string => `(${CACHED}, ${(savedMs / 1000).toFixed(1)}s saved)`
43
+
44
+ /** the stamp on the first line of an attached check */
45
+ export const attachedStamp = (pid: number, ranMs: number): string => `(${ATTACHED} to pid ${pid}, ${RAN} there in ${(ranMs / 1000).toFixed(1)}s)`
@@ -0,0 +1,86 @@
1
+ // which package manager a repo updates with, decided by its lock files. a
2
+ // repo whose deploy runs `npm ci` is broken by an update made with another
3
+ // manager: package.json moves, package-lock.json does not, and `npm ci`
4
+ // refuses a lock that disagrees with the manifest, so the failure lands at
5
+ // deploy time with every local gate green.
6
+ //
7
+ // TRACKED locks decide, because an untracked bun.lock is exactly what a wrong
8
+ // update leaves behind, and reading it as the repo's choice would make that
9
+ // first mistake permanent. Nothing tracked (a fresh tree, no git) falls back
10
+ // to what sits on disk. Among several, the order below IS the preference, and
11
+ // the loser is named so the drift is visible. No lock at all is bun, the
12
+ // house default.
13
+ import { existsSync } from 'fs'
14
+ import { join } from 'path'
15
+ import { gitBin } from './git-bin.ts'
16
+
17
+ export type PackageManagerName = 'bun' | 'npm' | 'pnpm' | 'yarn'
18
+
19
+ export type PackageManager = {
20
+ name: PackageManagerName
21
+ /** the lock files it writes, for a git dirty probe over the manifest */
22
+ locks: string[]
23
+ /** install what is already declared */
24
+ install: string[]
25
+ /** move these deps to their latest version, manifest AND lock in one call */
26
+ update: (names: string[]) => string[]
27
+ /** set when several managers own a lock here, so the pick is a guess */
28
+ warn?: string
29
+ }
30
+
31
+ const BUN_LOCKS = ['bun.lock', 'bun.lockb']
32
+ export const LOCK_FILES = [...BUN_LOCKS, 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json']
33
+
34
+ const bun: PackageManager = {
35
+ name: 'bun',
36
+ locks: BUN_LOCKS,
37
+ install: ['bun', 'install'],
38
+ update: (names) => ['bun', 'update', '--latest', ...names],
39
+ }
40
+ const npm: PackageManager = {
41
+ name: 'npm',
42
+ locks: ['package-lock.json'],
43
+ install: ['npm', 'install'],
44
+ update: (names) => ['npm', 'install', ...names.map((n) => `${n}@latest`)],
45
+ }
46
+ const pnpm: PackageManager = {
47
+ name: 'pnpm',
48
+ locks: ['pnpm-lock.yaml'],
49
+ install: ['pnpm', 'install'],
50
+ update: (names) => ['pnpm', 'update', '--latest', ...names],
51
+ }
52
+ // berry dropped `upgrade` and classic never had `up`, and a .yarnrc.yml is
53
+ // the cheap separator between the two
54
+ const yarn = (berry: boolean): PackageManager => ({
55
+ name: 'yarn',
56
+ locks: ['yarn.lock'],
57
+ install: ['yarn', 'install'],
58
+ update: berry ? (names) => ['yarn', 'up', ...names.map((n) => `${n}@latest`)] : (names) => ['yarn', 'upgrade', '--latest', ...names],
59
+ })
60
+
61
+ /** lock files git tracks at the root, empty outside a repo */
62
+ export function trackedLocks(root: string): string[] {
63
+ try {
64
+ const p = Bun.spawnSync([gitBin(), 'ls-files', '--', ...LOCK_FILES], { cwd: root, stdout: 'pipe', stderr: 'ignore' })
65
+ if (p.exitCode !== 0) return []
66
+ return p.stdout.toString().split('\n').map((l) => l.trim()).filter((l) => l !== '')
67
+ } catch {
68
+ return []
69
+ }
70
+ }
71
+
72
+ /** the manager this repo installs with, read off its lock files */
73
+ export function detectPackageManager(root: string, tracked: string[] = trackedLocks(root)): PackageManager {
74
+ const present = LOCK_FILES.filter((f) => existsSync(join(root, f)))
75
+ const owned = present.filter((f) => tracked.includes(f))
76
+ const locks = owned.length > 0 ? owned : present
77
+ const has = (f: string): boolean => locks.includes(f)
78
+ let pick = bun
79
+ if (BUN_LOCKS.some(has)) pick = bun
80
+ else if (has('pnpm-lock.yaml')) pick = pnpm
81
+ else if (has('yarn.lock')) pick = yarn(existsSync(join(root, '.yarnrc.yml')))
82
+ else if (has('package-lock.json')) pick = npm
83
+ const others = locks.filter((f) => !pick.locks.includes(f))
84
+ if (others.length === 0) return pick
85
+ return { ...pick, warn: `several lock files at the root (${locks.join(', ')}) — updating with ${pick.name}, so ${others.join(', ')} will drift; delete the one this repo does not install with` }
86
+ }
@@ -0,0 +1,21 @@
1
+ // THE POOL SIZE: how many checks this MACHINE measures at once, across every
2
+ // shipkit process (core/slots.ts owns the pool). One function, so the runner
3
+ // and the hub read the same number. A machine resource, not a
4
+ // repo fact: only the machine's environment sets it.
5
+ export const PARALLEL_ENV = 'SHIPKIT_PARALLEL'
6
+
7
+ /** the machine's cores, never below 2 (one slow check must not starve the
8
+ * rest) and never above 8 (each check is often a whole tool process) */
9
+ export const defaultParallel = (): number => Math.max(2, Math.min(8, navigator.hardwareConcurrency || 4))
10
+
11
+ /** the size and where it came from. A value nobody can use is named rather
12
+ * than guessed at, and the default holds. */
13
+ export function parallelCapInfo(): { cap: number; source: 'env' | 'default'; problem: string | null } {
14
+ const raw = process.env[PARALLEL_ENV]
15
+ if (raw == null || raw === '') return { cap: defaultParallel(), source: 'default', problem: null }
16
+ const n = Number(raw)
17
+ if (Number.isInteger(n) && n >= 1) return { cap: n, source: 'env', problem: null }
18
+ return { cap: defaultParallel(), source: 'default', problem: `${PARALLEL_ENV}=${raw} is not a whole number of at least 1, so the default ${defaultParallel()} holds` }
19
+ }
20
+
21
+ export const parallelCap = (): number => parallelCapInfo().cap
@@ -0,0 +1,44 @@
1
+ // THE PROCESS TREE: one `ps` read of who is whose parent, shared by the
2
+ // spawn timeout's tree kill and the restart reap (core/instance.ts).
3
+
4
+ /** one `ps` snapshot, `pid ppid` per line, the same invocation on darwin and
5
+ * linux. Unreadable means an empty tree, never a thrown kill. */
6
+ export function psSnapshot(): string {
7
+ const ps = Bun.which('ps')
8
+ if (ps == null) return ''
9
+ try {
10
+ const r = Bun.spawnSync([ps, '-Ao', 'pid=,ppid='])
11
+ return r.success ? r.stdout.toString() : ''
12
+ } catch {
13
+ return ''
14
+ }
15
+ }
16
+
17
+ /** every descendant of `root`, DEEPEST FIRST, so a child is signalled before
18
+ * the parent that could spawn another one. Enumerate before killing: once the
19
+ * parent dies its children are reparented and the link to `root` is gone. */
20
+ export function descendantPids(root: number, snapshot?: string): number[] {
21
+ const kids = new Map<number, number[]>()
22
+ for (const line of (snapshot ?? psSnapshot()).split('\n')) {
23
+ const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line)
24
+ if (m == null) continue
25
+ const pid = Number(m[1])
26
+ const ppid = Number(m[2])
27
+ if (pid === ppid || pid === 0) continue
28
+ const list = kids.get(ppid)
29
+ if (list == null) kids.set(ppid, [pid])
30
+ else list.push(pid)
31
+ }
32
+ const out: number[] = []
33
+ const seen = new Set<number>([root])
34
+ const walk = (pid: number): void => {
35
+ for (const kid of kids.get(pid) ?? []) {
36
+ if (seen.has(kid)) continue // a ppid loop is impossible, a stale snapshot is not
37
+ seen.add(kid)
38
+ walk(kid)
39
+ out.push(kid)
40
+ }
41
+ }
42
+ walk(root)
43
+ return out
44
+ }
@@ -0,0 +1,93 @@
1
+ // RAW ACCESS, found statically. A `run` check is cached on what it read through
2
+ // `c` only while nothing it can call reads the disk or spawns a process around
3
+ // `c`. So its repo-local import closure (installs excluded) is scanned for the
4
+ // shapes that could, and ONE hit keeps it uncached.
5
+ //
6
+ // Conservative on purpose: a module that imports `fs` only to write, or never
7
+ // calls the import, still counts. A false "never cached" costs speed, a missed
8
+ // read costs a wrong green, and the two are not symmetric.
9
+ import { readFileSync } from 'fs'
10
+ import { join, relative } from 'path'
11
+ import { loadConfig, type RvtConfig } from '../ts/config.ts'
12
+ import { importClosure } from '../ts/source-graph.ts'
13
+
14
+ /** where a module reaches the disk or a process around the check context */
15
+ export type RawAccess = { file: string; line: number; what: string }
16
+
17
+ const IMPORTS = { re: /\b(?:from\s*|import\s*\(\s*|require\s*\(\s*|import\s+)(['"])(?:node:)?(fs(?:\/promises)?|child_process)\1/, what: (m: RegExpMatchArray) => `imports ${m[2]}` }
18
+ /** the load time half: a dynamic `import()` or `require()` is left to the call that runs it */
19
+ const STATIC_IMPORTS = { re: /\b(?:from\s*|import\s+)(['"])(?:node:)?(fs(?:\/promises)?|child_process)\1/, what: (m: RegExpMatchArray) => `imports ${m[2]}` }
20
+ const CALLS = { re: /\bBun\s*\.\s*(file|spawn|spawnSync|write|\$)(?![\w$])\s*[(`]/, what: (m: RegExpMatchArray) => `calls Bun.${m[1]}` }
21
+
22
+ /** what closure a walk follows. A CHECK file's body can run anything it
23
+ * imports, dynamically or not, so its walk follows `import()` too. The
24
+ * CONFIG's value is what its module LOAD computes, so a literal `import()`
25
+ * inside a function it exports is not part of it: `dynamic: false` */
26
+ export type ClosureKind = { dynamic?: boolean }
27
+
28
+ /** the first raw access in one source text, 1-based line, comments skipped */
29
+ export function rawAccessInSource(src: string, o: ClosureKind = {}): { line: number; what: string } | null {
30
+ const memo = o.dynamic === false ? staticHits : allHits
31
+ const hit = memo.get(src)
32
+ if (hit !== undefined) return hit == null ? null : { ...hit }
33
+ const found = scanSource(src, o)
34
+ if (memo.size >= HITS_MEMO_MAX) memo.clear()
35
+ memo.set(src, found)
36
+ return found == null ? null : { ...found }
37
+ }
38
+
39
+ /** a pure function of the text, asked of the same closures by every gate, so
40
+ * the answer is remembered per text */
41
+ const allHits = new Map<string, { line: number; what: string } | null>()
42
+ const staticHits = new Map<string, { line: number; what: string } | null>()
43
+ const HITS_MEMO_MAX = 4000
44
+
45
+ function scanSource(src: string, o: ClosureKind): { line: number; what: string } | null {
46
+ const SHAPES = [o.dynamic === false ? STATIC_IMPORTS : IMPORTS, CALLS]
47
+ const lines = src.split('\n')
48
+ let inBlock = false
49
+ for (let i = 0; i < lines.length; i++) {
50
+ let l = lines[i]!
51
+ if (inBlock) {
52
+ const end = l.indexOf('*/')
53
+ if (end < 0) continue
54
+ l = l.slice(end + 2)
55
+ inBlock = false
56
+ }
57
+ l = l.replace(/\/\*.*?\*\//g, '')
58
+ const open = l.indexOf('/*')
59
+ if (open >= 0) { inBlock = true; l = l.slice(0, open) }
60
+ l = l.replace(/(^|\s)\/\/.*$/, '$1')
61
+ if (/^\s*import\s+type\b/.test(l)) continue
62
+ for (const s of SHAPES) {
63
+ const m = l.match(s.re)
64
+ if (m != null) return { line: i + 1, what: s.what(m) }
65
+ }
66
+ }
67
+ return null
68
+ }
69
+
70
+ /** the first raw access in the import closure of `file`, walked from `root`
71
+ * (the repo for a repo check, the toolkit for a built-in). Installs are never
72
+ * scanned, and neither is `skip`, the toolkit's own tree when it sits inside
73
+ * the repo: the toolchain id covers it exactly as it covers an install.
74
+ * Throws when the closure cannot be walked, which the caller counts as a hit */
75
+ export function rawAccessInClosure(root: string, file: string, o: { skip?: string; configs?: Map<string, RvtConfig> } & ClosureKind = {}): RawAccess | null {
76
+ // one pass over many checks under one root hands in `configs`, so the
77
+ // root's config (a walk of its source tree) is read once for all of them
78
+ let loaded = o.configs?.get(root)
79
+ if (loaded == null) { loaded = loadConfig(root); o.configs?.set(root, loaded) }
80
+ const cfg = { ...loaded, root }
81
+ const skipped = (f: string): boolean => f.split('/').includes('node_modules') || (o.skip != null && join(root, f).startsWith(`${o.skip}/`))
82
+ const files = importClosure(cfg, [relative(root, file).replace(/\\/g, '/')], o).filter((f) => !skipped(f))
83
+ for (const f of files) {
84
+ let src: string
85
+ try { src = readFileSync(join(root, f), 'utf8') } catch { continue }
86
+ const hit = rawAccessInSource(src, o)
87
+ if (hit != null) return { file: f, ...hit }
88
+ }
89
+ return null
90
+ }
91
+
92
+ /** the one line a surface prints about a hit */
93
+ export const rawAccessLine = (r: RawAccess): string => `raw access in ${r.file}:${r.line} (${r.what}): read through c, or declare inputs or never`
@@ -0,0 +1,152 @@
1
+ // WHAT A CHECK READ, as it read it. The check context (core/check-ctx.ts) is
2
+ // the only door a `run` body has to the repo, so every file, existence probe
3
+ // and listing it asked for lands here with the hash of what it saw. The ledger
4
+ // stores that set beside the answer, and a later run replays the answer only
5
+ // while every entry still hashes the same.
6
+ //
7
+ // A file is hashed from the bytes the check actually read, with git's own blob
8
+ // formula, so the record describes what the body saw even when the file moved
9
+ // a millisecond later: the next run hashes the disk again, the two differ, and
10
+ // the check runs. Nothing here can turn a mid-run edit into a replay.
11
+ import { createHash } from 'crypto'
12
+ import { lstatSync, readdirSync, readFileSync, statSync } from 'fs'
13
+ import { join } from 'path'
14
+
15
+ /** `file` or `dir` when the path was there, null when it was not */
16
+ export type PathKind = 'file' | 'dir' | null
17
+
18
+ /** one check's reads, sorted and serialisable: what the ledger stores */
19
+ export type ReadSet = {
20
+ /** repo-relative path → git blob id of the bytes read, null = absent */
21
+ files: Record<string, string | null>
22
+ /** repo-relative path → what stood there when it was asked */
23
+ exists: Record<string, PathKind>
24
+ /** glob → hash of the sorted listing it returned */
25
+ lists: Record<string, string>
26
+ /** repo-relative dir → hash of its entries as read from the disk, null =
27
+ * no dir there. Absent from a set stored before it existed */
28
+ dirs?: Record<string, string | null>
29
+ /** repo-relative path → whether a symlink stood there. Absent from a set
30
+ * stored before it existed */
31
+ links?: Record<string, boolean>
32
+ }
33
+
34
+ /** one entry of a directory, as `c.entries` returns it */
35
+ export type DirEntry = { name: string; kind: 'file' | 'dir' }
36
+
37
+ /** git's blob id of these bytes, computed in process */
38
+ export function blobOid(bytes: Uint8Array): string {
39
+ return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
40
+ }
41
+
42
+ /** the identity of one listing: the paths, sorted, as one hash */
43
+ export function listingHash(paths: readonly string[]): string {
44
+ return createHash('sha256').update(paths.join('\n')).digest('hex').slice(0, 16)
45
+ }
46
+
47
+ export function kindOf(abs: string): PathKind {
48
+ try {
49
+ const s = statSync(abs)
50
+ return s.isDirectory() ? 'dir' : 'file'
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
56
+ /** whether a symlink stands at `abs` itself, not what it points at */
57
+ export function isLinkAt(abs: string): boolean {
58
+ try {
59
+ return lstatSync(abs).isSymbolicLink()
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
65
+ /** the entries directly inside `abs`, from the disk (ignored ones included),
66
+ * sorted by name, null when no dir stands there. A symlink takes the kind of
67
+ * what it points at, and a dangling one is left out, as nothing is there */
68
+ export function dirEntries(abs: string): DirEntry[] | null {
69
+ let names: string[]
70
+ try {
71
+ if (!statSync(abs).isDirectory()) return null
72
+ names = readdirSync(abs)
73
+ } catch {
74
+ return null
75
+ }
76
+ const out: DirEntry[] = []
77
+ for (const name of names.sort()) {
78
+ const kind = kindOf(join(abs, name))
79
+ if (kind != null) out.push({ name, kind })
80
+ }
81
+ return out
82
+ }
83
+
84
+ /** the identity of one dir read, null for no dir */
85
+ export function entriesHash(entries: readonly DirEntry[] | null): string | null {
86
+ return entries == null ? null : listingHash(entries.map((e) => `${e.kind === 'dir' ? 'd' : 'f'} ${e.name}`))
87
+ }
88
+
89
+ /** the blob id of the file on disk now, null when there is no file to read */
90
+ export function oidOnDisk(abs: string): string | null {
91
+ try {
92
+ return blobOid(readFileSync(abs))
93
+ } catch {
94
+ return null
95
+ }
96
+ }
97
+
98
+ /** the recorder a context writes into. Merging is how a shared scan's reads
99
+ * reach every check that used it. */
100
+ export class Reads {
101
+ readonly files = new Map<string, string | null>()
102
+ readonly exists = new Map<string, PathKind>()
103
+ readonly lists = new Map<string, string>()
104
+ readonly dirs = new Map<string, string | null>()
105
+ readonly links = new Map<string, boolean>()
106
+
107
+ file(path: string, oid: string | null): void { this.files.set(path, oid) }
108
+ exist(path: string, kind: PathKind): void { this.exists.set(path, kind) }
109
+ list(glob: string, hash: string): void { this.lists.set(glob, hash) }
110
+ dir(path: string, hash: string | null): void { this.dirs.set(path, hash) }
111
+ link(path: string, is: boolean): void { this.links.set(path, is) }
112
+
113
+ merge(o: Reads): void {
114
+ for (const [k, v] of o.files) this.files.set(k, v)
115
+ for (const [k, v] of o.exists) this.exists.set(k, v)
116
+ for (const [k, v] of o.lists) this.lists.set(k, v)
117
+ for (const [k, v] of o.dirs) this.dirs.set(k, v)
118
+ for (const [k, v] of o.links) this.links.set(k, v)
119
+ }
120
+
121
+ snapshot(): ReadSet {
122
+ const sorted = <V>(m: Map<string, V>): Record<string, V> => Object.fromEntries([...m].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
123
+ return { files: sorted(this.files), exists: sorted(this.exists), lists: sorted(this.lists), dirs: sorted(this.dirs), links: sorted(this.links) }
124
+ }
125
+ }
126
+
127
+ /** the first entry of `set` that no longer holds, or null when every one
128
+ * does. `list` answers a glob the way `c.list` would right now, and returns
129
+ * null when it cannot answer honestly (no git scan), which counts as drift. */
130
+ export function readSetDrift(root: string, set: ReadSet, list: (glob: string) => readonly string[] | null): string | null {
131
+ for (const [p, oid] of Object.entries(set.files)) {
132
+ const now = oidOnDisk(join(root, p))
133
+ if (now !== oid) return `${p} ${oid == null ? 'appeared' : now == null ? 'is gone' : 'changed'}`
134
+ }
135
+ for (const [p, kind] of Object.entries(set.exists)) {
136
+ const now = kindOf(join(root, p))
137
+ if (now !== kind) return `${p} ${kind == null ? 'appeared' : now == null ? 'is gone' : `is a ${now} now`}`
138
+ }
139
+ for (const [g, hash] of Object.entries(set.lists)) {
140
+ const now = list(g)
141
+ if (now == null) return `${g} cannot be listed without a git scan`
142
+ if (listingHash(now) !== hash) return `the files matching ${g} changed`
143
+ }
144
+ for (const [d, hash] of Object.entries(set.dirs ?? {})) {
145
+ const now = entriesHash(dirEntries(join(root, d)))
146
+ if (now !== hash) return `${d}/ ${hash == null ? 'appeared' : now == null ? 'is gone' : 'changed its entries'}`
147
+ }
148
+ for (const [p, was] of Object.entries(set.links ?? {})) {
149
+ if (isLinkAt(join(root, p)) !== was) return `${p} ${was ? 'is no longer a link' : 'became a link'}`
150
+ }
151
+ return null
152
+ }