rvlib-shipkit 1.1.1 → 1.3.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 (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -39,7 +39,7 @@ import { parallelCap } from './parallel.ts'
39
39
  import { machinePool, SLOT_HELD_ENV, takeSlot, type SlotPool } from './slots.ts'
40
40
  import { budgetLine, machineLoad } from './budget.ts'
41
41
  import { killTree, spawnWithTimeout } from './spawn-timeout.ts'
42
- import { formatLine, parseWireOutput, type Line } from './wire.ts'
42
+ import { FOREVER_MS, formatLine, parseWireOutput, type Line } from './wire.ts'
43
43
  import { offLine, type WhenDecl } from './when.ts'
44
44
  import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
45
45
  import { Ledger, stampCached } from '../ledger/ledger.ts'
@@ -47,7 +47,7 @@ import { attachedStamp } from './outcome-words.ts'
47
47
  import type { RunLog, RunStatus } from '../ledger/run-log.ts'
48
48
  import { liveStateOf } from '../ledger/live-runs.ts'
49
49
  import { CHECK_ID_ENV } from '../ledger/suite-log.ts'
50
- import { rememberWorld, replayWorld, type WorldCache } from '../ledger/world-cache.ts'
50
+ import { rememberWorld, replayWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
51
51
  import { extractProblems } from '../problems/extract.ts'
52
52
  import { feedsProblemsPane, type Population, type Problem } from '../problems/problem-line.ts'
53
53
 
@@ -191,6 +191,9 @@ export type RunChecksOpts = {
191
191
  * gate the whole repo, since the check's own gate already ran. */
192
192
  exclude?: string[]
193
193
  print?: (line: string) => void
194
+ /** where the green summary goes when the caller prints its own verdict
195
+ * (`shipkit ci` does). Default: print */
196
+ detail?: (line: string) => void
194
197
  color?: boolean
195
198
  onCheck?: (e: CheckEvent) => void
196
199
  }
@@ -227,11 +230,21 @@ function closure(checks: RunnableCheck[], ids: string[]): Set<string> {
227
230
  return keep
228
231
  }
229
232
 
233
+ /** how many tests a suite ran, read from the headline it prints last
234
+ * (`Ran 920 tests across 12 files.`), null when it printed none */
235
+ export function testsRan(lastLine: string): number | null {
236
+ const m = /Ran (\d+) tests?/.exec(lastLine)
237
+ return m == null ? null : Number(m[1])
238
+ }
239
+
230
240
  export type Selection = {
231
241
  /** what this run measures */
232
242
  selected: RunnableCheck[]
233
243
  /** turned off by name, with the reason: never run, always reported */
234
244
  off: RunnableCheck[]
245
+ /** off, and outside what `--only` asked about: reported where the caller
246
+ * keeps what nobody acts on */
247
+ offElsewhere?: RunnableCheck[]
235
248
  /** not asked about by this run, and why */
236
249
  parked: { id: string; text: string }[]
237
250
  /** asked for, and it never said whether it blocks */
@@ -337,6 +350,8 @@ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'ful
337
350
  if (o.onlyIds != null && o.onlyIds.length > 0) {
338
351
  const keep = closure(o.checks, onlyIds)
339
352
  selected = selected.filter((u) => keep.has(u.id))
353
+ // a narrowed run says nothing about the checks it never asked about
354
+ return { selected, off: off.filter((u) => keep.has(u.id)), offElsewhere: off.filter((u) => !keep.has(u.id)), parked, undeclared, errors }
340
355
  }
341
356
  return { selected, off, parked, undeclared, errors }
342
357
  }
@@ -346,35 +361,57 @@ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'ful
346
361
  * was still streaming into, so the surviving FAIL line pointed at a path
347
362
  * that no longer existed. Now each run owns `run-<pid>-<started>/`.
348
363
  *
349
- * Old run dirs are swept on the way in, and only ones nothing can be using:
350
- * the pid must not be running AND the dir must be older than the grace
351
- * window, so a recycled pid cannot cost a live run its logs. */
364
+ * Old run dirs are swept on the way in (sweepRunDirs), and only ones nothing
365
+ * can be using: the pid must not be running, so a live run keeps its logs. */
352
366
  function openRunDir(base: string): string {
353
367
  // the pid plus the millisecond separates two PROCESSES; the counter
354
368
  // separates two runs inside one (the TUI can start a second before the
355
369
  // clock ticks), so no run ever writes into another's dir
356
370
  const dir = join(base, `run-${process.pid}-${Date.now()}-${runSeq++}`)
357
371
  mkdirSync(dir, { recursive: true })
358
- const cutoff = Date.now() - RUN_DIR_KEEP_MS
372
+ sweepRunDirs(base, Date.now(), { keep: basename(dir) })
373
+ return dir
374
+ }
375
+
376
+ /** how long a finished run's logs stay readable, and the pid-reuse guard */
377
+ export const RUN_DIR_KEEP_MS = 6 * 60 * 60 * 1000
378
+ /** the most finished runs whose logs stay, newest first */
379
+ export const RUN_DIR_MAX = 40
380
+
381
+ const RUN_ENTRY = /^(?:run|releases)-(\d+)-(\d+)(?:-\d+)?(?:\.json(?:\.lock)?)?$/
382
+
383
+ const pidRuns = (pid: number): boolean => {
384
+ try { process.kill(pid, 0); return true } catch (e) { return (e as { code?: string }).code === 'EPERM' }
385
+ }
386
+
387
+ /** the run dirs under a log base, and the per run release survey files a
388
+ * killed run leaves beside them: both carry the pid and the start time */
389
+ export function runEntries(base: string): { name: string; pid: number; started: number }[] {
359
390
  try {
360
- for (const name of readdirSync(base)) {
361
- // run dirs AND the per-run release survey files, which a killed
362
- // run leaves behind: both carry the pid and the start time, so one
363
- // sweep covers both rather than one path leaking forever
364
- const m = /^(?:run|releases)-(\d+)-(\d+)(?:-\d+)?(?:\.json(?:\.lock)?)?$/.exec(name)
365
- if (m == null || name === basename(dir)) continue
366
- if (Number(m[2]) > cutoff) continue
367
- try { process.kill(Number(m[1]), 0); continue } catch (e) { if ((e as { code?: string }).code === 'EPERM') continue }
368
- rmSync(join(base, name), { recursive: true, force: true })
369
- }
391
+ return readdirSync(base).flatMap((name) => {
392
+ const m = RUN_ENTRY.exec(name)
393
+ return m == null ? [] : [{ name, pid: Number(m[1]), started: Number(m[2]) }]
394
+ })
370
395
  } catch {
371
- // an unreadable log base is a housekeeping miss, never a gate failure
396
+ return []
372
397
  }
373
- return dir
374
398
  }
375
399
 
376
- /** how long a finished run's logs stay readable, and the pid-reuse guard */
377
- const RUN_DIR_KEEP_MS = 6 * 60 * 60 * 1000
400
+ /** the run logs' cap: every entry past the keep window (no run lasts that
401
+ * long, so a live pid there is a long lived process's finished run, or a
402
+ * reused pid), then the oldest past `max` whose pid no longer runs. `keep`
403
+ * spares the caller's own dir */
404
+ export function sweepRunDirs(base: string, now: number, o: { keep?: string; keepMs?: number; max?: number } = {}): void {
405
+ const all = runEntries(base).sort((a, b) => b.started - a.started)
406
+ const cutoff = now - (o.keepMs ?? RUN_DIR_KEEP_MS)
407
+ all.forEach((e, i) => {
408
+ if (e.name === o.keep) return
409
+ const old = e.started <= cutoff
410
+ if (!old && i < (o.max ?? RUN_DIR_MAX)) return
411
+ if (!old && pidRuns(e.pid)) return
412
+ try { rmSync(join(base, e.name), { recursive: true, force: true }) } catch { /* an unreadable log base is a housekeeping miss */ }
413
+ })
414
+ }
378
415
  let runSeq = 0
379
416
 
380
417
  type Live = RunnableCheck & CheckResult & {
@@ -423,9 +460,9 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
423
460
 
424
461
  // OFF, in ONE place: skipped by name, never run, never stored, and the
425
462
  // reason printed where the verdict would have been
426
- for (const u of sel.off) {
463
+ for (const u of [...sel.off, ...(sel.offElsewhere ?? [])]) {
427
464
  const line: Line = { kind: 'skip', text: offLine(u.id, u.when) }
428
- emit(line)
465
+ if (sel.off.includes(u)) emit(line); else (o.detail ?? print)(formatLine(line, color))
429
466
  report(u, { id: u.id, status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [line], problems: [], lastLine: line.text, note: 'off', logPath: null })
430
467
  }
431
468
  // `--only` that leaves nothing to run asked a question nobody answers, and
@@ -477,10 +514,15 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
477
514
  * or that a different shipkit measured it. */
478
515
  const worldKey = (u: RunnableCheck): string => {
479
516
  let code = ''
480
- try { code = o.ledger?.layer.keyFor(u.id).codeKey ?? '' } catch { code = '' }
481
- // a check that writes answers a different question when frozen
482
- const frozen = u.writes === true ? `\0frozen=${process.env['SHIPKIT_FROZEN'] === '1'}` : ''
483
- return `tc=${o.ledger?.layer.toolchainId ?? ''}\0code=${code}\0${u.rotation ?? ''}${frozen}`
517
+ if (u.ttlMs < FOREVER_MS) try { code = o.ledger?.layer.keyFor(u.id).codeKey ?? '' } catch { code = '' }
518
+ return worldKeyOf({
519
+ ttlMs: u.ttlMs,
520
+ toolchain: u.ttlMs < FOREVER_MS ? o.ledger?.layer.toolchainId ?? '' : '',
521
+ code,
522
+ rotation: u.rotation ?? '',
523
+ // a check that writes answers a different question when frozen
524
+ frozen: u.writes === true ? process.env['SHIPKIT_FROZEN'] === '1' : null,
525
+ })
484
526
  }
485
527
 
486
528
  // a non-blocking failure becomes a WARNING here, at the source: printing
@@ -510,10 +552,18 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
510
552
  // baked into a remembered line comes back on a replay as if this run
511
553
  // had measured it (ledger/ledger.ts rule 5)
512
554
  const head = u.lines[0]
555
+ // a suite's count rides on its own line: `test 920 tests (1.4s)`
556
+ const ran = u.status === 'pass' || u.status === 'warn' ? testsRan(u.lastLine) : null
557
+ const counted = ran != null && head != null && head.kind === 'ok' && (head.text === u.id || head.text.startsWith(`${u.id} (`))
558
+ const id = counted ? `${u.id} ${ran} test${ran === 1 ? '' : 's'}` : u.id
513
559
  const shown = u.synthetic && !u.replayed && head != null
514
- ? [{ ...head, text: `${u.id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
515
- : u.lines
516
- for (const l of shown) emit(l)
560
+ ? [{ ...head, text: `${id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
561
+ : counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}` }, ...u.lines.slice(1)] : u.lines
562
+ // a caller printing its own verdict wants one line per green check and
563
+ // only the warnings of a warned one; a failure prints whole
564
+ const quiet = (l: Line, i: number): boolean =>
565
+ o.detail != null && (u.status === 'pass' ? i > 0 : u.status === 'warn' || u.advisory ? l.kind === 'ok' || l.kind === 'note' || l.kind === 'skip' : false)
566
+ for (const [i, l] of shown.entries()) if (quiet(l, i)) o.detail!(formatLine(l, color)); else emit(l)
517
567
  if (u.status === 'fail' && !u.advisory && u.logPath != null)
518
568
  emit({ kind: 'fix-fail', text: `full log: ${u.logPath}` })
519
569
  report(u, { id: u.id, status: u.status, advisory: u.advisory, replayed: u.replayed, ms: u.ms, exitCode: u.exitCode, lines: u.lines, problems: u.problems, lastLine: u.lastLine, note: u.note, logPath: u.logPath }, finishedAt, u.attached)
@@ -820,7 +870,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
820
870
  const warned = results.filter((r) => r.status === 'warn' || (r.status === 'fail' && r.advisory))
821
871
  const ran = results.filter((r) => r.status !== 'skip').length
822
872
  if (blocked.length === 0 && !interrupted && results.length > 0)
823
- emit({ kind: 'ok', text: warned.length === 0 ? `all ${ran} check(s) green` : `all ${ran} check(s) passed, ${warned.length} with a warning: ${warned.map((r) => r.id).join(', ')}` })
873
+ (o.detail ?? print)(formatLine({ kind: 'ok', text: warned.length === 0 ? `all ${ran} check(s) green` : `all ${ran} check(s) passed, ${warned.length} with a warning: ${warned.map((r) => r.id).join(', ')}` }, color))
824
874
  return {
825
875
  ok: blocked.length === 0 && !interrupted,
826
876
  interrupted,
@@ -17,11 +17,13 @@
17
17
  //
18
18
  // Skipped dirs: node_modules, dist, tmp, references (templates for other
19
19
  // repos), and every dot-dir.
20
+ import { RETIRED_CHECK_IDS } from './retired-checks.ts'
20
21
  import { readdirSync, readFileSync, realpathSync, statSync } from 'fs'
21
22
  import { basename, join, relative } from 'path'
22
23
  import { check, type CheckDef, type CheckFrom } from '../check.ts'
23
24
 
24
- import { CHECK_SUFFIX as SUFFIX, fallbackNames, isLegacyCheckSource, SKIPPED_DIRS as IGNORED_DIRS } from './check-file-names.ts'
25
+ import { CHECK_SUFFIX as SUFFIX, fallbackNames, isLegacyCheckSource, SKIPPED_DIRS as IGNORED_DIRS, UNIT_SUFFIX, unitBase } from './check-file-names.ts'
26
+ import { unitKind } from '../shipkit-files.ts'
25
27
 
26
28
  export { fallbackNames, isLegacyCheckSource } from './check-file-names.ts'
27
29
 
@@ -54,16 +56,39 @@ export type DiscoveredCheck = {
54
56
  from: CheckFrom
55
57
  }
56
58
 
57
- function walk(dir: string, out: string[]): void {
59
+ function walk(dir: string, out: string[], suffix = SUFFIX): void {
58
60
  let entries
59
61
  try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
60
62
  for (const e of entries) {
61
63
  if (e.isDirectory()) {
62
- if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out)
63
- } else if (e.name.endsWith(SUFFIX)) out.push(join(dir, e.name))
64
+ if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix)
65
+ } else if (e.name.endsWith(suffix)) out.push(join(dir, e.name))
64
66
  }
65
67
  }
66
68
 
69
+ /** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted */
70
+ export function repoUnitFiles(root: string): string[] {
71
+ const out: string[] = []
72
+ walk(root, out, UNIT_SUFFIX)
73
+ return out.sort()
74
+ }
75
+
76
+ /** a file's default export, re-imported whenever the file on disk moves. An
77
+ * ES module is cached by path for the life of the process, so a repo that
78
+ * rewrites a file mid process would keep the old one without the stamp */
79
+ export async function importFresh(path: string): Promise<unknown> {
80
+ let stamp = ''
81
+ // no dot in the stamp: a query ending `.1234` reads as the module's
82
+ // extension, and a load plugin then never sees the file
83
+ try { stamp = String(statSync(path).mtimeMs).replace('.', '_') } catch {}
84
+ // the REAL path: through a linked one, the resolver misses a file
85
+ // created after it first listed the directory, which is exactly the
86
+ // file a fix pass just wrote
87
+ let real = path
88
+ try { real = realpathSync(path) } catch {}
89
+ return ((await import(`${real}?v=${stamp}`)) as { default?: unknown }).default
90
+ }
91
+
67
92
  /** every `*.check.ts` file under a repo root, check files and legacy
68
93
  * scripts alike, by the same walk discovery takes, without importing one.
69
94
  * Sorted. */
@@ -98,22 +123,14 @@ export function builtinCheckIds(): string[] {
98
123
  /** the module, re-imported whenever the file on disk moves. An ES module is
99
124
  * cached by path for the life of the process, so a repo that rewrites a
100
125
  * check file mid process would keep running the old body without this. */
101
- async function load(path: string): Promise<CheckDef> {
102
- let stamp = ''
103
- // no dot in the stamp: a query ending `.1234` reads as the module's
104
- // extension, and a load plugin then never sees the file
105
- try { stamp = String(statSync(path).mtimeMs).replace('.', '_') } catch {}
126
+ async function load(path: string): Promise<CheckDef | null> {
106
127
  // the path rides on every message from here down, because `check()` knows
107
128
  // the object and not where it was written, and an agent reading a refused
108
129
  // declaration needs the file before it needs the rule
109
130
  try {
110
- // the REAL path: through a linked one, the resolver misses a file
111
- // created after it first listed the directory, which is exactly the
112
- // file a fix pass just wrote
113
- let real = path
114
- try { real = realpathSync(path) } catch {}
115
- const mod = (await import(`${real}?v=${stamp}`)) as { default?: unknown }
116
- const d = mod.default
131
+ const d = await importFresh(path)
132
+ // a unit file of another kind is its reader's, never a check
133
+ if (path.endsWith(UNIT_SUFFIX) && unitKind(d) !== 'check') return null
117
134
  if (d == null || typeof d !== 'object' || (!('run' in d) && !('cmd' in d)))
118
135
  throw new Error(`a check file must \`export default check({...})\` with a \`run\` or a \`cmd\``)
119
136
  // re-validated here rather than trusted: a file can build the object
@@ -133,6 +150,7 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
133
150
  walk(BUILTIN_CHECK_DIR, builtins)
134
151
  const found: string[] = []
135
152
  walk(root, found)
153
+ const units = repoUnitFiles(root)
136
154
  // the repo that HOSTS the toolkit walks over `src/checks/` too, and a file
137
155
  // discovered twice is a duplicate id rather than a second check. Real paths,
138
156
  // because a consumer reaches the same files through a linked node_modules
@@ -147,17 +165,19 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
147
165
 
148
166
  const out: DiscoveredCheck[] = []
149
167
  const ids = new Set<string>()
150
- for (const f of [...builtins, ...own]) {
168
+ for (const f of [...builtins, ...own, ...units]) {
151
169
  const isBuiltin = builtins.includes(f)
152
170
  const def = await load(f)
171
+ if (def == null) continue
153
172
  const rel = isBuiltin ? null : relative(root, f).replace(/\\/g, '/')
154
173
  const fallback = isBuiltin
155
174
  ? (BUILTIN_ID_RE.exec(base(f))?.[1] ?? base(f))
156
- : names.get(f)!
175
+ : f.endsWith(UNIT_SUFFIX) ? unitBase(f) : names.get(f)!
157
176
  const id = def.id ?? fallback
158
177
  if (isBuiltin) {
159
178
  if (id !== fallback) throw new Error(`${f}: declares id '${id}' but its file name says '${fallback}' — a built-in id is its file name`)
160
179
  if ((def.owner ?? '') === '') throw new Error(`${f}: a built-in check names the page that OWNS the invariant`)
180
+ if (RETIRED_CHECK_IDS[id] != null) throw new Error(`${f}: id '${id}' is retired (${RETIRED_CHECK_IDS[id]}), and an id is never reused`)
161
181
  } else if (id.startsWith('SK')) {
162
182
  throw new Error(`${rel}: id '${id}' uses the reserved SK prefix — a repo check picks its own (e.g. RV${id.slice(-3)}) or plain words`)
163
183
  }
@@ -0,0 +1,20 @@
1
+ // the content of a file no tree id reaches (outside the repo, gitignored, an
2
+ // install), as one short hash for a check's `cacheKey`. `absent` when nothing
3
+ // stands there, so a file appearing or vanishing moves the key too.
4
+ import { createHash } from 'crypto'
5
+ import { readFileSync } from 'fs'
6
+ import { homedir } from 'os'
7
+ import { isAbsolute, join } from 'path'
8
+
9
+ export function fileStamp(abs: string): string {
10
+ try {
11
+ return createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16)
12
+ } catch {
13
+ return 'absent'
14
+ }
15
+ }
16
+
17
+ /** a path as a config declares it (`~/...`, absolute, or repo relative), absolute */
18
+ export function configPath(root: string, p: string): string {
19
+ return p.startsWith('~/') ? join(homedir(), p.slice(2)) : isAbsolute(p) ? p : join(root, p)
20
+ }
@@ -1,27 +1,30 @@
1
- // SK021: the gh login the toolkit itself runs on: `init` and `start` create
2
- // a PRIVATE repo with it, every push and release goes through it, SK007 reads
3
- // visibility with it. gh keeps that login outside any secrets manager, so this
4
- // probe is the only place a lost one shows. It is a `core` credential: red on
5
- // the secrets page and in `shipkit needs` (collections/providers.ts reads the
6
- // `cred <name> missing` line), while the check itself is about the world, so
7
- // it never blocks a gate and runs in the health pass only.
1
+ // the gh login the toolkit itself runs on: `init` and `start` create a
2
+ // PRIVATE repo with it, every push and release goes through it, SK007 reads
3
+ // visibility with it. gh keeps that login outside any secrets manager, so the
4
+ // house repo's one `github-login` check (a machine fact, asked once, never in
5
+ // every repo) runs `probeGhLogin`. It is a `core` credential: red on the
6
+ // secrets page and in `shipkit needs` (collections/providers.ts reads the
7
+ // `cred <name> missing` line).
8
8
  //
9
9
  // A private repo needs the classic `repo` scope. The token itself is never
10
10
  // asked for: `gh auth status --json` carries no token field, and the text
11
11
  // answer masks it. github.com unreachable is `unreachable`, a WARN that
12
12
  // measured nothing, never a lost login.
13
13
  import { carveGhJson, firstGhLine } from '../git-bootstrap.ts'
14
- import { check } from '../check.ts'
14
+ import type { CheckCtx } from './check-ctx.ts'
15
15
 
16
16
  const HOST = 'github.com'
17
17
 
18
- /** the secrets page row this check proves: a built-in `core` credential
19
- * every repo has, shown once SK021 has run there */
18
+ /** the id of the house check that runs `probeGhLogin` */
19
+ export const GH_LOGIN_CHECK = 'github-login'
20
+
21
+ /** the secrets page row the probe proves: a built-in `core` credential, shown
22
+ * in the repo where the `github-login` check has run */
20
23
  export const GH_LOGIN_CRED = {
21
24
  name: 'gh/github/login',
22
25
  service: 'gh login',
23
26
  level: 'core' as const,
24
- probedBy: 'SK021',
27
+ probedBy: GH_LOGIN_CHECK,
25
28
  mintUrl: 'https://cli.github.com/manual/gh_auth_login',
26
29
  /** what the row offers to copy when no failing line names a better fix */
27
30
  command: 'gh auth login',
@@ -76,48 +79,36 @@ export function readGhText(out: string): GhLogin {
76
79
  return { state: 'ok', login: login[1]!, scopes: scopesOf(scopes?.[1]) }
77
80
  }
78
81
 
79
- export const githubLogin = check({
80
- id: 'SK021',
81
- // about the world, never this repo: a lost login warns in a gate and is
82
- // red on the secrets page and in needs, through the core credential row
83
- blocking: false,
84
- title: 'github login',
85
- description: 'gh is logged in to github.com with the repo scope shipkit creates repos with',
86
- owner: 'build/shipkit/built-in-checks.md',
87
- when: 'health',
88
- ttl: '1h',
89
- run: async (c) => {
90
- const cred = GH_LOGIN_CRED.name
91
- // the switch that keeps every github step off (git-bootstrap.ts), which every test suite sets
92
- if ((process.env['SHIPKIT_NO_GITHUB'] ?? '') !== '') return void c.lines.push({ kind: 'skip', text: 'gh login not probed, SHIPKIT_NO_GITHUB is set' })
93
- const gh = c.tool('gh')
94
- if (gh == null)
95
- return c.fail(`cred ${cred} missing: gh is not installed, so shipkit cannot create a repo, push or release`, 'install gh (https://cli.github.com), then run `gh auth login`')
96
-
97
- let r = await c.exec([gh, 'auth', 'status', '--hostname', HOST, '--active', '--json', 'hosts'], { cwd: c.root, timeoutMs: 20_000 })
98
- let seen: GhLogin
99
- if (r.code !== 0 && /unknown flag/i.test(r.out)) {
100
- r = await c.exec([gh, 'auth', 'status', '--hostname', HOST], { cwd: c.root, timeoutMs: 20_000 })
101
- seen = readGhText(r.out)
102
- } else seen = r.timedOut ? { state: 'unreachable', why: 'gh auth status timed out' } : readGhJson(r.out)
82
+ /** the probe: logged in to github.com, with the repo scope */
83
+ export async function probeGhLogin(c: CheckCtx): Promise<void> {
84
+ const cred = GH_LOGIN_CRED.name
85
+ // the switch that keeps every github step off (git-bootstrap.ts), which every test suite sets
86
+ if ((process.env['SHIPKIT_NO_GITHUB'] ?? '') !== '') return void c.lines.push({ kind: 'skip', text: 'gh login not probed, SHIPKIT_NO_GITHUB is set' })
87
+ const gh = c.tool('gh')
88
+ if (gh == null)
89
+ return c.fail(`cred ${cred} missing: gh is not installed, so shipkit cannot create a repo, push or release`, 'install gh (https://cli.github.com), then run `gh auth login`')
103
90
 
104
- switch (seen.state) {
105
- case 'none':
106
- return c.fail(`cred ${cred} missing: gh is not logged in to ${HOST}, so shipkit cannot create a repo, push or release`, 'gh auth login')
107
- case 'refused':
108
- return c.fail(`cred ${cred} missing: the gh login from ${seen.source} was refused by ${HOST} (${seen.why.slice(0, 120)})`, 'gh auth login')
109
- case 'unreachable':
110
- return c.unreachable(`gh login unverified: ${HOST} unreachable (${seen.why.slice(0, 120)})`)
111
- case 'unreadable':
112
- return c.unreachable(`gh login unverified: gh answered something unreadable (${seen.why})`)
113
- case 'ok':
114
- if (seen.scopes == null)
115
- return c.warn(`gh is logged in to ${HOST} as ${seen.login}, but its token lists no oauth scopes (a fine grained or app token), so creating a private repo is unverified`, 'gh auth login')
116
- if (!seen.scopes.includes('repo'))
117
- return c.fail(`cred ${cred} missing: the gh login ${seen.login} lacks the repo scope (it has ${seen.scopes.join(', ')}), so shipkit cannot create a private repo`, `gh auth refresh -h ${HOST} -s repo`)
118
- return c.ok(`gh is logged in to ${HOST} as ${seen.login}, with the repo scope`)
119
- }
120
- },
121
- })
91
+ let r = await c.exec([gh, 'auth', 'status', '--hostname', HOST, '--active', '--json', 'hosts'], { cwd: c.root, timeoutMs: 20_000 })
92
+ let seen: GhLogin
93
+ if (r.code !== 0 && /unknown flag/i.test(r.out)) {
94
+ r = await c.exec([gh, 'auth', 'status', '--hostname', HOST], { cwd: c.root, timeoutMs: 20_000 })
95
+ seen = readGhText(r.out)
96
+ } else seen = r.timedOut ? { state: 'unreachable', why: 'gh auth status timed out' } : readGhJson(r.out)
122
97
 
123
- export default githubLogin
98
+ switch (seen.state) {
99
+ case 'none':
100
+ return c.fail(`cred ${cred} missing: gh is not logged in to ${HOST}, so shipkit cannot create a repo, push or release`, 'gh auth login')
101
+ case 'refused':
102
+ return c.fail(`cred ${cred} missing: the gh login from ${seen.source} was refused by ${HOST} (${seen.why.slice(0, 120)})`, 'gh auth login')
103
+ case 'unreachable':
104
+ return c.unreachable(`gh login unverified: ${HOST} unreachable (${seen.why.slice(0, 120)})`)
105
+ case 'unreadable':
106
+ return c.unreachable(`gh login unverified: gh answered something unreadable (${seen.why})`)
107
+ case 'ok':
108
+ if (seen.scopes == null)
109
+ return c.warn(`gh is logged in to ${HOST} as ${seen.login}, but its token lists no oauth scopes (a fine grained or app token), so creating a private repo is unverified`, 'gh auth login')
110
+ if (!seen.scopes.includes('repo'))
111
+ return c.fail(`cred ${cred} missing: the gh login ${seen.login} lacks the repo scope (it has ${seen.scopes.join(', ')}), so shipkit cannot create a private repo`, `gh auth refresh -h ${HOST} -s repo`)
112
+ return c.ok(`gh is logged in to ${HOST} as ${seen.login}, with the repo scope`)
113
+ }
114
+ }