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
@@ -99,7 +99,7 @@ const SUITE_CLI = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/suite/sui
99
99
  * bunfig, its preload and its `units[].tests` budget apply, after the checks
100
100
  * its `deps` name. Its inputs are its dir and the dirs of the
101
101
  * sibling units its package.json names, derived rather than declared */
102
- function unitTestTasks(c: RepoConfig, root: string, io: FloorReader, subs: TestUnitDecl[], siblings: Map<string, string[]>, notes: string[], unfilled: { id: string; reason: string }[]): TaskDef[] {
102
+ function unitTestTasks(root: string, io: FloorReader, subs: TestUnitDecl[], siblings: Map<string, string[]>, notes: string[], unfilled: { id: string; reason: string }[]): TaskDef[] {
103
103
  const read = io.text
104
104
  const out: TaskDef[] = []
105
105
  for (const u of subs) {
@@ -258,7 +258,7 @@ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): Floo
258
258
  else { tasks.push(decorate(b.task)); sources[id] = b.source }
259
259
  }
260
260
  const siblings = siblingUnits(subs, io.text)
261
- for (const t of unitTestTasks(c, root, io, subs, siblings, notes, unfilled)) { tasks.push(t); sources[t.id] = 'unit' }
261
+ for (const t of unitTestTasks(root, io, subs, siblings, notes, unfilled)) { tasks.push(t); sources[t.id] = 'unit' }
262
262
  for (const t of unitTypecheckTasks(root, io, subs, siblings, unfilled)) { tasks.push(t); sources[t.id] = 'unit' }
263
263
  return { tasks, disabled, unfilled, sources, notes }
264
264
  }
@@ -2,9 +2,10 @@
2
2
  // root and its prefix inside it. One rev-parse answers all four, and the answer
3
3
  // is remembered per directory, because one gate asks it from a dozen places
4
4
  // (store, run record, scan, nesting, three rules) and each ask is a process
5
- // spawn. A directory with no `.git` in reach is answered without asking. Every remembered answer is re-validated with a few stats on each hit,
5
+ // spawn. A directory with no `.git` in reach is answered without asking, and
6
+ // so is a plain repository root (plainRoot). Every remembered answer is re-validated with a few stats on each hit,
6
7
  // so a `git init` or a deleted repo is seen at once in a long-lived process.
7
- import { existsSync, realpathSync, statSync } from 'fs'
8
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'fs'
8
9
  import { dirname, join, resolve } from 'path'
9
10
  import { gitBin } from './git-bin.ts'
10
11
 
@@ -78,8 +79,31 @@ function nothingInReach(dir: string): boolean {
78
79
  }
79
80
  }
80
81
 
