rvlib-shipkit 1.0.0 → 1.1.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 (72) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +30 -4
  4. package/src/assets/gen/derived.ts +1 -1
  5. package/src/check.ts +6 -0
  6. package/src/checks/SK004-gitignore-policy.check.ts +4 -4
  7. package/src/checks/SK005-rvlib-versions.check.ts +25 -7
  8. package/src/checks/SK021-github-login.check.ts +123 -0
  9. package/src/cli/cmd_Asks.ts +1 -1
  10. package/src/cli/cmd_Check.ts +1 -1
  11. package/src/cli/cmd_Ci.ts +1 -1
  12. package/src/cli/cmd_Fleet.ts +4 -47
  13. package/src/cli.ts +2 -3
  14. package/src/collections/checks.ts +12 -3
  15. package/src/collections/collection-types.ts +34 -18
  16. package/src/collections/comfy.ts +1 -0
  17. package/src/collections/posts.ts +1 -0
  18. package/src/collections/providers.ts +71 -43
  19. package/src/collections/reflections.ts +1 -0
  20. package/src/collections/state-rules.ts +29 -13
  21. package/src/collections/tests.ts +1 -0
  22. package/src/collections/wikis.ts +1 -0
  23. package/src/core/budget.ts +60 -0
  24. package/src/core/check-ctx.ts +2 -1
  25. package/src/core/check-runner.ts +43 -6
  26. package/src/core/fd-table.ts +37 -0
  27. package/src/core/floor-resolve.ts +2 -2
  28. package/src/core/git-root.ts +26 -2
  29. package/src/core/lib-version.ts +16 -0
  30. package/src/core/migrate-layout.ts +69 -9
  31. package/src/core/motherhome.ts +2 -4
  32. package/src/core/package-manager.ts +20 -16
  33. package/src/core/raw-access.ts +11 -3
  34. package/src/core/reexec.ts +26 -0
  35. package/src/core/run-check.ts +36 -16
  36. package/src/core/run-ci.ts +31 -27
  37. package/src/core/run-fix.ts +4 -1
  38. package/src/core/run-fixers.ts +19 -11
  39. package/src/core/self-update.ts +63 -26
  40. package/src/core/update-policy.ts +25 -0
  41. package/src/fleet/scan-fleet.ts +20 -15
  42. package/src/fleet/update-fleet.ts +85 -0
  43. package/src/hooks/leak-guard.ts +10 -1
  44. package/src/hooks/run-hooks.ts +3 -3
  45. package/src/ledger/gate-checks.ts +19 -5
  46. package/src/ledger/git-key.ts +10 -5
  47. package/src/ledger/git-scan.ts +29 -17
  48. package/src/ledger/ledger.ts +14 -6
  49. package/src/ledger/repo-view.ts +8 -6
  50. package/src/ledger/run-log.ts +10 -4
  51. package/src/ledger/run-timeline.ts +21 -9
  52. package/src/problems/problem-board.ts +13 -2
  53. package/src/problems/problem-line.ts +9 -0
  54. package/src/repo-config.ts +24 -4
  55. package/src/serve/hub-app/check-view.tsx +12 -11
  56. package/src/serve/hub-app/cols-svc.ts +16 -9
  57. package/src/serve/hub-app/dur.ts +10 -1
  58. package/src/serve/hub-app/header-bar.tsx +11 -0
  59. package/src/serve/hub-app/help.tsx +1 -0
  60. package/src/serve/hub-app/hub-st.ts +3 -4
  61. package/src/serve/hub-app/hub-types.ts +5 -0
  62. package/src/serve/hub-app/hub.css +3 -0
  63. package/src/serve/hub-app/timeline-view.tsx +4 -4
  64. package/src/serve/hub-app/tree-build.ts +1 -2
  65. package/src/serve/hub-problems.ts +35 -0
  66. package/src/serve/run-serve.ts +51 -12
  67. package/src/serve/serve-hub.ts +10 -3
  68. package/src/serve/stale-restart.ts +77 -0
  69. package/src/smoke/run-smoke.ts +6 -2
  70. package/src/suite/suite-cli.ts +14 -10
  71. package/src/ts/source-graph.ts +3 -2
  72. package/src/wiki/scan-md.ts +1 -1
@@ -12,13 +12,13 @@ import { existsSync, readFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import { fileURLToPath } from 'url'
14
14
  import { armHooks } from '../hooks/run-hooks.ts'
15
- import type { RepoConfig } from '../repo-config.ts'
15
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
16
16
  import { runFix } from './run-fix.ts'
17
17
  import { routesToShipkit } from './canonical-scripts.ts'
18
18
  import { discoverLegacyChecks } from './discover-legacy-checks.ts'
19
19
  import { repoCheckFiles } from './discover-checks.ts'
20
20
  import { mutateLease, withMutateLease } from './locked-write.ts'
21
- import { migrateLayout } from './migrate-layout.ts'
21
+ import { layoutSummary, migrateLayout } from './migrate-layout.ts'
22
22
  import { renderPolicy } from '../checks/SK004-gitignore-policy.check.ts'
23
23
  import { spawnWithTimeout } from './spawn-timeout.ts'
24
24
  import { gitBin } from './git-bin.ts'
@@ -180,20 +180,29 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
180
180
  return { ran: false, touched: [], failed: false, reshaped: false }
181
181
  }
182
182
 
