rvlib-shipkit 1.8.0 → 1.9.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.
@@ -10,6 +10,7 @@ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
10
10
  import { reportToMotherhome } from './motherhome.ts'
11
11
  import { passScope, runCheckPass, type CheckOpts } from './run-check.ts'
12
12
  import { poolLines } from './slots.ts'
13
+ import { fixPassRuns } from './fix-scope.ts'
13
14
  import { runFixPass } from './run-fixers.ts'
14
15
  import { formatLine } from './wire.ts'
15
16
  import { outcomeCounts } from './outcome-words.ts'
@@ -29,8 +30,9 @@ export type CiOpts = {
29
30
  onlyIds?: string[]
30
31
  /** refresh STATUS.md after the run (default true — O4: every repo gets it) */
31
32
  status?: boolean
32
- /** repair the mechanical slots before gating. default on locally, verify
33
- * mode under CI. core/run-fixers.ts owns the scope. */
33
+ /** repair the mechanical slots before gating, verify mode under CI.
34
+ * Unset: on for a whole gate, off for a narrowed one (core/fix-scope.ts).
35
+ * core/run-fixers.ts owns the scope. */
34
36
  fix?: boolean
35
37
  /** also apply the repairs of the checks declaring `fix: 'ci'` during the
36
38
  * fix pass */
@@ -138,15 +140,14 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
138
140
  let c = o.config ?? (await loadRepoConfig(root))
139
141
  // mechanical failures are not information, so they never cost a round trip
140
142
  let fixFailed = false
141
- // a NARROWED run repairs only the mechanical slots. The structural rule
142
- // fixers rewrite package.json and repo config, and arming rewrites hooks/:
143
- // that is the right price for the full gate and the wrong one for
144
- // `bun run test`, which routes here and must answer the one question its
145
- // name promises without leaving files in the agent's diff that it never
146
- // wrote. Under CI it is worse than surprising: a formatting rewrite comes
147
- // back as a test failure.
143
+ // a NARROWED run repairs nothing unless --fix asks, and even then only the
144
+ // mechanical slots. `bun run test` routes here and must answer the one
145
+ // question its name promises without leaving files in the agent's diff
146
+ // that it never wrote: in a shared tree the formatter rewrites every other
147
+ // agent's files too. The structural rule fixers rewrite package.json and
148
+ // repo config, and arming rewrites hooks/: the full gate's price alone
148
149
  const narrowed = (o.onlyIds ?? []).length > 0
149
- if (o.fix !== false) {
150
+ if (fixPassRuns(o)) {
150
151
  const pass = await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink, detail })
151
152
  fixFailed = pass.failed
152
153
  // a fixer that rewrote the config or moved a check file leaves the
@@ -158,7 +159,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
158
159
  return 1
159
160
  }
160
161
  }
161
- } else detail('skip: fix pass (--no-fix)')
162
+ } else detail(o.fix === false ? 'skip: fix pass (--no-fix)' : 'skip: fix pass (a --only run leaves the tree alone, --fix repairs first)')
162
163
  const tier: TestTier = o.tests ?? envTier()
163
164
  // announced BEFORE the run (under --verbose, and in every release log),
164
165
  // so a log that died mid-way still says which tier it was asking for
package/src/core/slots.ts CHANGED
@@ -4,6 +4,11 @@
4
4
  // mutate lease's non blocking form (locked-write.ts tryLease): a slot whose
5
5
  // holder died is free at once, and a live holder heartbeats it.
6
6
  //
7
+ // A slot is a check, and a check is often a whole multi core tool (bun test
8
+ // workers, oxlint threads, tsc), so slots alone never bound the CPU. Past the
9
+ // cores the pool admits fewer slots in proportion to the 1 minute load, never
10
+ // fewer than one (admittedSlots). A slot already held is never taken back.
11
+ //
7
12
  // The one constraint a reader breaks first: a process started BY a check
8
13
  // (SHIPKIT_SLOT_HELD=1 in its environment) takes no slot. It already runs on
9
14
  // its parent's, and a parent waiting on a child that waits for a slot the
@@ -12,6 +17,8 @@ import { mkdirSync, readdirSync } from 'fs'
12
17
  import { homedir } from 'os'
13
18
  import { join } from 'path'
14
19
  import { leaseHolder, tryLease } from './locked-write.ts'
20
+ import { machineLoad } from './budget.ts'
21
+ import type { MachineLoad } from '../suite/judge-suites.ts'
15
22
  import { PARALLEL_ENV, parallelCap, parallelCapInfo } from './parallel.ts'
16
23
 
17
24
  /** where the slots live, for a test that must not touch the machine's pool */
@@ -19,7 +26,9 @@ export const SLOTS_DIR_ENV = 'SHIPKIT_SLOTS_DIR'
19
26
  /** set for every process a check spawns: it runs on its parent's slot */
20
27
  export const SLOT_HELD_ENV = 'SHIPKIT_SLOT_HELD'