82
+ /** the common case answered from the disk: `.git` is a plain directory right
83
+ * at `dir`, which git describes as exactly that. Anything unusual (a locating
84
+ * variable, a gitfile or a symlinked `.git`, a shared common dir, a config
85
+ * that relocates the work tree, marks it bare or includes another file, a
86
+ * `.git` owned by someone else) is left to git */
87
+ function plainRoot(dir: string): GitRoot | null {
88
+ if (LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')) return null
89
+ try {
90
+ const top = realpathSync(dir)
91
+ const gitDir = join(top, '.git')
92
+ const st = lstatSync(gitDir)
93
+ if (!st.isDirectory() || (process.getuid != null && st.uid !== process.getuid())) return null
94
+ if (!existsSync(join(gitDir, 'HEAD')) || existsSync(join(gitDir, 'commondir'))) return null
95
+ const cfg = readFileSync(join(gitDir, 'config'), 'utf8')
96
+ if (/^\s*(worktree\s*=|bare\s*=\s*true|\[include)/im.test(cfg)) return null
97
+ return { gitDir, commonDir: gitDir, top, prefix: '' }
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
81
103
  function ask(dir: string): Probe {
82
104
  if (nothingInReach(dir)) return { root: null, error: 'fatal: not a git repository (or any of the parent directories): .git' }
105
+ const plain = plainRoot(dir)
106
+ if (plain != null) return { root: plain, error: '' }
83
107
  let p: ReturnType<typeof Bun.spawnSync>
84
108
  try {
85
109
  p = Bun.spawnSync([gitBin(), 'rev-parse', '--path-format=absolute', '--absolute-git-dir', '--git-common-dir', '--show-toplevel', '--show-prefix'], {
@@ -0,0 +1,16 @@
1
+ // the version of this lib: what the running process booted with, and what
2
+ // the package.json on disk says now. The boot version is a MODULE import on
3
+ // purpose: `bun --watch` follows the module graph only, so a version bump
4
+ // (which touches nothing but package.json) restarts a watched hub. Read with
5
+ // fs, the file sits outside the graph and a watched hub never learns of it.
6
+ import { readFileSync } from 'fs'
7
+ import pkg from '../../package.json' with { type: 'json' }
8
+
9
+ /** the version this process booted with */
10
+ export const LIB_VERSION: string = pkg.version
11
+
12
+ /** what package.json says at this moment, `?` when it is unreadable. Only a
13
+ * process NOT under a watch can see it differ from LIB_VERSION */
14
+ export const diskLibVersion = (): string => {
15
+ try { return (JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
16
+ }
@@ -34,7 +34,20 @@ import { nestedIn } from './nesting.ts'
34
34
  /** the prefix every contract folder carried before `.shipkit/` */
35
35
  export const LEGACY_CONTRACT_PREFIX = '.rv-'
36
36
 
37
- export const MIGRATION_MESSAGE = 'chore(machine): move the shipkit folders under .shipkit/'
37
+ const CONFIG_FILE = 'repo.config.ts'
38
+
39
+ /** what one pass did, in words: the folders it moved and the root files it
40
+ * dropped. Empty when it did neither, so no caller prints a sentence with a
41
+ * hole where the list should be */
42
+ export function layoutSummary(r: { moved: LayoutMove[]; removed: string[] }): string {
43
+ const list = (xs: string[]): string => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`)
44
+ const folders = r.moved.filter((m) => m.kind !== 'cache').map((m) => `${m.from}/`)
45
+ const parts = [
46
+ ...(folders.length > 0 ? [`move ${list(folders)} under ${SHIPKIT_DIR}/`] : []),
47
+ ...(r.removed.length > 0 ? [`drop the root ${list(r.removed)}, which shipkit writes under ${SHIPKIT_DIR}/`] : []),
48
+ ]
49
+ return parts.join(', and ')
50
+ }
38
51
 
39
52
  /** the contract names a typo is matched against */
40
53
  const KNOWN = ['identity', 'assets', 'comfy', 'private', 'reflections', 'social', 'journal', 'domain', 'itch', 'steam', 'discord'] as const
@@ -200,6 +213,39 @@ export function rebaseRelative(text: string, at: { from: string; to: string }, m
200
213
  .replace(/(\]\()(\.\.?\/[^)\s#]+)/g, (_m, lead: string, spec: string) => `${lead}${rebase(spec)}`)
201
214
  }
202
215
 
216
+ /** one repo.config.ts value that names a moved folder, as a path: the folder
217
+ * then a slash, behind an optional `!`, `/` or `./`. A word, a bare folder
218
+ * name and a longer name that merely starts the same stay as they are */
219
+ function rebasePathValue(v: string, moves: LayoutMove[]): string {
220
+ const m = /^(!?(?:\/|\.\/)?)(.*)$/s.exec(v)!
221
+ const rest = m[2]!
222
+ const hit = moves.find((x) => rest.startsWith(`${x.from}/`))
223
+ return hit == null ? v : `${m[1]}${hit.to}${rest.slice(hit.from.length)}`
224
+ }
225
+
226
+ /** PURE: repo.config.ts source with every quoted path that names a moved
227
+ * folder rewritten (`leakAllow` paths, `blockedPaths`, `gitignore` lines, a
228
+ * prose include, any other value): each of them is a repo path by contract */
229
+ export function sweepConfigSource(text: string, moves: LayoutMove[]): string {
230
+ if (moves.length === 0) return text
231
+ return text.replace(/(['"`])((?:\\.|(?!\1)[^\\\n])*)\1/g, (lit, q: string, body: string) => {
232
+ if (q === '`' && body.includes('${')) return lit
233
+ const next = rebasePathValue(body, moves)
234
+ return next === body ? lit : `${q}${next}${q}`
235
+ })
236
+ }
237
+
238
+ /** PURE: the loaded config with the same rewrite, so what this pass renders
239
+ * from it (the ignore block) reads the values the file now holds. Only plain
240
+ * objects and arrays are copied; functions and class instances are kept */
241
+ export function rebaseConfigPaths<T>(v: T, moves: LayoutMove[]): T {
242
+ if (typeof v === 'string') return rebasePathValue(v, moves) as T
243
+ if (Array.isArray(v)) return v.map((x) => rebaseConfigPaths(x, moves)) as T
244
+ if (v != null && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype)
245
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, rebaseConfigPaths(x, moves)])) as T
246
+ return v
247
+ }
248
+
203
249
  /** PURE: the .gitignore after the move. Old names turn into new ones, except
204
250
  * the folders still on disk (`keep`), whose lines keep guarding them. A
205
251
  * negation of one FILE inside a folder (`!.shipkit/social/README.md`) is dead
@@ -327,9 +373,10 @@ export function legacyHooksOurs(root: string): boolean {
327
373
  * still has core.hooksPath on the vanished `hooks/`, and git runs no hook
328
374
  * from a folder that is not there: the commit gates go dark in silence */
329
375
  function repointStaleHooks(root: string): void {
376
+ // the disk decides first: git is asked only when the move has happened
377
+ if (existsSync(join(root, 'hooks')) || !existsSync(join(root, LAYOUT.hooks, 'pre-commit'))) return
330
378
  const v = git(root, ['config', 'core.hooksPath']).out.trim().replace(/\/+$/, '')
331
379
  if (v !== 'hooks' && v !== join(root, 'hooks')) return
332
- if (existsSync(join(root, 'hooks')) || !existsSync(join(root, LAYOUT.hooks, 'pre-commit'))) return
333
380
  git(root, ['config', 'core.hooksPath', LAYOUT.hooks])
334
381
  }
335
382
 
@@ -400,8 +447,10 @@ function inProgress(root: string): string | null {
400
447
  export type MigrateOpts = {
401
448
  config?: RepoConfig
402
449
  /** re-render the SK004 block into this .gitignore text (SK004 owns the
403
- * block). `legacy`: an old folder is still on disk, so its name stays ignored */
404
- renderGitignore?: (text: string, legacy: boolean) => string
450
+ * block). `legacy`: an old folder is still on disk, so its name stays
451
+ * ignored. `config`: the repo config with its paths already moved, which
452
+ * is what the block must be rendered from */
453
+ renderGitignore?: (text: string, legacy: boolean, config?: RepoConfig) => string
405
454
  }
406
455
 
407
456
  /** tracked symlinks, with where each points */
@@ -442,8 +491,13 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
442
491
  const targets = Object.fromEntries(plan.moves.filter((m) => m.kind === 'contract').map((m) => [m.from, m.to]))
443
492
  const sources = plan.moves.filter((m) => m.kind !== 'cache').map((m) => m.from)
444
493
  const tracked = inGit ? lsFiles(root, [...sources, ...legacyStatus]) : []
445
- // a moved file is rebased whether or not it names an old folder
446
- const swept = inGit ? [...new Set([...sweepTargets(root, contractNames), ...tracked.filter((t) => /\.(m?[jt]sx?|json|md)$/.test(t))])] : []
494
+ // a moved file is rebased whether or not it names an old folder, and
495
+ // repo.config.ts whenever one of its values names one
496
+ const configNames = inGit && lsFiles(root, [CONFIG_FILE]).length > 0 && sweepConfigSource(readFileSync(join(root, CONFIG_FILE), 'utf8'), plan.moves) !== readFileSync(join(root, CONFIG_FILE), 'utf8') ? [CONFIG_FILE] : []
497
+ // a legacy generated file is dropped, never rewritten: a rewrite would
498
+ // write it back after the drop, and the commit would keep it
499
+ const dropped = new Set<string>(legacyStatus)
500
+ const swept = inGit ? [...new Set([...sweepTargets(root, contractNames), ...tracked.filter((t) => /\.(m?[jt]sx?|json|md)$/.test(t)), ...configNames])].filter((f) => !dropped.has(f)) : []
447
501
  const links = inGit ? trackedLinks(root) : []
448
502
  // commit the ignore file when git tracks it, or when this pass creates it:
449
503
  // it is what keeps the moved private folders out. An existing untracked
@@ -464,7 +518,8 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
464
518
  const text = readFileSync(join(root, rel), 'utf8')
465
519
  if (text.includes(SWEEP_SKIP)) continue
466
520
  const now = movedTo(moves, rel)
467
- const next = sweepText(rebaseRelative(text, { from: rel, to: now }, moves), targets, { from: rel, to: now })
521
+ const paths = rel === CONFIG_FILE ? sweepConfigSource(text, moves) : text
522
+ const next = sweepText(rebaseRelative(paths, { from: rel, to: now }, moves), targets, { from: rel, to: now })
468
523
  if (next !== text) writes.push({ now, text: next })
469
524
  }
470
525
  const relinks: { now: string; target: string }[] = []
@@ -486,7 +541,8 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
486
541
 
487
542
  const gi = join(root, '.gitignore')
488
543
  const giOriginal = existsSync(gi) ? readFileSync(gi, 'utf8') : ''
489
- const render = (text: string, legacy: boolean): string => (o.renderGitignore != null ? o.renderGitignore(text, legacy) : text)
544
+ const config = o.config == null ? undefined : rebaseConfigPaths(o.config, moves)
545
+ const render = (text: string, legacy: boolean): string => (o.renderGitignore != null ? o.renderGitignore(text, legacy, config) : text)
490
546
  const stillOld = (): string[] => dirsIn(root).filter((d) => d.startsWith(LEGACY_CONTRACT_PREFIX))
491
547
  try {
492
548
  // 1. both layouts ignored while any folder is in flight
@@ -550,7 +606,11 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
550
606
  const giChanged = giNew || (giTracked && readFileSync(join(root, '.gitignore'), 'utf8') !== giOriginal)
551
607
  const touched = [...new Set([...tracked, ...added, ...sweptTracked, ...(giChanged ? ['.gitignore'] : [])])]
552
608
  if (touched.length === 0) return out
553
- const landed = machineCommit({ anchor: root, message: MIGRATION_MESSAGE, paths: touched.map((p) => join(root, p)) })
609
+ // the title names what this commit carries; a pass that did neither has
610
+ // nothing tracked to land and returned above
611
+ const what = layoutSummary(out)
612
+ if (what === '') return out
613
+ const landed = machineCommit({ anchor: root, message: `chore(machine): ${what}`, paths: touched.map((p) => join(root, p)) })
554
614
  if (!landed) return { ...out, uncommitted: true, pending: touched }
555
615
  out.commit = git(root, ['rev-parse', '--short', 'HEAD']).out.trim()
556
616
  return out
@@ -17,6 +17,7 @@ import type { RepoConfig } from '../repo-config.ts'
17
17
  import { atomicWrite, withFileLock } from './locked-write.ts'
18
18
  import { isLinkedWorktree } from './git-root.ts'
19
19
  import { machineCommit } from './machine-commit.ts'
20
+ import { LIB_VERSION } from './lib-version.ts'
20
21
 
21
22
  const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
22
23
  const END = '<!-- <<< shipkit local-projects -->'
@@ -53,9 +54,6 @@ const parseRow = (line: string): Row | null => {
53
54
  return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
54
55
  }
55
56
 
56
- function libVersion(): string {
57
- try { return (JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
58
- }
59
57
 
60
58
  // how this repo consumes the lib — `?` on an unreadable package.json (a
61
59
  // read failure must never be rendered as the fact 'path')
@@ -91,7 +89,7 @@ export function reportToMotherhome(root: string, config: RepoConfig, today: stri
91
89
  const mode = consumptionMode(root)
92
90
  // source-consumed code is not the published release its version
93
91
  // string names — disclose it
94
- const version = mode === 'npm' ? libVersion() : `${libVersion()}+src`
92
+ const version = mode === 'npm' ? LIB_VERSION : `${LIB_VERSION}+src`
95
93
  const me: Row = { name: config.name, root: resolve(root), version, mode, seen: today }
96
94
 
97
95
  const r = withFileLock(page, (): MotherhomeOutcome => {
@@ -7,9 +7,8 @@
7
7
  // TRACKED locks decide, because an untracked bun.lock is exactly what a wrong
8
8
  // update leaves behind, and reading it as the repo's choice would make that
9
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.
10
+ // to what sits on disk. No lock at all, or locks of two managers, is a
11
+ // REFUSAL that names what it saw: a guessed manager is the failure above.
13
12
  import { existsSync } from 'fs'
14
13
  import { join } from 'path'
15
14
  import { gitBin } from './git-bin.ts'
@@ -24,10 +23,11 @@ export type PackageManager = {
24
23
  install: string[]
25
24
  /** move these deps to their latest version, manifest AND lock in one call */
26
25
  update: (names: string[]) => string[]
27
- /** set when several managers own a lock here, so the pick is a guess */
28
- warn?: string
29
26
  }
30
27
 
28
+ /** the manager, or why the lock files do not name exactly one */
29
+ export type PackageManagerPick = PackageManager | { refused: string }
30
+
31
31
  const BUN_LOCKS = ['bun.lock', 'bun.lockb']
32
32
  export const LOCK_FILES = [...BUN_LOCKS, 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json']
33
33
 
@@ -70,17 +70,21 @@ export function trackedLocks(root: string): string[] {
70
70
  }
71
71
 
72
72
  /** the manager this repo installs with, read off its lock files */
73
- export function detectPackageManager(root: string, tracked: string[] = trackedLocks(root)): PackageManager {
73
+ export function detectPackageManager(root: string, tracked?: string[]): PackageManagerPick {
74
74
  const present = LOCK_FILES.filter((f) => existsSync(join(root, f)))
75
- const owned = present.filter((f) => tracked.includes(f))
75
+ // git is asked which locks it tracks only when there is a lock to ask about
76
+ const known = present.length === 0 ? [] : (tracked ?? trackedLocks(root))
77
+ const owned = present.filter((f) => known.includes(f))
76
78
  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` }
79
+ const managers = [
80
+ ...(locks.some((f) => BUN_LOCKS.includes(f)) ? [bun] : []),
81
+ ...(locks.includes('pnpm-lock.yaml') ? [pnpm] : []),
82
+ ...(locks.includes('yarn.lock') ? [yarn(existsSync(join(root, '.yarnrc.yml')))] : []),
83
+ ...(locks.includes('package-lock.json') ? [npm] : []),
84
+ ]
85
+ if (managers.length === 1) return managers[0]!
86
+ if (managers.length === 0) return { refused: `no lock file at the root (looked for ${LOCK_FILES.join(', ')}), so which package manager installs this repo is unknown: install once with the right one and commit its lock` }
87
+ return { refused: `lock files of ${managers.length} package managers ${owned.length > 0 ? 'are tracked' : 'sit at the root'} (${locks.join(', ')}), so which one installs this repo is unknown: delete the lock of the one it does not install with` }
86
88
  }
89
+
90
+ export const isRefusal = (p: PackageManagerPick): p is { refused: string } => 'refused' in p
@@ -72,17 +72,25 @@ function scanSource(src: string, o: ClosureKind): { line: number; what: string }
72
72
  * scanned, and neither is `skip`, the toolkit's own tree when it sits inside
73
73
  * the repo: the toolchain id covers it exactly as it covers an install.
74
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 {
75
+ export function rawAccessInClosure(root: string, file: string, o: { skip?: string; configs?: Map<string, RvtConfig>; texts?: Map<string, string> } & ClosureKind = {}): RawAccess | null {
76
76
  // one pass over many checks under one root hands in `configs`, so the
77
77
  // root's config (a walk of its source tree) is read once for all of them
78
78
  let loaded = o.configs?.get(root)
79
79
  if (loaded == null) { loaded = loadConfig(root); o.configs?.set(root, loaded) }
80
80
  const cfg = { ...loaded, root }
81
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))
82
+ // one pass over many checks hands in `texts` too: their closures share
83
+ // most of their files, and each is read once for the whole pass
84
+ const read = (rel: string): string => {
85
+ const p = join(root, rel)
86
+ let text = o.texts?.get(p)
87
+ if (text == null) { text = readFileSync(p, 'utf8'); o.texts?.set(p, text) }
88
+ return text
89
+ }
90
+ const files = importClosure(cfg, [relative(root, file).replace(/\\/g, '/')], { ...o, read }).filter((f) => !skipped(f))
83
91
  for (const f of files) {
84
92
  let src: string
85
- try { src = readFileSync(join(root, f), 'utf8') } catch { continue }
93
+ try { src = read(f) } catch { continue }
86
94
  const hit = rawAccessInSource(src, o)
87
95
  if (hit != null) return { file: f, ...hit }
88
96
  }
@@ -0,0 +1,26 @@
1
+ // run this exact invocation again, on whatever code is on disk now. execve
2
+ // replaces the image in place: same pid, same terminal, same argv (so the
3
+ // same flags and the same port), and a `--watch` flag stays in execArgv. A
4
+ // runtime without execve runs the invocation as a child and exits with its
5
+ // code, which keeps the terminal but leaves this process waiting under it.
6
+ import { existsSync } from 'fs'
7
+
8
+ const argvOf = (): string[] => [process.execPath, ...process.execArgv, ...process.argv.slice(1)]
9
+
10
+ /** why this invocation cannot run again, null when it can. Asked BEFORE a
11
+ * caller tears anything down, so a refusal leaves the process serving */
12
+ export function reexecBlocker(): string | null {
13
+ const entry = process.argv[1]
14
+ if (entry != null && entry !== '' && !entry.startsWith('-') && /\.[cm]?[jt]sx?$/.test(entry) && !existsSync(entry)) return `its entry file ${entry} is gone`
15
+ return null
16
+ }
17
+
18
+ /** never returns. `env` defaults to this process's own */
19
+ export function reexecSelf(env: Record<string, string | undefined> = process.env): never {
20
+ const argv = argvOf()
21
+ const clean = Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] != null))
22
+ const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
23
+ if (typeof execve === 'function') execve.call(process, process.execPath, argv, clean)
24
+ const child = Bun.spawnSync(argv, { cwd: process.cwd(), stdout: 'inherit', stderr: 'inherit', stdin: 'inherit', env: clean })
25
+ process.exit(child.exitCode ?? 1)
26
+ }
@@ -14,10 +14,11 @@ import { Ledger } from '../ledger/ledger.ts'
14
14
  import { RunLog, type InvokedBy } from '../ledger/run-log.ts'
15
15
  import { storeRoot } from '../ledger/store.ts'
16
16
  import { gateBinary } from '../ledger/toolchain-identity.ts'
17
- import { runChecks, type Audience, type CheckEvent, type CheckResult } from './check-runner.ts'
17
+ import { runChecks, selectChecks, type Audience, type CheckEvent, type CheckResult, type RunnableCheck } from './check-runner.ts'
18
18
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
19
19
  import { reportToMotherhome } from './motherhome.ts'
20
20
  import { unknownWhenIds } from './when.ts'
21
+ import { unknownBudgetIds } from './budget.ts'
21
22
  import { formatLine, type Line } from './wire.ts'
22
23
  import { writeStatus } from './write-status.ts'
23
24
  import { CHECK_TABLE_FILE, writeCheckTable } from '../ledger/check-table.ts'
@@ -106,12 +107,37 @@ export type CheckRun = {
106
107
  /** `when` entries naming no check of this repo — a toggle that does
107
108
  * nothing is worse than none, so it is fatal too */
108
109
  unknownWhen: string[]
110
+ /** `budgets` entries naming no check of this repo, fatal for the same reason */
111
+ unknownBudgets: string[]
109
112
  /** what every asked-for check did, in declaration order */
110
113
  results: CheckResult[]
111
114
  /** the run was interrupted or bailed out */
112
115
  interrupted: boolean
113
116
  }
114
117
 
118
+ /** what the runner selects from, built from the pass options in ONE place: the
119
+ * ledger is scoped on the same selection the runner then makes */
120
+ function selectionOf(checks: RunnableCheck[], o: CheckOpts, tier: TestTier) {
121
+ return {
122
+ checks,
123
+ audience: o.audience ?? 'health',
124
+ fullTier: tier === 'full',
125
+ skipGroups: o.skipGroups,
126
+ onlyIds: o.only,
127
+ withIds: o.withIds,
128
+ onlyBlocking: o.onlyBlocking,
129
+ buildOnly: o.buildOnly,
130
+ includeProactive: o.includeProactive,
131
+ exclude: o.exclude,
132
+ } satisfies Parameters<typeof selectChecks>[0]
133
+ }
134
+
135
+ /** the ids a pass with these options executes: the scope its ledger scans for */
136
+ export function passScope(composed: GateCheck[], o: CheckOpts): Set<string> {
137
+ const tier: TestTier = o.tier ?? envTier()
138
+ return new Set(selectChecks(selectionOf(composed.map((g) => g.runnable), o, tier)).selected.map((u) => u.id))
139
+ }
140
+
115
141
  /** THE run body: compose this repo's checks, run the set the audience asks
116
142
  * for, and classify the outcome by what each check declared. `shipkit check`
117
143
  * and `shipkit ci` both come through here, and nothing else decides
@@ -135,7 +161,7 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
135
161
  let ledger: Ledger | null = o.ledger ?? null
136
162
  if (ledger === null && o.ledger === undefined) {
137
163
  try {
138
- ledger = Ledger.open(root, composed.map((g) => g.row.decl), { cliEntry: o.cliEntry ?? gateBinary(), fresh: o.fresh })
164
+ ledger = Ledger.open(root, composed.map((g) => g.row.decl), { cliEntry: o.cliEntry ?? gateBinary(), fresh: o.fresh, scope: passScope(composed, o) })
139
165
  } catch { ledger = null }
140
166
  }
141
167
  // the caller's log, or this run's own: `shipkit check` measures checks too,
@@ -157,24 +183,15 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
157
183
  const collected: Line[] = []
158
184
  const sink = (l: string): void => print(l)
159
185
  const summary = await runChecks({
160
- checks,
186
+ ...selectionOf(checks, o, tier),
161
187
  root,
162
188
  config: c,
163
- audience,
164
- fullTier: tier === 'full',
165
189
  logDir: c.logDir ?? join(root, CI_LOG_DIR),
166
190
  cache: { storeDir: storeRoot(root), ns: c.name, now: Date.now },
167
191
  ledger,
168
192
  log,
169
193
  childEnv: { [SHARE_ENV]: sharePath, ...(o.fresh === true ? { SHIPKIT_FRESH: '1' } : {}) },
170
194
  bail: o.bail,
171
- skipGroups: o.skipGroups,
172
- onlyIds: o.only,
173
- withIds: o.withIds,
174
- onlyBlocking: o.onlyBlocking,
175
- buildOnly: o.buildOnly,
176
- includeProactive: o.includeProactive,
177
- exclude: o.exclude,
178
195
  print: sink,
179
196
  onCheck: (e) => { collected.push(...e.lines); o.onCheck?.(e) },
180
197
  })
@@ -205,13 +222,16 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
205
222
  if (o.runLog === undefined && log != null) for (const l of log.flush()) say(l)
206
223
  // a `when` id naming nothing is a switch wired to no check: the id is read
207
224
  // against the WHOLE inventory this run composed, never its narrowed list
208
- const unknownWhen = unknownWhenIds(composed.map((g) => g.row), c)
225
+ const rows = composed.map((g) => g.row)
226
+ const unknownWhen = unknownWhenIds(rows, c)
227
+ const unknownBudgets = unknownBudgetIds(rows.map((r) => r.decl.id), c.budgets)
209
228
  return {
210
229
  lines: [...collected, ...lines],
211
230
  blocked: !summary.ok,
212
231
  advisoryFailures: summary.advisoryFailures,
213
232
  undeclared: summary.undeclared,
214
233
  unknownWhen,
234
+ unknownBudgets,
215
235
  results: summary.results,
216
236
  interrupted: summary.interrupted,
217
237
  }
@@ -222,14 +242,14 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
222
242
  const print = o.print ?? ((l: string) => console.log(l))
223
243
  const c = o.config ?? (await loadRepoConfig(root))
224
244
  const run = await runCheckPass(root, { ...o, config: c })
225
- const code = run.blocked || run.undeclared.length > 0 || run.unknownWhen.length > 0 ? 1 : 0
245
+ const code = run.blocked || run.undeclared.length > 0 || run.unknownWhen.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
226
246
  if (o.json === true) { print(JSON.stringify(run)); return code }
227
247
  for (const u of run.undeclared) {
228
248
  print(`FAIL: ${u} does not declare whether it blocks`)
229
249
  print(` fix: add \`blocking: true\` (about this repo) or \`false\` (about the world) to that check`)
230
250
  }
231
- for (const id of run.unknownWhen) {
232
- print(`FAIL: when.${id} names no check of this repo`)
251
+ for (const key of [...run.unknownWhen.map((id) => `when.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
252
+ print(`FAIL: ${key} names no check of this repo`)
233
253
  print(` fix: \`shipkit checks\` lists every id; drop the entry or correct it`)
234
254
  }
235
255
  if (o.status !== false) {
@@ -6,15 +6,14 @@
6
6
  // (write-status.ts — atomic + lock) unless status:false, and the fix pass plus
7
7
  // some checks WRITE, so a run is not read-only unless frozen is set and
8
8
  // --no-fix is passed.
9
- import { join } from 'path'
10
9
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
11
10
  import { reportToMotherhome } from './motherhome.ts'
12
- import { runCheckPass, type CheckOpts } from './run-check.ts'
11
+ import { passScope, runCheckPass, type CheckOpts } from './run-check.ts'
13
12
  import { poolLines } from './slots.ts'
14
13
  import { runFixPass } from './run-fixers.ts'
15
- import { formatLine, type Line } from './wire.ts'
14
+ import { formatLine } from './wire.ts'
16
15
  import { writeStatus } from './write-status.ts'
17
- import { allTasks, composeChecks, envTier, FULL_TESTS_ENV, gateInventory, type TestTier } from '../ledger/gate-checks.ts'
16
+ import { allTasks, composeChecks, envTier, FULL_TESTS_ENV, type TestTier } from '../ledger/gate-checks.ts'
18
17
  import { CHECK_TABLE_FILE, writeCheckTable } from '../ledger/check-table.ts'
19
18
  import { Ledger } from '../ledger/ledger.ts'
20
19
  import { RunLog, type InvokedBy } from '../ledger/run-log.ts'
@@ -170,11 +169,34 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
170
169
  // ONE walk for the whole gate: the ledger is keyed on it and the run
171
170
  // measures it, and two walks can disagree about what exists
172
171
  const composed = await composeChecks(root, c, { tier })
172
+ // `gate = false` opts a check out of the everyday gate, and failOnAdvisory
173
+ // (release.requireHealthy) re-arms the world probes, because a repo asking
174
+ // for exactly those must not have them silently dropped by the opt-out
175
+ const passOpts: CheckOpts = {
176
+ status: false,
177
+ config: c,
178
+ composed,
179
+ audience: 'gate',
180
+ includeProactive: o.failOnAdvisory === true,
181
+ frozen: o.frozen,
182
+ onlyBlocking: o.onlyBlockingChecks,
183
+ only: o.onlyIds,
184
+ withIds: o.withIds,
185
+ skipGroups: o.skipGroups,
186
+ exclude: o.skipTaskIds,
187
+ buildOnly: o.buildOnly,
188
+ bail: o.bail,
189
+ fresh: o.fresh,
190
+ tier,
191
+ }
173
192
  try {
174
193
  ledger = Ledger.open(root, composed.map((g) => g.row.decl), {
175
194
  cliEntry: o.cliEntry ?? gateBinary(),
176
195
  read: o.ledgerRead,
177
196
  fresh: o.fresh,
197
+ // only what this run executes is scanned: an `--only` run pays for
198
+ // its own checks, never for the whole inventory
199
+ scope: passScope(composed, passOpts),
178
200
  })
179
201
  const reg = ledger.layer.registration
180
202
  for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
@@ -192,27 +214,9 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
192
214
  ledger = null
193
215
  sink(`WARN cache unavailable (${String(e).slice(0, 160)}) — every check runs this time, so the gate is correct and slower`)
194
216
  }
195
- // THE RUN. One engine, one DAG, one pass over everything this gate asks
196
- // for: `gate = false` opts a check out of the everyday gate, and
197
- // failOnAdvisory (release.requireHealthy) re-arms the world probes,
198
- // because a repo asking for exactly those must not have them silently
199
- // dropped by the opt-out.
217
+ // THE RUN. One engine, one DAG, one pass over everything this gate asks for
200
218
  const run = await runCheckPass(root, {
201
- status: false,
202
- config: c,
203
- composed,
204
- audience: 'gate',
205
- includeProactive: o.failOnAdvisory === true,
206
- frozen: o.frozen,
207
- onlyBlocking: o.onlyBlockingChecks,
208
- only: o.onlyIds,
209
- withIds: o.withIds,
210
- skipGroups: o.skipGroups,
211
- exclude: o.skipTaskIds,
212
- buildOnly: o.buildOnly,
213
- bail: o.bail,
214
- fresh: o.fresh,
215
- tier,
219
+ ...passOpts,
216
220
  ledger,
217
221
  runLog,
218
222
  onCheck: o.onCheck,
@@ -231,8 +235,8 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
231
235
  }
232
236
  // a switch wired to no check does nothing, silently, which is the one thing
233
237
  // a toggle must never do
234
- for (const id of run.unknownWhen) {
235
- sink(`FAIL: when.${id} names no check of this repo`)
238
+ for (const key of [...run.unknownWhen.map((id) => `when.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
239
+ sink(`FAIL: ${key} names no check of this repo`)
236
240
  sink(` fix: \`shipkit checks\` lists every id; drop the entry or correct it`)
237
241
  }
238
242
  let advisoryFatal = false
@@ -250,7 +254,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
250
254
  if (ledger != null) for (const l of ledger.header(ledger.commit())) sink(formatLine(l, false))
251
255
  // one write for the whole gate, after everything has finished
252
256
  for (const l of runLog.flush()) sink(formatLine(l, false))
253
- const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownWhen.length > 0 ? 1 : 0
257
+ const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownWhen.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
254
258
  // motherhome row (docsBase wiki on this disk) — never affects the gate,
255
259
  // and never fires under frozen: a bump/release gate must not re-dirty the
256
260
  // tree it just proved clean
@@ -17,11 +17,14 @@ export type FixOpts = {
17
17
  * not wire-shaped, which the frozen output contract forbids */
18
18
  quiet?: boolean
19
19
  print?: (line: string) => void
20
+ /** the config as it stands on disk now, when the caller holds one: a
21
+ * second load of a rewritten repo.config.ts can hit the module cache */
22
+ config?: RepoConfig
20
23
  }
21
24
 
22
25
  export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
23
26
  const print = o.print ?? ((l: string) => console.log(l))
24
- const config = await loadRepoConfig(root)
27
+ const config = o.config ?? (await loadRepoConfig(root))
25
28
  const env: CheckEnv = { root, config }
26
29
  // a fresh context per body: a fixer may rewrite the config, and the body
27
30
  // after it must read the one on disk now