183
- async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, verify: boolean): Promise<FixPassResult> {
183
+ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, verify: boolean): Promise<FixPassResult> {
184
+ let config = loaded
185
+ const shapeBefore = shapeStamp(root)
184
186
  // the old root layout moves under .shipkit/ before anything else reads a
185
187
  // path: a move is not a decision, so it lands as one machine commit and
186
188
  // only what a machine cannot decide reaches a human. Runs before the tree
187
189
  // snapshot, and before hooks arm, because the hooks folder may be what moves
188
190
  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(' ')
191
+ const m = migrateLayout(root, { config, renderGitignore: (t, legacy, cfg) => renderPolicy(t, cfg ?? config, legacy) })
192
+ // the move rewrote repo.config.ts, so the config loaded before it names
193
+ // folders that are gone, and every fixer below must read the new one
194
+ if (m.swept.includes('repo.config.ts')) {
195
+ try { config = await loadRepoConfig(root, { fresh: true }) } catch (e) {
196
+ o.print(`WARN repo.config.ts does not load after the move rewrote it: ${String((e as Error)?.message ?? e).slice(0, 160)}`)
197
+ }
198
+ }
199
+ const what = layoutSummary(m)
200
+ if (what !== '') {
192
201
  if (m.uncommitted === true) {
193
- o.print(`WARN moved ${what} under .shipkit/, and the commit did not land`)
202
+ o.print(`WARN layout: ${what}. The commit did not land`)
194
203
  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}` : ''}`)
204
+ o.print(` fix: commit exactly these paths, never a bare commit: git commit -m 'chore: ${what}' -- ${p.slice(0, 12).join(' ')}${p.length > 12 ? ` (+${p.length - 12} more, listed by git status)` : ''}`)
205
+ } else o.print(`ok: layout: ${what}${m.commit != null ? `, committed as ${m.commit}` : ''}`)
197
206
  }
198
207
  if (m.deferred != null) {
199
208
  o.print(`WARN the move under .shipkit/ is deferred: ${m.deferred}`)
@@ -218,7 +227,6 @@ async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, ver
218
227
  for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
219
228
  for (const d of disabled) o.print(`skip: ${d.id} (${d.reason})`)
220
229
  const before = treeState(root)
221
- const shapeBefore = shapeStamp(root)
222
230
 
223
231
  for (const s of slots) {
224
232
  const r = await spawnWithTimeout(s.cmd, { cwd: root, timeoutMs: FIXER_TIMEOUT_MS })
@@ -228,7 +236,7 @@ async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, ver
228
236
  o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
229
237
  }
230
238
  // structural repo-config repair is never implicit, and never in verify mode
231
- if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print })
239
+ if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print, config })
232
240
 
233
241
  const after = treeState(root)
234
242
  const reshaped = shapeStamp(root) !== shapeBefore
@@ -10,12 +10,16 @@
10
10
  // re-exec guard set, dirty package.json/lockfile (a tool must not mix its
11
11
  // writes into human-staged work), registry unreachable (offline is not a
12
12
  // defect). every skip is silent except the actionable ones, which WARN.
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
13
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'
14
14
  import { homedir } from 'os'
15
- import { dirname, join } from 'path'
15
+ import { basename, dirname, join } from 'path'
16
16
  import { fileURLToPath } from 'url'
17
- import { detectPackageManager } from './package-manager.ts'
17
+ import { detectPackageManager, isRefusal } from './package-manager.ts'
18
18
  import { gitBin } from './git-bin.ts'
19
+ import { reexecSelf } from './reexec.ts'
20
+ import { LIB_ROOT } from './lib-root.ts'
21
+ import { FRESH_ENV } from '../ledger/store.ts'
22
+ import { allowMajor, breakingAdvice, isBreaking } from './update-policy.ts'
19
23
 
20
24
  const PKG = 'rvlib-shipkit'