21
28
 
22
- export type SlotPool = { dir: string; size: number }
29
+ /** `load`: the reading that scales what the pool admits. Omitted: the whole
30
+ * size, whatever the machine runs (a test's own pool) */
31
+ export type SlotPool = { dir: string; size: number; load?: () => MachineLoad }
23
32
 
24
33
  export type SlotHolder = { slot: number; pid: number; repo: string; check: string; since: number }
25
34
 
@@ -37,9 +46,18 @@ export const autoGatePool = (): SlotPool => ({ dir: `${poolDir()}-auto-gate`, si
37
46
 
38
47
  /** this process's pool, or null when it runs inside a check that holds one */
39
48
  export function machinePool(): SlotPool | null {
40
- return process.env[SLOT_HELD_ENV] === '1' ? null : { dir: poolDir(), size: parallelCap() }
49
+ return process.env[SLOT_HELD_ENV] === '1' ? null : { dir: poolDir(), size: parallelCap(), load: machineLoad }
41
50
  }
42
51
 
52
+ /** how many of `size` slots a machine at this load admits: all of them up to
53
+ * a load of one per core, then size × cores / load, never below one. At 20
54
+ * on 8 cores a pool of 8 admits 3, at 81 it admits 1 */
55
+ export const admittedSlots = (size: number, m: MachineLoad): number =>
56
+ Math.max(1, Math.min(size, Math.floor((size * m.cores) / Math.max(m.cores, m.load1))))
57
+
58
+ const admitWords = (size: number, admitted: number, m: MachineLoad): string =>
59
+ `the pool admits ${admitted} of its ${size} slots at load ${m.load1.toFixed(1)} on ${m.cores} cores`
60
+
43
61
  /** who holds which slot right now, dead holders left out. Every slot file in
44
62
  * the dir is read, so a slot taken under a larger size still shows. */
45
63
  export function poolOccupancy(pool: SlotPool = { dir: poolDir(), size: parallelCap() }): { size: number; dir: string; held: SlotHolder[] } {
@@ -64,25 +82,37 @@ export function poolLines(): string[] {
64
82
  ...(info.problem == null ? [] : [`WARN ${info.problem}`]),
65
83
  pool == null
66
84
  ? `ok: parallel: inside a check that holds a machine slot, so this run takes none (up to ${info.cap} at once in this process)`
67
- : `ok: parallel: one machine pool of ${info.cap} slot(s) shared by every shipkit process, ${poolOccupancy(pool).held.length} held now (${why})`,
85
+ : `ok: parallel: one machine pool of ${info.cap} slot(s) shared by every shipkit process, ${poolOccupancy(pool).held.length} held now (${why})${loadNote(pool)}`,
68
86
  ]
69
87
  }
70
88
 
89
+ const loadNote = (pool: SlotPool): string => {
90
+ const m = pool.load?.()
91
+ if (m == null) return ''
92
+ const admitted = admittedSlots(pool.size, m)
93
+ return admitted < pool.size ? `; ${admitWords(pool.size, admitted, m)}` : ''
94
+ }
95
+
71
96
  /** wait for a free slot and hold it until release(). null = abort said stop. */
72
97
  export async function takeSlot(
73
98
  pool: SlotPool,
74
99
  who: { repo: string; check: string },
75
- o: { abort?: () => boolean; pollMs?: number; onWait?: (held: SlotHolder[]) => void } = {},
100
+ o: { abort?: () => boolean; pollMs?: number; onWait?: (held: SlotHolder[], why: string) => void } = {},
76
101
  ): Promise<{ slot: number; release: () => void } | null> {
77
102
  mkdirSync(pool.dir, { recursive: true })
78
103
  let heard = false
79
104
  for (;;) {
80
- for (let slot = 0; slot < pool.size; slot++) {
105
+ const m = pool.load?.() ?? null
106
+ const admitted = m == null ? pool.size : admittedSlots(pool.size, m)
107
+ for (let slot = 0; slot < admitted; slot++) {
81
108
  const lease = tryLease(join(pool.dir, `slot-${slot}`), who.check, { repo: who.repo, check: who.check })
82
109
  if (lease != null) return { slot, release: lease.release }
83
110
  }
84
111
  if (o.abort?.() === true) return null
85
- if (!heard) { heard = true; o.onWait?.(poolOccupancy(pool).held) }
112
+ if (!heard) {
113
+ heard = true
114
+ o.onWait?.(poolOccupancy(pool).held, m != null && admitted < pool.size ? admitWords(pool.size, admitted, m) : `all ${pool.size} slots are held`)
115
+ }
86
116
  await Bun.sleep(o.pollMs ?? SLOT_POLL_MS)
87
117
  }
88
118
  }
@@ -130,6 +130,7 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
130
130
 
131
131
  func applicationDidFinishLaunching(_ note: Notification) {
132
132
  if CommandLine.arguments.contains("--self-test-find") { return selfTestFind() }
133
+ if CommandLine.arguments.contains("--self-test-panels") { return selfTestPanels() }
133
134
  buildMenu()
134
135
  // esc closes the find bar of the key window, and reaches the page otherwise
135
136
  NSEvent.addLocalMonitorForEvents(matching: .keyDown) { e in
@@ -267,6 +268,61 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
267
268
  web.loadHTMLString("<p>one needle, two needle</p>", baseURL: nil)
268
269
  }
269
270
 
271
+ /** hears every js panel once it shows: the self test answers it */
272
+ var onPanel: ((NSAlert) -> Void)?
273
+
274
+ // `--self-test-panels`: a page in an offscreen window calls prompt, confirm
275
+ // and alert, answered OK after typing over the prompt's text, then again,
276
+ // answered with esc. What the page got printed, exit 0 only when every
277
+ // answer reached it. Return is checked as OK being the sheet's default
278
+ // button: a synthetic return reaches it only in a key window, and the test
279
+ // must not take the focus
280
+ func selfTestPanels() {
281
+ let web = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
282
+ web.navigationDelegate = self
283
+ web.uiDelegate = self
284
+ let win = NSWindow(contentRect: NSRect(x: -4000, y: -4000, width: 600, height: 400), styleMask: [.titled], backing: .buffered, defer: false)
285
+ win.contentView = Pane(web: web)
286
+ win.orderFrontRegardless()
287
+ windows.append(win)
288
+ var accept = true
289
+ var panels = 0
290
+ var returnIsOK = true
291
+ var escHeard = true
292
+ onPanel = { alert in
293
+ panels += 1
294
+ DispatchQueue.main.async {
295
+ if alert.window.defaultButtonCell !== alert.buttons[0].cell { returnIsOK = false }
296
+ if accept {
297
+ // typing replaces the default text only when it is selected in an active editor
298
+ if let field = alert.accessoryView as? NSTextField { (field.currentEditor() as? NSTextView)?.insertText("typed", replacementRange: NSRange(location: NSNotFound, length: 0)) }
299
+ return alert.buttons[0].performClick(nil)
300
+ }
301
+ if alert.buttons.count == 1 { return alert.buttons[0].performClick(nil) }
302
+ let esc = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: alert.window.windowNumber, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)
303
+ if let esc, alert.window.performKeyEquivalent(with: esc) { return }
304
+ escHeard = false
305
+ alert.buttons[1].performClick(nil)
306
+ }
307
+ }
308
+ let js = "JSON.stringify([prompt('name', 'x'), confirm('ok?'), (alert('hi'), 'alerted')])"
309
+ onLoad = { _ in
310
+ web.evaluateJavaScript(js) { a, _ in
311
+ accept = false
312
+ web.evaluateJavaScript(js) { b, _ in
313
+ let ok = (a as? String) ?? "none", cancel = (b as? String) ?? "none"
314
+ print("panels=\(panels)")
315
+ print("return-is-ok=\(returnIsOK)")
316
+ print("esc-heard=\(escHeard)")
317
+ print("ok=\(ok)")
318
+ print("cancel=\(cancel)")
319
+ exit(panels == 6 && returnIsOK && escHeard && ok == "[\"typed\",true,\"alerted\"]" && cancel == "[null,false,\"alerted\"]" ? 0 : 1)
320
+ }
321
+ }
322
+ }
323
+ web.loadHTMLString("<p>panels</p>", baseURL: nil)
324
+ }
325
+
270
326
  func windowWillClose(_ note: Notification) {
271
327
  windows.removeAll { $0 === note.object as? NSWindow }
272
328
  }
@@ -293,6 +349,46 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
293
349
  return nil
294
350
  }
295
351
 
352
+ // alert, confirm and prompt: without these WebKit answers each one at once
353
+ // (no panel, confirm false, prompt null), so a page's dialogs do nothing.
354
+ // WebKit throws when a handler is dropped or called twice: `panel` calls
355
+ // `done` exactly once on every path
356
+
357
+ /** one js panel: a sheet on the page's window, a modal where there is
358
+ * none. Return is OK, esc is Cancel; `done` hears whether OK was pressed */
359
+ func panel(_ web: WKWebView, _ message: String, cancel: Bool, field: NSTextField? = nil, done: @escaping (Bool) -> Void) {
360
+ let alert = NSAlert()
361
+ alert.messageText = message
362
+ alert.addButton(withTitle: "OK").keyEquivalent = "\r"
363
+ if cancel { alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}" }
364
+ if let field {
365
+ alert.accessoryView = field
366
+ alert.window.initialFirstResponder = field
367
+ }
368
+ guard let win = web.window else {
369
+ onPanel?(alert)
370
+ return done(alert.runModal() == .alertFirstButtonReturn)
371
+ }
372
+ alert.beginSheetModal(for: win) { r in done(r == .alertFirstButtonReturn) }
373
+ // first responder selects the whole default text, so typing replaces it
374
+ if let field { alert.window.makeFirstResponder(field) }
375
+ onPanel?(alert)
376
+ }
377
+
378
+ func webView(_ web: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
379
+ panel(web, message, cancel: false) { _ in completionHandler() }
380
+ }
381
+
382
+ func webView(_ web: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
383
+ panel(web, message, cancel: true) { ok in completionHandler(ok) }
384
+ }
385
+
386
+ func webView(_ web: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
387
+ let field = NSTextField(string: defaultText ?? "")
388
+ field.frame = NSRect(x: 0, y: 0, width: 300, height: 24)
389
+ panel(web, prompt, cancel: true, field: field) { ok in completionHandler(ok ? field.stringValue : nil) }
390
+ }
391
+
296
392
  func item(_ menu: NSMenu, _ title: String, _ action: Selector, _ key: String, _ mods: NSEvent.ModifierFlags = [.command], tag: Int = 0) {
297
393
  let i = menu.addItem(withTitle: title, action: action, keyEquivalent: key)
298
394
  i.keyEquivalentModifierMask = mods
@@ -285,6 +285,9 @@ export class Resource {
285
285
  * while it answers, else it starts and opens once it does */
286
286
  async open(how: OpenHow): Promise<void> {
287
287
  if (this.decl.url == null) throw new Error(`${this.decl.id} declares no url to open`)
288
+ // the status is the last tick's: a process that died since would get
289
+ // a window on a dead port and never start
290
+ await this.tick()
288
291
  if (this.status === 'up') {
289
292
  this.show(how, this.decl.url)
290
293
  return
@@ -11,7 +11,8 @@
11
11
  //
12
12
  // what keeps it off the cpu: a save burst is asked about once the repo is
13
13
  // quiet (QUIET_MS, or MAX_WAIT_MS into a burst that never stops), a check a
14
- // save moved waits COST_FACTOR times its last measured run, and nothing it
14
+ // save moved waits COST_FACTOR times its last measured run and GATED_HOLD_MS
15
+ // after the latest gate someone else started in its repo, and nothing it
15
16
  // starts on its own starts while the load per core is above LOAD_MAX.
16
17
  import { availableParallelism, loadavg } from 'os'
17
18
  import { CATCH_UP_BEAT_MS, resumeCatchUp, type CatchUpStore } from './catch-up.ts'
@@ -39,6 +40,9 @@ export type PlanSource = {
39
40
  checks: () => Promise<PlanCheck[]>
40
41
  /** every check's last result on record */
41
42
  last: () => Record<string, LastResult>
43
+ /** when the latest run someone asked for started here (a terminal, an
44
+ * agent, a click), never one the plan started; null when none did */
45
+ gatedAt?: () => number | null
42
46
  /** the checks a save moved: their answer would not replay */
43
47
  changed: () => Promise<string[]>
44
48
  /** run these checks now, in the background; resolves to the exit code */
@@ -88,6 +92,11 @@ export const MAX_WAIT_MS = 10 * 60_000
88
92
  * measured run: a 30s suite at most every 10 minutes, a 200ms lint at once */
89
93
  export const COST_FACTOR = 20
90
94
 
95
+ /** a save moved check waits this long after the latest run someone else
96
+ * started in its repo (a terminal, an agent, a click): whoever gates that
97
+ * tree is still at work, and its own gate measures what it changed */
98
+ export const GATED_HOLD_MS = 10 * 60_000
99
+
91
100
  /** above this 1 minute load average per core, the plan starts nothing on its
92
101
  * own: every core already has a process waiting for it */
93
102
  export const LOAD_MAX = 1
@@ -112,6 +121,7 @@ type Repo = {
112
121
  src: PlanSource
113
122
  checks: PlanCheck[]
114
123
  last: Record<string, LastResult>
124
+ gatedAt: number | null
115
125
  lastRead: number
116
126
  marks: Map<string, Mark>
117
127
  timer: unknown
@@ -169,7 +179,7 @@ export class Planner {
169
179
  async add(src: PlanSource): Promise<void> {
170
180
  const old = this.repos.get(src.repo)
171
181
  if (old?.timer != null) this.timers.clear(old.timer)
172
- const r: Repo = { src, checks: [], last: {}, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
182
+ const r: Repo = { src, checks: [], last: {}, gatedAt: null, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
173
183
  this.repos.set(src.repo, r)
174
184
  await this.reload(src.repo)
175
185
  try {
@@ -223,6 +233,7 @@ export class Planner {
223
233
  r.lastRead = now
224
234
  try {
225
235
  r.last = r.src.last()
236
+ r.gatedAt = r.src.gatedAt?.() ?? null
226
237
  } catch (e) {
227
238
  this.fail(r.src.repo, e)
228
239
  }
@@ -315,7 +326,9 @@ export class Planner {
315
326
  // a check pays for its own runs: one save cannot run it again
316
327
  // before COST_FACTOR times its last measured run has passed
317
328
  const spaced = last?.cost != null ? last.cost.at + COST_FACTOR * last.cost.ms : -Infinity
318
- const at = Math.max(m.changedAt, spaced)
329
+ // and it waits for whoever gates this tree by hand to stop
330
+ const gated = r.gatedAt != null ? r.gatedAt + GATED_HOLD_MS : -Infinity
331
+ const at = Math.max(m.changedAt, spaced, gated)
319
332
  return { ...base, state: at > now ? 'next' : 'due', reason: 'changed', dueAt: at }
320
333
  }
321
334
  const lastAt = Math.max(last?.at ?? -Infinity, m.attemptAt ?? -Infinity)
@@ -354,8 +367,12 @@ export class Planner {
354
367
  held(): string[] {
355
368
  const now = this.now()
356
369
  const out: string[] = []
357
- for (const r of this.repos.values())
370
+ for (const r of this.repos.values()) {
358
371
  if (r.settleAt != null) out.push(`settling: ${r.src.repo}, ${Math.max(0, Math.ceil((r.settleAt - now) / 1_000))}s`)
372
+ const until = r.gatedAt == null ? null : r.gatedAt + GATED_HOLD_MS
373
+ if (until != null && until > now && [...r.marks.values()].some((m) => m.changedAt != null))
374
+ out.push(`deferred: ${r.src.repo}, a gate someone started there ${Math.floor((now - r.gatedAt!) / 60_000)}m ago, so what a save moved waits ${Math.ceil((until - now) / 60_000)}m`)
375
+ }
359
376
  if (this.holdReason != null) out.push(this.holdReason)
360
377
  return out
361
378
  }
@@ -82,12 +82,15 @@ export function readLastCosts(path: string): Record<string, { at: number; ms: nu
82
82
  return out
83
83
  }
84
84
 
85
- /** when the latest run on record started, whoever ran it, or null */
86
- export function lastStarted(path: string): number | null {
85
+ /** when the latest run on record started, or null. `asked`: only a run
86
+ * someone asked for (a terminal, an agent, a click), never one the plan
87
+ * started on its own */
88
+ export function lastStarted(path: string, o: { asked?: boolean } = {}): number | null {
87
89
  const opened = openDb(path, { create: false })
88
90
  if (opened == null) return null
89
91
  try {
90
- const r = opened.db.query('select max(started) as at from invocations').get() as { at: number | null } | null
92
+ const sql = o.asked === true ? "select max(started) as at from invocations where by != 'schedule'" : 'select max(started) as at from invocations'
93
+ const r = opened.db.query(sql).get() as { at: number | null } | null
91
94
  return r?.at == null ? null : Number(r.at)
92
95
  } catch {
93
96
  return null
@@ -144,6 +147,7 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
144
147
  for (const [id, cost] of Object.entries(costs)) if (all[id] != null) all[id] = { ...all[id]!, cost }
145
148
  return all
146
149
  },
150
+ gatedAt: () => (d.record === '' ? null : lastStarted(d.record, { asked: true })),
147
151
  changed: async () => {
148
152
  const decls = rows.map((r) => r.decl)
149
153
  if (decls.length === 0) return []
@@ -12,7 +12,7 @@ import { runCi } from '../core/run-ci.ts'
12
12
  import { extractChangelogSection } from './changelog.ts'
13
13
  import { deriveRemote, dirtyPaths, git, remoteTagState, tagExistsLocally, type RemoteTagState } from './git-facts.ts'
14
14
  import { releaseScope } from './release-scope.ts'
15
- import { npmPublishArgv, unitBlockers } from './unit-gate.ts'
15
+ import { publishCommand, unitBlockers, type Publisher } from './unit-gate.ts'
16
16
  import { formatUnitError, resolveUnits, unitById, unknownUnit, type ReleaseUnit } from './units.ts'
17
17
  import type { RepoConfig } from '../repo-config.ts'
18
18
  import { nestedIn, nestedRefusal } from '../core/nesting.ts'
@@ -37,6 +37,10 @@ export type ReleaseConfig = {
37
37
  * Absent = rely on ambient npm auth. Never put a token in config.
38
38
  * Repo-wide: one account publishes every unit. */
39
39
  npmTokenCommand?: string[]
40
+ /** the tool that packs and publishes every npm unit (default 'npm'). A
41
+ * repo that tests its tarball with `bun pm pack` publishes with 'bun', so
42
+ * the packer it tests is the packer that ships. */
43
+ publisher?: Publisher
40
44
  /** cut a GitHub release for the ROOT unit with `gh` (default true) */
41
45
  githubRelease?: boolean
42
46
  /** make a NON-blocking check failure block the release too. Blocking
@@ -301,18 +305,23 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
301
305
  if (wantNpm && !alreadyPublished) {
302
306
  const env: Record<string, string> = { ...process.env as Record<string, string> }
303
307
  const argv = rc.npmTokenCommand
308
+ const publisher = rc.publisher ?? 'npm'
304
309
  let npmrc: string | null = null
310
+ let token: string | null = null
305
311
  if (argv != null && argv[0] != null) {
306
312
  const t = Bun.spawnSync(argv)
307
- const token = t.exitCode === 0 ? t.stdout.toString().trim() : ''
313
+ token = t.exitCode === 0 ? t.stdout.toString().trim() : ''
308
314
  if (token === '') { print(`FAIL: release.npmTokenCommand produced no token (exit ${t.exitCode})`); return 1 }
309
- // token via a throwaway userconfig, never argv, never a
310
- // committed .npmrc, never a line in a log
311
- npmrc = join(work, 'npmrc')
312
- writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 })
315
+ // token via a throwaway userconfig (npm) or the env (bun),
316
+ // never argv, never a committed .npmrc, never a line in a log
317
+ if (publisher === 'npm') {
318
+ npmrc = join(work, 'npmrc')
319
+ writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 })
320
+ }
313
321
  }
314
- const p = Bun.spawnSync(npmPublishArgv({ npmrc }), { cwd: unit.dir, env, stdout: 'inherit', stderr: 'inherit' })
315
- if (p.exitCode !== 0) { print(`FAIL: npm publish failed for ${unit.id} (exit ${p.exitCode})`); return 1 }
322
+ const cmd = publishCommand({ publisher, npmrc, token })
323
+ const p = Bun.spawnSync(cmd.argv, { cwd: unit.dir, env: { ...env, ...cmd.env }, stdout: 'inherit', stderr: 'inherit' })
324
+ if (p.exitCode !== 0) { print(`FAIL: ${publisher} publish failed for ${unit.id} (exit ${p.exitCode})`); return 1 }
316
325
  print(`ok: published ${name}@${version}`)
317
326
  }
318
327
 
@@ -9,11 +9,18 @@ import { dirtyPaths } from './git-facts.ts'
9
9
  import type { ReleaseUnit } from './units.ts'
10
10
  import type { RepoConfig } from '../repo-config.ts'
11
11
 
12
- /** the publish argv. `--access public` is not a default: npm restricts a
13
- * scoped package unless it is asked, and a restricted publish looks
14
- * successful right up until a consumer cannot install it. */
15
- export function npmPublishArgv(o: { npmrc?: string | null } = {}): string[] {
16
- return ['npm', 'publish', '--access', 'public', ...(o.npmrc != null ? ['--userconfig', o.npmrc] : [])]
12
+ /** the tool that packs and publishes an npm unit (`release.publisher`) */
13
+ export const PUBLISHERS = ['npm', 'bun'] as const
14
+ export type Publisher = (typeof PUBLISHERS)[number]
15
+
16
+ /** the publish argv and the env its token rides in. `--access public` is not
17
+ * a default: npm restricts a scoped package unless it is asked, and a
18
+ * restricted publish looks successful right up until a consumer cannot
19
+ * install it. npm reads the token from a throwaway userconfig; `bun publish`
20
+ * has no `--userconfig` and reads NPM_CONFIG_TOKEN instead. */
21
+ export function publishCommand(o: { publisher?: Publisher; npmrc?: string | null; token?: string | null } = {}): { argv: string[]; env: Record<string, string> } {
22
+ if (o.publisher === 'bun') return { argv: ['bun', 'publish', '--access', 'public'], env: o.token != null ? { NPM_CONFIG_TOKEN: o.token } : {} }
23
+ return { argv: ['npm', 'publish', '--access', 'public', ...(o.npmrc != null ? ['--userconfig', o.npmrc] : [])], env: {} }
17
24
  }
18
25
 
19
26
  /** everything that must hold for this unit, checked before anything is
@@ -25,6 +25,7 @@ import type { Rule } from './check.ts'
25
25
  import { leakAllowProblems } from './hooks/leak-guard.ts'
26
26
  import type { HooksConfig } from './hooks/run-hooks.ts'
27
27
  import type { ReleaseConfig } from './release/run-release.ts'
28
+ import { PUBLISHERS } from './release/unit-gate.ts'
28
29
  import { TEST_ONLY_UNIT_KEYS, UNIT_DECL_KEYS, type UnitDecl } from './release/unit-resolve.ts'
29
30
  import { TASK_DEF_KEYS, type TaskDef } from './check.ts'
30
31
  import { testsConfigProblems, type TestsConfig } from './suite/judge-suites.ts'
@@ -41,6 +42,10 @@ export type ServeConfig = {
41
42
  * its own port — house range 5800+, default = the hub port plus 1000
42
43
  * (build/comfyui.md owns that surface) */
43
44
  comfyPort?: number
45
+ /** false hides the asset kit from the hub page (no asset rows, an empty
46
+ * studio) and from `shipkit needs`, for a repo whose own tool mounts
47
+ * `studioRoutes` and owns the picking. Default true */
48
+ assets?: boolean
44
49
  /** custom wiki-graph provider for repos with their own link semantics.
45
50
  * Default: a generic md scan of the repo root. */
46
51
  wikiGraph?: {
@@ -320,6 +325,8 @@ export function defineRepo(c: RepoConfig): RepoConfig {
320
325
  for (const k of Object.keys(c))
321
326
  if (!KNOWN_KEYS.has(k))
322
327
  throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : ''}`)
328
+ if (c.release?.publisher != null && !(PUBLISHERS as readonly string[]).includes(c.release.publisher))
329
+ throw new Error(`defineRepo: release.publisher '${String(c.release.publisher)}' is not a publisher (${PUBLISHERS.join(', ')})`)
323
330
  // a typo'd unit key would silently drop a target and release the wrong shape
324
331
  for (const u of c.units ?? []) {
325
332
  if (typeof u.dir !== 'string' || u.dir === '') throw new Error(`defineRepo: every units[] entry needs a 'dir'`)
@@ -158,9 +158,10 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
158
158
  graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
159
159
  // ONE page for every repo: the studio ALWAYS
160
160
  // mounts — kit-less repos get an empty media section, never a second
161
- // page. A broken kit degrades to the same empty kit, loudly.
161
+ // page. A broken kit degrades to the same empty kit, loudly, and a repo
162
+ // whose own tool owns the picking turns the kit off here
162
163
  let kit: ResolvedKit | null = null
163
- if (existsSync(join(root, '.shipkit/assets/kit.ts'))) {
164
+ if (c.serve?.assets !== false && existsSync(join(root, '.shipkit/assets/kit.ts'))) {
164
165
  try {
165
166
  kit = await loadKit(root)
166
167
  } catch (e) {
@@ -9,7 +9,7 @@
9
9
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
10
10
  import { basename, dirname, join, resolve, sep } from 'path'
11
11
  import { fileURLToPath } from 'url'
12
- import { gitRoot } from '../core/git-root.ts'
12
+ import { gitRoot, isLinkedWorktree } from '../core/git-root.ts'
13
13
  import { DB_FILE } from '../ledger/db.ts'
14
14
  import { defaultHubPort } from './hub-port.ts'
15
15
  import { LAYOUT } from '../core/layout.ts'
@@ -52,7 +52,9 @@ export const ICON_FILES = ['.shipkit/identity/icon.png', 'public/favicon.png', '
52
52
 
53
53
  export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
54
54
 
55
- /** every direct child of `parent` that includes `index`, sorted by name */
55
+ /** every direct child of `parent` that includes `index`, sorted by name. A
56
+ * linked worktree of one is that repo checked out again, never a repo of its
57
+ * own: its processes would collide with the main checkout's ids and ports */
56
58
  export function wiredRepos(parent: string, index: string, home: string): WiredRepo[] {
57
59
  let names: string[]
58
60
  try {
@@ -65,6 +67,7 @@ export function wiredRepos(parent: string, index: string, home: string): WiredRe
65
67
  if (name.startsWith('.')) continue
66
68
  const path = join(parent, name)
67
69
  if (!includeTargets(path, home).includes(index)) continue
70
+ if (isLinkedWorktree(path)) continue
68
71
  out.push({ name, path, icon: ICON_FILES.find((f) => existsSync(join(path, f))) ?? null })
69
72
  }
70
73
  return out
@@ -7,7 +7,8 @@
7
7
  // - a run that writes the tree (a repair pass) waits for every run ahead of
8
8
  // it, and every later run waits for it
9
9
  // - every other run starts at once: the machine slot pool each check body
10
- // takes bounds the CPU, never this queue
10
+ // takes bounds the CPU, never this queue. It admits fewer slots on a
11
+ // loaded machine (core/slots.ts), so a run past it waits and says why
11
12
  //
12
13
  // the one constraint a reader breaks first: the stamp is taken when a run
13
14
  // STARTS, before its body reads anything, so a tree that moves during the run
@@ -15,6 +16,7 @@
15
16
  import { createHash } from 'crypto'
16
17
  import { statSync } from 'fs'
17
18
  import { join } from 'path'
19
+ import { fixPassRuns } from '../core/fix-scope.ts'
18
20
  import { gitBin } from '../core/git-bin.ts'
19
21
 
20
22
  export type LaneAsk = {
@@ -106,8 +108,10 @@ export function runKey(kind: string, opts: Record<string, unknown>): string {
106
108
  return `${kind} ${JSON.stringify(sorted)}`
107
109
  }
108
110
 
109
- /** a run with its repair pass on: `fix` and `fixRules` default to on */
110
- export const writesTree = (opts: Record<string, unknown>): boolean => opts.fix !== false || opts.fixRules !== false
111
+ /** a run whose repair pass runs (core/fix-scope.ts): the structural fixers
112
+ * and the hooks only ever run inside it */
113
+ export const writesTree = (opts: Record<string, unknown>): boolean =>
114
+ fixPassRuns({ fix: typeof opts.fix === 'boolean' ? opts.fix : undefined, onlyIds: Array.isArray(opts.onlyIds) ? (opts.onlyIds as string[]) : undefined })
111
115
 
112
116
  /** HEAD, what git status lists, and the stat of each listed path, hashed:
113
117
  * equal stamps mean no tracked or untracked file moved in between. Null
@@ -27,6 +27,10 @@ export type SmokeRoute = {
27
27
  * match's href after this route and judges the landed page as its own
28
28
  * row. How a detail page with a real id gets visited without knowing one. */
29
29
  open?: string
30
+ /** the visible text a settled route needs before it counts as rendered,
31
+ * default 40. A canvas app (a game) draws everything into one canvas and
32
+ * shows no text: `{ path: '/', waitFor: 'canvas', minChars: 0 }`. */
33
+ minChars?: number
30
34
  }
31
35
 
32
36
  export type SmokeLogin = {
@@ -63,8 +67,9 @@ export type SmokeConfig = {
63
67
  const SMOKE_KEYS = new Set(['base', 'routes', 'login', 'paceMs', 'shots'])
64
68
  const LOGIN_KEYS = new Set(['path', 'email', 'emailSecret', 'passwordSecret', 'form'])
65
69
  const FORM_KEYS = new Set(['email', 'password', 'submit'])
66
- const ROUTE_KEYS = new Set(['path', 'waitFor', 'open'])
70
+ const ROUTE_KEYS = new Set(['path', 'waitFor', 'open', 'minChars'])
67
71
  export const DEFAULT_LOGIN_PATH = '/login'
72
+ export const DEFAULT_MIN_CHARS = 40
68
73
 
69
74
  export const DEFAULT_PACE_MS = 3000
70
75
  export const DEFAULT_SHOTS = `${LAYOUT.tmp}/smoke`
@@ -86,6 +91,8 @@ export function validateSmokeConfig(c: unknown): SmokeConfig {
86
91
  const v = (r as Record<string, unknown>)[k]
87
92
  if (v != null && (typeof v !== 'string' || v === '')) throw new Error(`defineRepo: smoke.routes['${path}'].${k} must be a non-empty selector`)
88
93
  }
94
+ const min = (r as Record<string, unknown>)['minChars']
95
+ if (min != null && (typeof min !== 'number' || !Number.isInteger(min) || min < 0)) throw new Error(`defineRepo: smoke.routes['${path}'].minChars must be a whole number of characters, 0 or more`)
89
96
  }
90
97
  }
91
98
  if (s['login'] != null) {
@@ -152,8 +159,8 @@ export type SmokeReport = {
152
159
  * nothing threw, an empty `waitFor` on a listing is exactly how a broken
153
160
  * migration looks, and a page that scrolls sideways is a layout defect only
154
161
  * a real browser can see (happy-dom does no layout). */
155
- export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; overflowPx: number }): RouteReport {
156
- const empty = r.chars < 40
162
+ export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; minChars?: number; overflowPx: number }): RouteReport {
163
+ const empty = r.chars < (r.minChars ?? DEFAULT_MIN_CHARS)
157
164
  const noMatch = r.waitFor != null && r.matches === 0
158
165
  const sideways = r.overflowPx > 1
159
166
  const note = empty ? 'EMPTY PAGE' : noMatch ? `NO MATCH FOR ${r.waitFor}` : sideways ? `PAGE SCROLLS SIDEWAYS +${r.overflowPx}px` : r.problems > 0 ? `${r.problems} problem(s)` : ''
@@ -368,7 +375,7 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
368
375
  }
369
376
 
370
377
  /** visit one url, settle it, judge it, screenshot it */
371
- const visit = async (label: string, url: string, waitFor?: string): Promise<RouteReport> => {
378
+ const visit = async (label: string, url: string, waitFor?: string, minChars?: number): Promise<RouteReport> => {
372
379
  // let the previous page's requests drain out of the concurrency
373
380
  // window BEFORE the label moves, or its late failures land on this row
374
381
  if (paceMs > 0) await page.waitForTimeout(paceMs)
@@ -387,11 +394,11 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
387
394
  return Math.max(de.scrollWidth - de.clientWidth, doc.body.scrollWidth - doc.body.clientWidth)
388
395
  }).catch(() => 0)
389
396
  await page.screenshot({ path: join(shots, `${routeSlug(label)}.png`), fullPage: true })
390
- return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, overflowPx })
397
+ return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, minChars, overflowPx })
391
398
  }
392
399
 
393
400
  for (const route of routes) {
394
- reports.push(await visit(route.path, route.path, route.waitFor))
401
+ reports.push(await visit(route.path, route.path, route.waitFor, route.minChars))
395
402
  if (route.open == null) continue
396
403
  // a detail page with a real id: the first link the selector matches
397
404
  const href = await page.locator(route.open).first().getAttribute('href').catch(() => null)