21
25
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000
@@ -34,6 +38,10 @@ export function planSelfUpdate(o: {
34
38
  ci: boolean
35
39
  frozen: boolean
36
40
  manifestDirty: boolean
41
+ /** `fleet --update --major` asked for this jump on purpose */
42
+ allowMajor: boolean
43
+ /** the repo dir name, for the advice a breaking jump prints */
44
+ repo: string
37
45
  }): SelfUpdatePlan {
38
46
  if (o.reexecGuard) return { action: 'skip', reason: 'already re-executed once' }
39
47
  if (o.ci) return { action: 'skip', reason: 'CI env' }
@@ -43,6 +51,7 @@ export function planSelfUpdate(o: {
43
51
  if (o.latest == null) return { action: 'skip', reason: 'registry unreachable' }
44
52
  if (o.installed === o.latest) return { action: 'skip', reason: 'current' }
45
53
  if (newerThan(o.installed, o.latest)) return { action: 'skip', reason: 'ahead of the registry' }
54
+ if (isBreaking(o.installed, o.latest) && !o.allowMajor) return { action: 'warn', reason: breakingAdvice(PKG, o.latest, o.repo) }
46
55
  if (o.manifestDirty) return { action: 'warn', reason: `rvlib-shipkit ${o.installed} is behind ${o.latest}, package.json or lockfile dirty, not touching — commit, then rerun` }
47
56
  return { action: 'update', installed: o.installed, latest: o.latest }
48
57
  }
@@ -58,23 +67,40 @@ const newerThan = (a: string, b: string): boolean => {
58
67
  return false
59
68
  }
60
69
 
61
- const cachePath = (): string => join(homedir(), '.cache', 'shipkit', 'self-update.json')
70
+ /** overrides the per-machine cache file (tests) */
71
+ export const SELF_UPDATE_CACHE_ENV = 'SHIPKIT_SELF_UPDATE_CACHE'
62
72
 
63
- /** npm latest with a per-machine 24h cache, null when unreachable */
64
- async function latestCached(): Promise<string | null> {
73
+ const cachePath = (): string => {
74
+ const env = process.env[SELF_UPDATE_CACHE_ENV]
75
+ return env != null && env !== '' ? env : join(homedir(), '.cache', 'shipkit', 'self-update.json')
76
+ }
77
+
78
+ /** store a registry answer another path measured, so the next startup
79
+ * decides on it instead of on an older cached one */
80
+ export function rememberLatest(version: string): void {
65
81
  const p = cachePath()
66
- try {
82
+ mkdirSync(dirname(p), { recursive: true })
83
+ writeFileSync(p, JSON.stringify({ at: Date.now(), version }))
84
+ }
85
+
86
+ const npmLatest = async (): Promise<string | null> => {
87
+ const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(5000) })
88
+ if (!res.ok) return null
89
+ return ((await res.json()) as { version?: string }).version ?? null
90
+ }
91
+
92
+ /** npm latest with a per-machine 24h cache, null when unreachable. A fresh
93
+ * run skips the cache: it decides the one update that restarts the run, and
94
+ * a stale answer here leaves that update to SK005, mid run */
95
+ export async function latestCached(o: { fresh: boolean; fetchLatest?: () => Promise<string | null> }): Promise<string | null> {
96
+ const p = cachePath()
97
+ if (!o.fresh) try {
67
98
  const c = JSON.parse(readFileSync(p, 'utf8')) as { at: number; version: string }
68
99
  if (Date.now() - c.at < CACHE_TTL_MS) return c.version
69
100
  } catch { /* no cache yet */ }
70
101
  try {
71
- const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(5000) })
72
- if (!res.ok) return null
73
- const v = ((await res.json()) as { version?: string }).version ?? null
74
- if (v != null) {
75
- mkdirSync(dirname(p), { recursive: true })
76
- writeFileSync(p, JSON.stringify({ at: Date.now(), version: v }))
77
- }
102
+ const v = await (o.fetchLatest ?? npmLatest)()
103
+ if (v != null) rememberLatest(v)
78
104
  return v
79
105
  } catch {
80
106
  return null
@@ -87,10 +113,22 @@ const manifestDirty = (root: string): boolean => {
87
113
  return p.stdout.toString().trim() !== ''
88
114
  }
89
115
 
90
- /** run before dispatching an interactive ci/check. On update: re-exec the
91
- * SAME argv with a loop guard and exit with the child's code (never
92
- * returns). Otherwise returns after at most one cached registry probe. */
93
- export async function maybeSelfUpdateAndRestart(root: string): Promise<void> {
116
+ /** true when `root`'s installed rvlib-shipkit is the code of THIS process.
117
+ * Updating that tree in process would leave the rest of the run importing
118
+ * modules of two versions, so only the startup path below may update it */
119
+ export function isRunningCopy(root: string): boolean {
120
+ try {
121
+ return realpathSync(join(root, 'node_modules', PKG)) === realpathSync(LIB_ROOT)
122
+ } catch {
123
+ return false
124
+ }
125
+ }
126
+
127
+ /** run before dispatching an interactive ci/check, so no check has loaded
128
+ * yet. On update: re-exec the SAME argv with a loop guard and exit with the
129
+ * child's code (never returns). Otherwise returns after at most one registry
130
+ * probe, cached unless the run is fresh (`--fresh` or SHIPKIT_FRESH=1). */
131
+ export async function maybeSelfUpdateAndRestart(root: string, o: { fresh?: boolean } = {}): Promise<void> {
94
132
  const here = fileURLToPath(import.meta.url)
95
133
  const base = {
96
134
  installed: installedVersion(root),
@@ -98,23 +136,22 @@ export async function maybeSelfUpdateAndRestart(root: string): Promise<void> {
98
136
  reexecGuard: process.env.SHIPKIT_SELF_UPDATED === '1',
99
137
  ci: process.env.CI != null && process.env.CI !== '',
100
138
  frozen: process.env.SHIPKIT_FROZEN === '1',
139
+ allowMajor: allowMajor(),
140
+ repo: basename(root),
101
141
  }
102
142
  // cheap gates first: no network unless an update is even possible
103
143
  if (base.reexecGuard || base.ci || base.frozen || !base.runningFromNodeModules || base.installed == null) return
104
- const plan = planSelfUpdate({ ...base, latest: await latestCached(), manifestDirty: manifestDirty(root) })
144
+ const plan = planSelfUpdate({ ...base, latest: await latestCached({ fresh: o.fresh === true || process.env[FRESH_ENV] === '1' }), manifestDirty: manifestDirty(root) })
105
145
  if (plan.action === 'warn') { console.error(`WARN self-update: ${plan.reason}`); return }
106
146
  if (plan.action !== 'update') return
107
- const cmd = detectPackageManager(root).update([PKG])
147
+ const pm = detectPackageManager(root)
148
+ if (isRefusal(pm)) { console.error(`WARN self-update: rvlib-shipkit ${plan.installed} is behind ${plan.latest}, not updating: ${pm.refused}`); return }
149
+ const cmd = pm.update([PKG])
108
150
  console.error(`ok: rvlib-shipkit ${plan.installed} → ${plan.latest}, self-updating (${cmd.join(' ')})`)
109
151
  const up = Bun.spawnSync(cmd, { cwd: root, stdout: 'inherit', stderr: 'inherit' })
110
152
  if (up.exitCode !== 0) { console.error(`WARN self-update failed (exit ${up.exitCode}), continuing on ${plan.installed}`) ; return }
111
153
  console.error(`ok: restarting on ${plan.latest} — commit package.json + lockfile with your work`)
112
- const child = Bun.spawnSync([process.execPath, ...process.argv.slice(1)], {
113
- cwd: process.cwd(),
114
- stdout: 'inherit', stderr: 'inherit', stdin: 'inherit',
115
- env: { ...process.env, SHIPKIT_SELF_UPDATED: '1' },
116
- })
117
- process.exit(child.exitCode ?? 1)
154
+ reexecSelf({ ...process.env, SHIPKIT_SELF_UPDATED: '1' })
118
155
  }
119
156
 
120
157
  const installedVersion = (root: string): string | null => {
@@ -0,0 +1,25 @@
1
+ // the ONE update policy every automatic update path asks: rule SK005, the
2
+ // startup self-update and `shipkit fleet --update`. An automatic update never
3
+ // crosses a breaking boundary. It reports the jump and names the one command
4
+ // that takes it on purpose.
5
+
6
+ /** set by `shipkit fleet --update --major` on the repo run it spawns */
7
+ export const ALLOW_MAJOR_ENV = 'SHIPKIT_ALLOW_MAJOR'
8
+
9
+ export const allowMajor = (): boolean => process.env[ALLOW_MAJOR_ENV] === '1'
10
+
11
+ /** a jump across the boundary npm's caret draws: a changed major, or on a
12
+ * `0.x` package a changed minor (`^0.2.0` means `<0.3.0`). A porting job
13
+ * rather than a bump, so no automatic path takes it. */
14
+ export function isBreaking(installed: string, latest: string): boolean {
15
+ const parts = (v: string) => v.split('-')[0]!.split('.').map((n) => Number(n) || 0)
16
+ const [aMajor = 0, aMinor = 0] = parts(installed)
17
+ const [bMajor = 0, bMinor = 0] = parts(latest)
18
+ if (aMajor !== bMajor) return true
19
+ return aMajor === 0 && aMinor !== bMinor
20
+ }
21
+
22
+ /** the WARN text for a jump no automatic path takes. `repo` is the directory
23
+ * name, which is what `fleet --only` matches */
24
+ export const breakingAdvice = (name: string, latest: string, repo: string): string =>
25
+ `${name} ${latest} available (breaking): run \`shipkit fleet --update --major --only ${repo}\``
@@ -8,10 +8,12 @@
8
8
  //
9
9
  // This module DISCOVERS and REPORTS. It never updates: rule SK005 owns the
10
10
  // update, with the lease, the dirty-tree guard and the frozen guard that took
11
- // three bugs to get right. `--update` runs each repo's own `shipkit check`,
12
- // so the fix travels through that one code path.
11
+ // three bugs to get right. `--update` (update-fleet.ts) runs each repo's own
12
+ // `shipkit check`, so the fix travels through that one code path.
13
13
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
14
14
  import { join } from 'path'
15
+ import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
16
+ import { isBreaking } from '../core/update-policy.ts'
15
17
 
16
18
  export type Pkg = { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
17
19
 
@@ -25,18 +27,6 @@ export type FleetDep = {
25
27
  latest: string | null
26
28
  }
27
29
 
28
- /** a jump that changes the MAJOR, which is a porting job rather than a bump.
29
- *
30
- * Deliberately NOT the semver rule. On a `0.x` package npm treats every
31
- * minor as breaking, and by that rule every repo here is breaking, including
32
- * the `0.24 → 0.35` shipkit jumps that rule SK005 already performs by itself
33
- * every day. A flag that fires on everything is a flag nobody reads. What
34
- * survives is the case a human has to own: `rvlib-pb-mobx 0.2.0 → 2.0.0`. */
35
- export function isBreaking(installed: string, latest: string): boolean {
36
- const major = (v: string) => Number(v.split('-')[0]!.split('.')[0]) || 0
37
- return major(installed) !== major(latest)
38
- }
39
-
40
30
  export type FleetRepo = {
41
31
  name: string
42
32
  dir: string
@@ -61,7 +51,7 @@ const readPkg = (dir: string): Pkg | null => {
61
51
  }
62
52
  }
63
53
 
64
- const installedVersion = (dir: string, name: string): string | null => readPkg(join(dir, 'node_modules', name))?.version ?? null
54
+ export const installedVersion = (dir: string, name: string): string | null => readPkg(join(dir, 'node_modules', name))?.version ?? null
65
55
 
66
56
  /** semver-ish compare, enough to answer "is this one behind that one". A
67
57
  * prerelease tag sorts before its release, which is the safe direction: it
@@ -140,3 +130,18 @@ export async function npmLatest(name: string): Promise<string | null> {
140
130
  if (!res.ok) return null
141
131
  return ((await res.json()) as Pkg).version ?? null
142
132
  }
133
+
134
+ /** the first release whose SK005 updates through the manager the repo's lock
135
+ * files name. An older one runs bun whatever the lock says */
136
+ export const PM_AWARE_SINCE = '1.0.0'
137
+
138
+ /** why `fleet --update` must not hand this repo its own update, or null.
139
+ * The fleet runs the REPO's shipkit, so what matters is what THAT binary
140
+ * does: one older than PM_AWARE_SINCE updates an npm repo with bun */
141
+ export function updateRefusal(r: FleetRepo): string | null {
142
+ const pm = detectPackageManager(r.dir)
143
+ if (isRefusal(pm)) return `cannot tell which package manager installs it: ${pm.refused}`
144
+ const own = r.deps.find((d) => d.name === 'rvlib-shipkit')?.installed
145
+ if (pm.name === 'bun' || own == null || !isBehind(own, PM_AWARE_SINCE)) return null
146
+ return `its shipkit ${own} updates with bun whatever the lock says, and it installs with ${pm.name}. Run: cd ${r.dir} && ${pm.update(r.behind.map((d) => d.name)).join(' ')}`
147
+ }
@@ -0,0 +1,85 @@
1
+ // `shipkit fleet --update`: hand each behind repo its own update. The update
2
+ // is NOT performed here. Each repo runs its own binary, which is the only copy
3
+ // whose SK005 matches the rules that repo is pinned to, and the only one
4
+ // holding that repo's mutate lease.
5
+ import { existsSync } from 'fs'
6
+ import { join } from 'path'
7
+ import { spawnWithTimeout } from '../core/spawn-timeout.ts'
8
+ import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
9
+ import { ALLOW_MAJOR_ENV, breakingAdvice } from '../core/update-policy.ts'
10
+ import { installedVersion, isBehind, updateRefusal, type FleetRepo } from './scan-fleet.ts'
11
+
12
+
13
+ export const short = (name: string): string => name.replace(/^rvlib-/, '')
14
+
15
+ // the hand-run fallback speaks the manager THAT repo installs with: telling
16
+ // an npm consumer to run bun leaves its package-lock.json behind
17
+ const manualUpdate = (r: FleetRepo): string => {
18
+ const pm = detectPackageManager(r.dir)
19
+ return isRefusal(pm) ? `<its package manager> update ${r.behind.map((d) => d.name).join(' ')} (${pm.refused})` : pm.update(r.behind.map((d) => d.name)).join(' ')
20
+ }
21
+
22
+ export type FleetUpdateOpts = {
23
+ /** cross a breaking boundary too (`--major`) */
24
+ major: boolean
25
+ print: (line: string) => void
26
+ /** per repo run (default 10 minutes: a fresh health pass probes the world) */
27
+ timeoutMs?: number
28
+ }
29
+
30
+ /** returns how many repos did not update. A repo whose gate refused (dirty
31
+ * tree, frozen, a lease held elsewhere) is a REPORT, not a failure of the
32
+ * command: SK005 defers on purpose */
33
+ export async function updateFleet(behind: FleetRepo[], o: FleetUpdateOpts): Promise<number> {
34
+ const w = o.print
35
+ let failed = 0
36
+ for (const r of behind) {
37
+ // the same policy SK005 applies inside the repo: a breaking jump is reported,
38
+ // never taken, and a safe jump beside it still moves
39
+ const held = o.major ? [] : r.breaking
40
+ for (const d of held) w(`WARN ${r.name}: ${breakingAdvice(d.name, d.latest!, r.name)}`)
41
+ const wanted = r.behind.filter((d) => !held.includes(d))
42
+ if (wanted.length === 0) {
43
+ failed++
44
+ continue
45
+ }
46
+ const refused = updateRefusal(r)
47
+ if (refused != null) {
48
+ w(`WARN ${r.name}: not updating: ${refused}`)
49
+ failed++
50
+ continue
51
+ }
52
+ const cli = join(r.dir, 'node_modules', 'rvlib-shipkit', 'src', 'cli.ts')
53
+ if (!existsSync(cli)) {
54
+ w(`WARN ${r.name}: no shipkit installed, so it cannot update itself — run: cd ${r.dir} && ${manualUpdate(r)}`)
55
+ failed++
56
+ continue
57
+ }
58
+ // `--fresh` because SK005 caches a clean pass for 24h: a plain `check`
59
+ // replays the answer it gave before this release existed, and the run
60
+ // meant to update never asks the registry. `check --fix SK005` is no
61
+ // way around it: SK005 has no autofix, so the binary refuses the id.
62
+ // The re-exec guard is dropped so the repo's own startup self-update
63
+ // can still fire, and --major travels as the one env the policy reads.
64
+ const env: Record<string, string> = Object.fromEntries(Object.entries(process.env).filter((e): e is [string, string] => e[1] != null && e[0] !== 'SHIPKIT_SELF_UPDATED' && e[0] !== ALLOW_MAJOR_ENV))
65
+ if (o.major) env[ALLOW_MAJOR_ENV] = '1'
66
+ const res = await spawnWithTimeout(['bun', cli, 'check', '--fresh'], { cwd: r.dir, env, timeoutMs: o.timeoutMs ?? 600_000 })
67
+ // judge by what node_modules carries now, never by the exit code or a
68
+ // printed line: the code covers every rule in that repo, and the line
69
+ // format belongs to whatever version that repo runs
70
+ const stuck = wanted.filter((d) => {
71
+ const now = installedVersion(r.dir, d.name)
72
+ return now == null || isBehind(now, d.latest!)
73
+ })
74
+ if (stuck.length === 0) {
75
+ w(`ok: ${r.name} — ${wanted.map((d) => `${short(d.name)} ${d.installed} → ${installedVersion(r.dir, d.name)}`).join(', ')}`)
76
+ continue
77
+ }
78
+ failed++
79
+ const said = res.out.split('\n').find((l) => /\brvlib-/.test(l) && (l.startsWith('WARN') || l.startsWith('FAIL')))
80
+ w(`WARN ${r.name} did not move ${stuck.map((d) => short(d.name)).join(', ')} — ${said ?? `check exited ${res.code}${res.timedOut ? ', timed out' : ''} with no rvlib line`}`)
81
+ w(` fix: cd ${r.dir} && ${manualUpdate(r)}`)
82
+ }
83
+ w(failed === 0 ? `ok: ${behind.length} repo(s) updated through their own gate` : `WARN ${failed}/${behind.length} repo(s) did not update — each line above says why`)
84
+ return failed
85
+ }
@@ -80,6 +80,15 @@ export type ParsedPatterns = {
80
80
 
81
81
  const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
82
82
 
83
+ /** a key shape is a token, so it starts where a token starts: `ask-the-...`
84
+ * or `desk-lamp-...` is a word that ends in `sk`, never a key */
85
+ export const SK_KEY_ROW = 're:(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{20,}'
86
+
87
+ /** rows the hooks seeded before their shape was fixed, read as the fixed
88
+ * shape. Their id stays the seeded row, so a waiver written against its
89
+ * fingerprint keeps working */
90
+ const SEEDED_ROW_FIXES: Record<string, string> = { 're:sk-[A-Za-z0-9_-]{20,}': SK_KEY_ROW }
91
+
83
92
  /** Parse the keyword file: blank lines and `#` comments dropped, `re:<pattern>`
84
93
  * rows become case-insensitive regexes, `w:<word>` rows match on word
85
94
  * boundaries, everything else a case-insensitive substring.
@@ -95,7 +104,7 @@ export function parsePatterns(text: string): ParsedPatterns {
95
104
  const row = raw.trim()
96
105
  if (row === '' || row.startsWith('#')) continue
97
106
  if (row.startsWith('re:')) {
98
- try { regexes.push({ row, re: new RegExp(row.slice(3), 'i') }) }
107
+ try { regexes.push({ row, re: new RegExp((SEEDED_ROW_FIXES[row] ?? row).slice(3), 'i') }) }
99
108
  catch { /* a malformed row must not disable the whole guard */ }
100
109
  } else if (row.startsWith('w:')) {
101
110
  const word = row.slice(2).trim().toLowerCase()
@@ -8,7 +8,7 @@ import { dirname, join } from 'path'
8
8
  import { fileURLToPath } from 'url'
9
9
  import { KEYWORD_FILE, LAYOUT } from '../core/layout.ts'
10
10
  import { commitMessageOffenses } from './commit-message.ts'
11
- import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
11
+ import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, SK_KEY_ROW, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
12
12
  import { runRootIdentity } from './root-identity.ts'
13
13
  import { gitRoot } from '../core/git-root.ts'
14
14
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
@@ -143,14 +143,14 @@ export function installHooks(root: string, binInvocation: string, keywordFile?:
143
143
  // generic secret shapes only. private names live in the machine-global
144
144
  // ~/.shipkit/private/banned-keywords.txt, which the guard reads on every run
145
145
  // (never copied into a repo: a copy rots and a copy can leak)
146
- const KEYWORD_STARTER = `# banned-keywords — the leak guard greps every commit (added lines, paths,
146
+ export const KEYWORD_STARTER = `# banned-keywords — the leak guard greps every commit (added lines, paths,
147
147
  # message) for these rows. one row = one case-insensitive substring, or
148
148
  # re:<regex>. add repo-specific names here; machine-wide private names go in
149
149
  # ~/.shipkit/private/banned-keywords.txt, read automatically alongside this file.
150
150
  # this file is gitignored on purpose: the list itself is private.
151
151
  re:-----BEGIN [A-Z ]*PRIVATE KEY-----
152
152
  re:AKIA[0-9A-Z]{16}
153
- re:sk-[A-Za-z0-9_-]{20,}
153
+ ${SK_KEY_ROW}
154
154
  re:ghp_[A-Za-z0-9]{36}
155
155
  re:xox[baprs]-[A-Za-z0-9-]{10,}
156
156
  `
@@ -22,6 +22,7 @@ import { discoverChecks } from '../core/discover-checks.ts'
22
22
  import { checkCacheExtra, describeFirst, type TaskDef, type CheckFrom } from '../check.ts'
23
23
  import { CHECK_TIMEOUT_MS, type RunnableCheck, type CheckBody } from '../core/check-runner.ts'
24
24
  import { parseDuration } from '../core/wire.ts'
25
+ import { budgetOf } from '../core/budget.ts'
25
26
  import { whenOfLegacyCheck, whenOfDisabledSlot, whenOfRule, whenOfTask, whenOfCheck, type WhenDecl } from '../core/when.ts'
26
27
  import type { CheckDecl } from './git-key.ts'
27
28
  import { populationOf } from '../problems/problem-line.ts'
@@ -34,9 +35,9 @@ import { declaredTestUnits, inputsOutsideUnits, isTestFile } from '../suite/test
34
35
  /** why an undeclared `run` check cannot be keyed on its reads, or null when it
35
36
  * can. A built-in is walked inside the toolkit, a repo check inside the repo
36
37
  * with the toolkit left out (the toolchain id covers it) */
37
- function rawAccessOf(root: string, u: DiscoveredCheck, configs: Map<string, RvtConfig>): string | null {
38
+ function rawAccessOf(root: string, u: DiscoveredCheck, configs: Map<string, RvtConfig>, texts: Map<string, string>): string | null {
38
39
  try {
39
- const r = u.rel == null ? rawAccessInClosure(LIB_ROOT, u.path, { configs }) : rawAccessInClosure(root, u.path, { skip: LIB_ROOT, configs })
40
+ const r = u.rel == null ? rawAccessInClosure(LIB_ROOT, u.path, { configs, texts }) : rawAccessInClosure(root, u.path, { skip: LIB_ROOT, configs, texts })
40
41
  return r == null ? null : rawAccessLine(r)
41
42
  } catch (e) {
42
43
  return `the modules ${u.rel ?? u.path} imports could not be listed (${String(e).slice(0, 120)}): declare inputs or never`
@@ -103,6 +104,9 @@ export type CheckRow = {
103
104
  /** the wall clock ceiling it DECLARED, null when it named none and takes
104
105
  * the default for its kind of body */
105
106
  timeoutMs: number | null
107
+ /** what a healthy run takes, RESOLVED: the repo's `budgets`, else what
108
+ * the check declared, else the default for its kind (core/budget.ts) */
109
+ budgetMs: number
106
110
  /** excluded from a default run unless named with `--with` */
107
111
  optIn: boolean
108
112
  /** an idempotent repair exists: `shipkit check --fix <id>` applies it,
@@ -150,7 +154,7 @@ const ttlMsOf = (ttl: string | undefined): number => (ttl == null || ttl === ''
150
154
 
151
155
  /** the runnable half of a row: everything the runner needs, and nothing it
152
156
  * has to look up again */
153
- function runnableOf(row: CheckRow, body: CheckBody, o: { fix?: unknown; pointer?: string | null } = {}): RunnableCheck {
157
+ function runnableOf(row: CheckRow, body: CheckBody, o: { fix?: unknown; pointer?: string | null; unbudgeted?: boolean } = {}): RunnableCheck {
154
158
  return {
155
159
  id: row.decl.id,
156
160
  title: row.title,
@@ -161,6 +165,7 @@ function runnableOf(row: CheckRow, body: CheckBody, o: { fix?: unknown; pointer?
161
165
  deps: row.deps ?? [],
162
166
  group: row.group,
163
167
  timeoutMs: row.timeoutMs ?? (row.build ? BUILD_TIMEOUT_MS : CHECK_TIMEOUT_MS),
168
+ ...(o.unbudgeted === true ? {} : { budgetMs: row.budgetMs }),
164
169
  ttlMs: ttlMsOf(row.decl.ttl),
165
170
  rotation: row.decl.extra,
166
171
  writes: row.decl.writes === true,
@@ -251,6 +256,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
251
256
  owner: t.cmd == null ? null : t.cmd.join(' '),
252
257
  group: t.group ?? null,
253
258
  timeoutMs: t.timeoutMs ?? null,
259
+ budgetMs: budgetOf(t.id, undefined, { build: true, cmd: t.cmd != null, world: t.ttl != null }, c.budgets),
254
260
  optIn: t.optIn === true,
255
261
  // a build step runs a TOOL, and repairing what a tool reports is
256
262
  // that tool's own job (a formatter, a codemod), never the gate's
@@ -259,7 +265,10 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
259
265
  deps: t.deps ?? null,
260
266
  from: taskFrom(t.id),
261
267
  }
262
- out.push({ row, runnable: runnableOf(row, taskBody(t)) })
268
+ // at the full tier a build step runs every slow suite on purpose, and
269
+ // its env (and so its cache key) differs from the quick run, so its
270
+ // time is not judged and its answer never stands in for a quick one
271
+ out.push({ row, runnable: runnableOf(row, taskBody(t), { unbudgeted: full }) })
263
272
  }
264
273
  // a slot `defaults: { <id>: false }` turned off never resolves to a task,
265
274
  // so without this it had no row at all: no `runs in` value, no reason, and
@@ -277,6 +286,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
277
286
  owner: null,
278
287
  group: null,
279
288
  timeoutMs: null,
289
+ budgetMs: budgetOf(id, undefined, { build: true, cmd: false, world: false }, c.budgets),
280
290
  optIn: false,
281
291
  fixable: false,
282
292
  when: whenOfDisabledSlot(id),
@@ -290,6 +300,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
290
300
  }
291
301
  // every `*.check.ts` this repo and the toolkit declare
292
302
  const configs = new Map<string, RvtConfig>()
303
+ const texts = new Map<string, string>()
293
304
  for (const u of checks) {
294
305
  const w = whenOfCheck(c, u)
295
306
  // an in process body declaring nothing is keyed on what it reads
@@ -299,7 +310,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
299
310
  const undeclared = u.def.run != null && u.def.inputs == null && u.def.ttl == null && u.def.never == null
300
311
  // conservative: any module its body can reach that touches the disk or
301
312
  // a process around `c` keeps it off its reads (core/raw-access.ts)
302
- const raw = undeclared ? rawAccessOf(root, u, configs) : null
313
+ const raw = undeclared ? rawAccessOf(root, u, configs, texts) : null
303
314
  const reads = undeclared && raw == null
304
315
  const extra = checkCacheExtra({ id: u.id, cacheKey: u.def.cacheKey }, env, w)
305
316
  const row: CheckRow = {
@@ -323,6 +334,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
323
334
  owner: u.owner,
324
335
  group: u.def.group ?? null,
325
336
  timeoutMs: u.def.timeoutMs ?? null,
337
+ budgetMs: budgetOf(u.id, u.def.budgetMs, { build: u.def.build === true, cmd: u.def.cmd != null, world: u.def.ttl != null }, c.budgets),
326
338
  optIn: false,
327
339
  fixable: u.def.fix != null,
328
340
  when: w,
@@ -358,6 +370,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
358
370
  owner: r.owner,
359
371
  group: null,
360
372
  timeoutMs: null,
373
+ budgetMs: budgetOf(r.id, undefined, { build: false, cmd: false, world: r.ttl != null }, c.budgets),
361
374
  optIn: false,
362
375
  fixable: r.fix != null,
363
376
  when: whenOfRule(c, r.id),
@@ -401,6 +414,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
401
414
  owner: rel,
402
415
  group: task?.group ?? null,
403
416
  timeoutMs: task?.timeoutMs ?? null,
417
+ budgetMs: budgetOf(d.name, undefined, { build: false, cmd: true, world: d.ttl != null }, c.budgets),
404
418
  optIn: false,
405
419
  // the reader is a source scanner, so it cannot carry a function
406
420
  fixable: false,
@@ -173,6 +173,9 @@ export type KeyLayerOpts = {
173
173
  cliEntry: string
174
174
  /** module-graph config; loaded from the repo when omitted */
175
175
  cfg?: RvtConfig
176
+ /** the checks this run executes. Only they are scanned and hashed, and any
177
+ * other id gets no key. Omitted = every check */
178
+ scope?: ReadonlySet<string>
176
179
  }
177
180
 
178
181
  /**
@@ -191,6 +194,7 @@ export class KeyLayer {
191
194
  private readonly codeOids: Map<string, string>,
192
195
  private readonly toolchain: { id: string; uncacheable: string[] },
193
196
  private readonly scanPathsUsed: string[],
197
+ private readonly scope: ReadonlySet<string> | null,
194
198
  /** wall time of the WHOLE layer: toolchain identity, the module-graph
195
199
  * closures, the git scan and the closure hashing. Reported rather
196
200
  * than the scan alone, because the scan alone is the flattering
@@ -237,7 +241,8 @@ export class KeyLayer {
237
241
  const closureByCheck = new Map<string, string[]>()
238
242
  // computed for EVERY check with code, not only the content ones: a
239
243
  // world check's TTL cache must break when the probe itself is rewritten
240
- for (const d of decls) {
244
+ const scoped = o.scope == null ? decls : decls.filter((d) => o.scope!.has(d.id))
245
+ for (const d of scoped) {
241
246
  const entries = d.code ?? []
242
247
  if (entries.length > 0) closureByCheck.set(d.id, importClosure(cfg, entries))
243
248
  }
@@ -246,14 +251,13 @@ export class KeyLayer {
246
251
  // are mostly directories, mostly unchanged, and git's stat cache
247
252
  // answers them without reading a byte.
248
253
  const paths = new Set<string>()
249
- for (const d of decls) {
254
+ for (const d of scoped) {
250
255
  if (classify(d).population !== 'content') continue
251
256
  for (const p of d.inputs ?? []) paths.add(p)
252
257
  }
253
258
 
254
259
  // the closure is INDIVIDUAL FILES belonging to no declared path, so it
255
- // goes through one `hash-object --stdin-paths` instead of one `git
256
- // add` spawn per file (measured: 15ms for the whole set)
260
+ // is hashed in process (hashFiles) instead of staged one `git add` each
257
261
  const closureFiles = new Set<string>()
258
262
  for (const files of closureByCheck.values()) for (const f of files) closureFiles.add(f)
259
263
 
@@ -268,7 +272,7 @@ export class KeyLayer {
268
272
  // downstream may key on it. Loud, and the whole run goes uncached.
269
273
  scanError = e instanceof GitScanError ? e.message : String(e)
270
274
  }
271
- return new KeyLayer(root, decls, registration, scan, scanError, closureByCheck, codeOids, toolchain, [...paths].sort(), performance.now() - started)
275
+ return new KeyLayer(root, decls, registration, scan, scanError, closureByCheck, codeOids, toolchain, [...paths].sort(), o.scope ?? null, performance.now() - started)
272
276
  }
273
277
 
274
278
  /** the key for one check, or the reason there is none. */
@@ -284,6 +288,7 @@ export class KeyLayer {
284
288
  codeKey: own.length === 0 || !ownReadable ? null : createHash('sha256').update(codeLines.join('\n')).digest('hex').slice(0, 16),
285
289
  }
286
290
  if (c.error != null) return { ...base, reason: c.error }
291
+ if (this.scope != null && !this.scope.has(d.id)) return { ...base, reason: 'not one of the checks this run executes, so nothing was scanned for it' }
287
292
  if (c.population === 'world') return { ...base, reason: `world check — TTL ${d.ttl}, no content key` }
288
293
  if (c.population === 'none') return { ...base, reason: c.reason ?? 'undeclared' }
289
294
  if (this.scanError != null) return { ...base, reason: `git scan failed, nothing may be keyed: ${this.scanError}` }