rvlib-shipkit 1.3.0 → 1.4.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 (80) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +4 -2
  4. package/src/assets/kits/identity-kit.ts +15 -13
  5. package/src/assets/run-assets.ts +12 -2
  6. package/src/check.ts +6 -4
  7. package/src/checks/SK001-package-json-shape.check.ts +20 -2
  8. package/src/checks/SK009-commit-ai-tells.check.ts +90 -72
  9. package/src/checks/SK014-prose-ste.check.ts +16 -6
  10. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  11. package/src/checks/SK027-agent-wiki-links.check.ts +30 -8
  12. package/src/checks/SK028-readme.check.ts +6 -2
  13. package/src/cli/cmd_CrossRepoActivity.ts +4 -1
  14. package/src/collections/collection-types.ts +15 -0
  15. package/src/collections/providers.ts +6 -2
  16. package/src/contracts/apply-contracts.ts +9 -8
  17. package/src/contracts/contract-types.ts +8 -2
  18. package/src/contracts/contracts-engine.ts +34 -13
  19. package/src/contracts/manifests/identity.contract.ts +6 -5
  20. package/src/contracts/manifests/social.contract.ts +4 -2
  21. package/src/contracts/manifests/steam.contract.ts +7 -3
  22. package/src/contracts/web-surface.ts +59 -0
  23. package/src/core/budget.ts +19 -9
  24. package/src/core/check-runner.ts +26 -5
  25. package/src/core/discover-checks.ts +8 -5
  26. package/src/core/layout.ts +2 -0
  27. package/src/core/motherhome.ts +88 -66
  28. package/src/core/run-check.ts +2 -5
  29. package/src/core/run-ci.ts +3 -3
  30. package/src/core/wire.ts +38 -9
  31. package/src/home/activity-shape.ts +4 -1
  32. package/src/home/activity.ts +22 -15
  33. package/src/home/gui-app/ShipkitGui.swift +144 -25
  34. package/src/home/gui-app.ts +93 -30
  35. package/src/home/home-launchd.ts +10 -2
  36. package/src/home/home-routes.ts +2 -1
  37. package/src/home/home-start.ts +33 -5
  38. package/src/home/machine-stores.ts +4 -2
  39. package/src/home/open-app.ts +23 -10
  40. package/src/home/pm-config.ts +15 -11
  41. package/src/home/pm-inputs.ts +76 -0
  42. package/src/home/pm.ts +3 -3
  43. package/src/home/process-rows.ts +23 -6
  44. package/src/home/resources.ts +67 -15
  45. package/src/home/run-daemon.ts +6 -1
  46. package/src/home/run-pm.ts +29 -5
  47. package/src/home/widgets.ts +32 -3
  48. package/src/hooks/commit-message.ts +2 -3
  49. package/src/hooks/run-hooks.ts +12 -2
  50. package/src/ledger/gate-checks.ts +3 -3
  51. package/src/ledger/store.ts +11 -0
  52. package/src/ledger/world-cache.ts +17 -15
  53. package/src/repo-config.ts +6 -1
  54. package/src/serve/hub-app/activity-view.tsx +3 -3
  55. package/src/serve/hub-app/checks-svc.ts +2 -1
  56. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  57. package/src/serve/hub-app/col-tables.ts +115 -0
  58. package/src/serve/hub-app/cols-svc.ts +28 -0
  59. package/src/serve/hub-app/dock.css +1 -1
  60. package/src/serve/hub-app/home-st.ts +1 -1
  61. package/src/serve/hub-app/home-strip.css +32 -17
  62. package/src/serve/hub-app/home-strip.tsx +99 -89
  63. package/src/serve/hub-app/hub-app.tsx +7 -1
  64. package/src/serve/hub-app/hub-types.ts +6 -3
  65. package/src/serve/hub-app/hub.css +24 -2
  66. package/src/serve/hub-app/icons.tsx +4 -0
  67. package/src/serve/hub-app/keys.ts +6 -6
  68. package/src/serve/hub-app/machine-st.ts +26 -37
  69. package/src/serve/hub-app/palette-st.ts +5 -5
  70. package/src/serve/hub-app/palette-view.tsx +1 -1
  71. package/src/serve/hub-app/project-list.tsx +3 -3
  72. package/src/serve/hub-app/repo-st.ts +1 -1
  73. package/src/serve/hub-app/runs-tabs.ts +1 -1
  74. package/src/serve/hub-app/timeline-view.tsx +14 -11
  75. package/src/serve/project-gutter.ts +1 -2
  76. package/src/serve/warm-cell.ts +49 -16
  77. package/src/suite/judge-suites.ts +42 -17
  78. package/src/suite/suite-cli.ts +5 -8
  79. package/src/wiki/lint-md.ts +2 -0
  80. package/src/wiki/scan-md.ts +37 -10
@@ -1,8 +1,8 @@
1
- // the one picker of the page (cmd+k, keys.ts), a node of the root: the
2
- // home's places (home, planned, activity) first, then the shown repo's own jump
1
+ // the one picker of the page (cmd+j, keys.ts), a node of the root: the
2
+ // home's places (home, activity, planned, in the list's order) first, then the shown repo's own jump
3
3
  // targets, then every other house repo as a place to go. Another repo's
4
4
  // resources are never listed: a jump to a repo selects it in place, and its
5
- // own targets are one more cmd+k away.
5
+ // own targets are one more cmd+j away.
6
6
  import { makeAutoObservable } from 'mobx'
7
7
  import type { HomeSt } from './home-st.ts'
8
8
  import type { Jump } from './repo-st.ts'
@@ -25,8 +25,8 @@ export class PaletteSt {
25
25
  if (st.machine == null) return []
26
26
  return [
27
27
  { id: 'place:home', label: 'home', section: 'place', face: { place: 'home' }, run: () => st.navigate('/') },
28
- { id: 'place:planned', label: 'planned', section: 'place', face: { place: 'planned' }, run: () => st.navigate('/planned') },
29
28
  { id: 'place:activity', label: 'activity', section: 'place', face: { place: 'activity' }, run: () => st.navigate('/activity') },
29
+ { id: 'place:planned', label: 'planned', section: 'place', face: { place: 'planned' }, run: () => st.navigate('/planned') },
30
30
  { id: 'place:archived', label: 'archived', section: 'place', face: { place: 'archived' }, run: () => st.navigate('/archived') },
31
31
  ]
32
32
  }
@@ -45,7 +45,7 @@ export class PaletteSt {
45
45
  }
46
46
 
47
47
  /** ranked: prefix matches first, then substring. With no query, home,
48
- * planned and activity, then the shown repo's first targets, then every other repo,
48
+ * activity and planned, then the shown repo's first targets, then every other repo,
49
49
  * so a jump elsewhere is always in reach */
50
50
  get results(): Jump[] {
51
51
  const q = this.query.trim().toLowerCase()
@@ -1,4 +1,4 @@
1
- // the one picker, cmd+k (palette-st.ts): home, planned, activity and
1
+ // the one picker, cmd+j (palette-st.ts): home, activity, planned and
2
2
  // archived, the shown repo's targets, then every other repo. A place wears
3
3
  // its button's icon, a repo its own icon, as the project list shows them.
4
4
  import { observer } from 'mobx-react-lite'
@@ -14,7 +14,7 @@
14
14
  // in), archived shows or hides the archived repos just above the foot
15
15
  // (names only, no ⌘ number, a row opens the archived page, nothing loads),
16
16
  // the foot never moving. On the home a section of four lines on top, in the
17
- // repo rows' style, opens the overview, the picker, the activity and the
17
+ // repo rows' style, opens the picker, the overview, the activity and the
18
18
  // plan, each with its shortcut at the line's end, the page shown lit. It
19
19
  // renders from the first paint with the list this browser kept, so a load
20
20
  // moves nothing.
@@ -30,11 +30,11 @@ import { cardModel, faceOf, hueOf, SORTS, type Place, type Project, type Project
30
30
  /** what the list needs of its page: the hub and the home both carry one */
31
31
  export type ListHost = { projects: ProjectsSvc }
32
32
 
33
- /** the lines on top of the list, in their order: the overview, the picker,
33
+ /** the lines on top of the list, in their order: the picker, the overview,
34
34
  * then the places, their shortcuts ⌘J ⌘K ⌘L ⌘' top to bottom */
35
35
  const PLACES: { id: Place | 'picker'; label: string; title: string }[] = [
36
- { id: 'overview', label: 'overview', title: 'the overview: shipkit, every global process and every repo on this machine' },
37
36
  { id: 'picker', label: 'jump to…', title: 'jump to a page, a repo or one of its resources' },
37
+ { id: 'overview', label: 'overview', title: 'the overview: shipkit, every global process and every repo on this machine' },
38
38
  { id: 'activity', label: 'activity', title: 'what every repo committed lately, what it holds uncommitted, its branch and whether it is pushed' },
39
39
  { id: 'planned', label: 'planned', title: 'every check the machine runs on its own: now, next, late, and why' },
40
40
  ]
@@ -27,7 +27,7 @@ import type { HomeSt } from './home-st.ts'
27
27
 
28
28
  export type Sel = HashSel | null
29
29
 
30
- /** one picker target (cmd+k): run() fires the SAME action clicking would */
30
+ /** one picker target (cmd+j): run() fires the SAME action clicking would */
31
31
  /** what a picker entry wears: a repo's own icon, or a place's button icon */
32
32
  export type JumpFace = { repo: string } | { place: 'home' | 'planned' | 'activity' | 'archived' }
33
33
  /** `key`: the shortcut that does the same, printed at the entry's end */
@@ -7,7 +7,7 @@ export type RunsTab = 'runs' | 'files' | 'slowest' | 'longest'
7
7
 
8
8
  /** the four lists, in tab order. `key` is what the address carries */
9
9
  export const RUNS_TABS: { key: RunsTab; label: string; help: string }[] = [
10
- { key: 'runs', label: 'runs', help: `every run on record, newest first: shipkit ci, shipkit check, the hub's buttons, and the runs the plan starts. One line per run: when, who started it, what ran, its wall time, how many checks ${RAN}, how many were ${CACHED} (answered from the cache, nothing ran), and its reds. Unfold a run for one line per check, named by its title with its id as the small tag, in a box that scrolls: its reds first, then what ${RAN}, slowest first, then what was ${CACHED}. Click a check for its page. Every run in time, and how many ran at once, is the chart over the tabs: press fit for the whole record` },
10
+ { key: 'runs', label: 'runs', help: `every run on record, newest first: shipkit ci, shipkit check, the hub's buttons, and the runs the plan starts. One line per run: when, who started it, what ran, its wall time, how many checks ${RAN}, how many were ${CACHED} (answered from the cache, nothing ran), and its reds. Unfold a run for one line per check, named by its title with its id as the small tag, in a box that scrolls: its reds first, then what ${RAN}, slowest first, then what was ${CACHED}. Click a check for its page. Every run in time, and how many ran at once, is the chart under this list: press fit for the whole record` },
11
11
  { key: 'files', label: 'test files to fix', help: "bun test files, not checks: each file's last recorded run inside the check that ran it (test, test:<unit>), kept when a test in it failed or when it broke the quick tier budget per file. The same verdict colours the file in the tree under tests. Click a file for its page, the check tag for the check" },
12
12
  { key: 'slowest', label: 'slowest checks', help: 'the checks that cost the most when they really run: the mean and the worst of the runs that ran. A check whose worst run passed half its ceiling is flagged, because the next slow machine trips it' },
13
13
  { key: 'longest', label: 'longest runs', help: 'the invocations on record that took the longest, start to end, whatever ran them' },
@@ -5,9 +5,10 @@
5
5
  // Every line sits on a grid of fixed columns (the .rline grids in hub.css):
6
6
  // numbers right aligned in tabular figures, ran and cached each a column of
7
7
  // their own told apart by colour, and no separator glyph on any line. The
8
- // shaping is the server's (ledger/run-timeline.ts). Over the tabs, the one run
9
- // chart (timeline-chart.tsx) draws this repo's whole record in time; what
10
- // runs now in every repo is the dock at the bottom of the page.
8
+ // shaping is the server's (ledger/run-timeline.ts). The tabs come first.
9
+ // Under the runs tab's list, and only there, the one run chart
10
+ // (timeline-chart.tsx) draws this repo's whole record in time; what runs now
11
+ // in every repo is the dock at the bottom of the page.
11
12
  import { observer } from 'mobx-react-lite'
12
13
  import { useEffect } from 'react'
13
14
  import { CACHED, RAN } from '../../core/outcome-words.ts'
@@ -129,7 +130,10 @@ const RunChecks = observer(({ app, bars }: { app: RepoSt; bars: TimelineBar[] })
129
130
  })
130
131
 
131
132
  const Runs = ({ app, rows }: { app: RepoSt; rows: TimelineInvocation[] }) => (
132
- rows.length === 0 ? <div className="rempty">no run on record yet</div> : <>{rows.map((inv, n) => <RunLine key={inv.runId} app={app} inv={inv} n={n} />)}</>
133
+ <>
134
+ {rows.length === 0 ? <div className="rempty">no run on record yet</div> : rows.map((inv, n) => <RunLine key={inv.runId} app={app} inv={inv} n={n} />)}
135
+ <RecordChart app={app} />
136
+ </>
133
137
  )
134
138
 
135
139
  /** what a test file to fix did: `red`, or its time against the budget */
@@ -224,7 +228,7 @@ const Tabs = observer(({ app, on }: { app: RepoSt; on: RunsTab }) => {
224
228
  )
225
229
  })
226
230
 
227
- /** what the chart over the tabs is, behind its (?) */
231
+ /** what the chart under the runs list is, behind its (?) */
228
232
  export const RECORD_HELP = 'every check this repo ran, on record: the run bands, the time axis, and over the lanes how many checks ran at once, with the cap dashed and the stretches past it in orange. It opens on the last ten minutes and follows now: the right edge is the present. The wheel zooms and a drag pans; leaving the right edge stops the view where it is so a bar can be read, and ● live, the k key or panning back to the right edge follows now again. While a tooltip is open the view stands still. fit shows every run on record, and a click on a bar opens its check. What runs in every repo right now is the dock at the bottom of the page'
229
233
 
230
234
  /** this repo's whole record in time, its height held so it never jumps
@@ -246,17 +250,16 @@ export const TimelineView = observer(({ app }: { app: RepoSt }) => {
246
250
  const on = runsTab(app.sel?.t === 'item' ? app.sel.id : null)
247
251
  return (
248
252
  <div className="colcard tlcard">
249
- <RecordChart app={app} />
250
253
  <Tabs app={app} on={on} />
251
254
  {t.error !== '' ? <div className="warnline">{t.error}</div> : null}
252
- {d == null ? <div className="dim twait"><span className="spin" />reading the run record…</div> : (
253
- <div className={`rlist ${on}`}>
254
- {on === 'runs' ? <Runs app={app} rows={d.invocations} />
255
+ {/* the wait line sits where the list will, so the list never moves in */}
256
+ <div className={`rlist ${on}`}>
257
+ {d == null ? <div className="dim twait"><span className="spin" />reading the run record…</div>
258
+ : on === 'runs' ? <Runs app={app} rows={d.invocations} />
255
259
  : on === 'files' ? <TestFiles app={app} rows={d.testFiles} recorded={d.suitesRecorded} />
256
260
  : on === 'slowest' ? <Slowest app={app} rows={d.slowest} />
257
261
  : <Longest app={app} rows={d.longest} />}
258
- </div>
259
- )}
262
+ </div>
260
263
  </div>
261
264
  )
262
265
  })
@@ -14,11 +14,10 @@ import { DB_FILE } from '../ledger/db.ts'
14
14
  import { defaultHubPort } from './hub-port.ts'
15
15
  import { LAYOUT } from '../core/layout.ts'
16
16
  import { isArchived } from '../core/archived.ts'
17
+ import { CLAUDE_FILES } from '../home/pm-inputs.ts'
17
18
 
18
19
  export type WiredRepo = { name: string; path: string; icon: string | null }
19
20
 
20
- /** the files a repo carries its includes in */
21
- const CLAUDE_FILES = ['CLAUDE.md', 'CLAUDE.local.md']
22
21
 
23
22
  /** the absolute md file of every `@<path>.md` include line a repo's CLAUDE
24
23
  * files carry, in order. A sentence that mentions a path is no include */
@@ -1,12 +1,21 @@
1
1
  // one answer of a hub kept warm in memory: computed when the hub loads,
2
2
  // recomputed in the background when something says it moved (a save, a
3
- // stamp, its age), and served from memory. A request never pays for the
4
- // walk behind it, except the very first one and one that asks for `fresh`.
3
+ // stamp, its age) while a page reads it, and served from memory. A cell
4
+ // nobody read for WARM_WATCH_MS computes nothing on a save: its next reader
5
+ // waits on one compute instead, so an unwatched repo costs no cpu per save.
5
6
  //
6
- // the one constraint a reader breaks first: `get()` answers what is in
7
- // memory even when it is known stale. Whoever just changed the truth (an
8
- // act, the end of a run) asks for `fresh`, which waits on a compute that
9
- // started after its call.
7
+ // the one constraint a reader breaks first: `get()` from a watched cell
8
+ // answers what is in memory even when it is known stale. Whoever just
9
+ // changed the truth (an act, the end of a run) asks for `fresh`, which waits
10
+ // on a compute that started after its call.
11
+
12
+ /** a cell read within this long has a reader: a save recomputes it in the
13
+ * background. Past it, nothing recomputes until a reader asks */
14
+ export const WARM_WATCH_MS = 30_000
15
+
16
+ /** a background recompute waits this many times the last compute's length
17
+ * after it ended: a 1.5s answer at most every 15s, a 50ms one at once */
18
+ export const WARM_SPACING = 10
10
19
 
11
20
  export type WarmCellOpts = {
12
21
  /** a cheap fingerprint of what the answer reads (file stats): a new one
@@ -20,6 +29,10 @@ export type WarmCellOpts = {
20
29
 
21
30
  export class WarmCell<T> {
22
31
  private value: { v: T; at: number; stamp: string } | null = null
32
+ /** when a reader last asked, null before any */
33
+ private readAt: number | null = null
34
+ /** when the last compute ended and how long it took */
35
+ private last: { end: number; ms: number } | null = null
23
36
  private running: Promise<T> | null = null
24
37
  /** bumped by invalidate: a compute that started before it is stale */
25
38
  private gen = 0
@@ -37,6 +50,7 @@ export class WarmCell<T> {
37
50
  const gen = this.gen
38
51
  const stamp = this.o.stamp?.() ?? ''
39
52
  this.runGen = gen
53
+ const start = this.now()
40
54
  const p = (async (): Promise<T> => {
41
55
  try {
42
56
  // never synchronously: `running` is set before this settles
@@ -44,6 +58,8 @@ export class WarmCell<T> {
44
58
  this.value = { v, at: this.now(), stamp }
45
59
  return v
46
60
  } finally {
61
+ const end = this.now()
62
+ this.last = { end, ms: end - start }
47
63
  this.running = null
48
64
  // what said it moved while this ran is not in this answer
49
65
  if (this.gen !== gen) this.kick()
@@ -53,11 +69,19 @@ export class WarmCell<T> {
53
69
  return p
54
70
  }
55
71
 
56
- /** a compute in the background: on a failure the last answer stays */
72
+ /** a compute in the background, at most once per WARM_SPACING times the
73
+ * last one: a kick too soon is dropped, and the next read past the gap
74
+ * kicks again. On a failure the last answer stays */
57
75
  private kick(): void {
76
+ if (this.last != null && this.now() < this.last.end + WARM_SPACING * this.last.ms) return
58
77
  void this.run().catch(() => {})
59
78
  }
60
79
 
80
+ /** a reader asked within WARM_WATCH_MS */
81
+ private get watched(): boolean {
82
+ return this.readAt != null && this.now() - this.readAt < WARM_WATCH_MS
83
+ }
84
+
61
85
  get stale(): boolean {
62
86
  const v = this.value
63
87
  if (v == null) return true
@@ -68,23 +92,32 @@ export class WarmCell<T> {
68
92
  /** the answer in memory, a stale one kicking a recompute behind it. With
69
93
  * nothing in memory yet, or `fresh`, it waits on a compute */
70
94
  async get(o: { fresh?: boolean } = {}): Promise<T> {
71
- if (o.fresh === true) {
72
- // a compute that started before this call may miss what the
73
- // caller just changed: wait for it, then for one of our own
74
- if (this.running != null) await this.running.catch(() => {})
75
- return this.run()
76
- }
95
+ const watched = this.watched
96
+ this.readAt = this.now()
97
+ if (o.fresh === true) return this.fresh()
77
98
  const v = this.value
78
99
  // nothing in memory, a failed compute included: wait on a new one
79
100
  if (v == null) return this.run()
80
- if (this.running == null && (this.stale || this.gen !== this.runGen)) this.kick()
101
+ const moved = this.stale || this.gen !== this.runGen
102
+ // the first reader in a while: what is in memory may be hours old,
103
+ // and no save recomputed it meanwhile
104
+ if (moved && !watched) return this.running != null && this.runGen === this.gen ? this.running : this.fresh()
105
+ if (this.running == null && moved) this.kick()
81
106
  return v.v
82
107
  }
83
108
 
84
- /** something this answer reads moved: recompute in the background */
109
+ /** a compute that started after this call: one that started before may
110
+ * miss what the caller just changed, so wait for it, then for our own */
111
+ private async fresh(): Promise<T> {
112
+ if (this.running != null) await this.running.catch(() => {})
113
+ return this.run()
114
+ }
115
+
116
+ /** something this answer reads moved: recompute in the background while
117
+ * a reader watches, else leave it to the next reader */
85
118
  invalidate(): void {
86
119
  this.gen++
87
- if (this.running == null) this.kick()
120
+ if (this.running == null && this.watched) this.kick()
88
121
  }
89
122
 
90
123
  /** compute now unless an answer is already in memory: the hub's load */
@@ -18,7 +18,7 @@ export type SerialSuite = {
18
18
  why: string
19
19
  }
20
20
  export type TestsConfig = {
21
- /** per suite time in the quick tier (default 2000). A suite whose wall time goes over it under the parallel run is run again alone, and fails when that run burns more CPU than this, or waits more than twice this while the machine is idle. A suite whose CPU inside the parallel run, children included, plus all its worker burned before it stays under it skips that run, unless it may wait on an idle machine or ended with a child not yet reaped. The full tier fails nothing on time */
21
+ /** per suite time in the quick tier (default 2000). A suite whose wall time goes over it under the parallel run is run again alone, and fails when the smaller of its CPU and its wall passes this, grown with the load share by up to half, or when its wall passes twice this, an allowance that grows with the square of the load share and has no end on a saturated machine. A suite whose CPU inside the parallel run, children included, plus all its worker burned before it stays under it skips that run, unless its wall is past that allowance or it ended with a child not yet reaped. The full tier fails nothing on time */
22
22
  budgetMs?: number
23
23
  /** suites that break the budget on purpose: the full tier runs them, the quick tier does not */
24
24
  slow?: SlowSuite[]
@@ -190,32 +190,58 @@ export const WAIT_FACTOR = 2
190
190
  /** how much more CPU the same suite burns on a saturated machine. CPU time is
191
191
  * not load free: measured on 8 cores, one suite went from 1.0s of CPU idle to
192
192
  * 1.35 to 1.5s with the cores saturated (load 7 to 19), and did not grow past
193
- * that. So a loaded machine fails a suite past the budget times this */
193
+ * that. So the CPU allowance grows with the load share up to this factor */
194
194
  export const LOADED_CPU_FACTOR = 1.5
195
+
196
+ /** how much of the machine the other processes took, 0 idle to 1 saturated:
197
+ * the one load measure every verdict here reads, so no verdict has a step */
198
+ export const loadShare = (load: MachineLoad): number => (load.cores > 0 ? Math.min(1, Math.max(0, load.load1 / load.cores)) : 0)
199
+ /** the CPU a suite may burn at this load: the budget, plus the measured
200
+ * inflation in proportion to how saturated the machine is */
201
+ export const cpuAllowance = (budgetMs: number, load: MachineLoad): number => budgetMs * (1 + (LOADED_CPU_FACTOR - 1) * loadShare(load))
202
+ /** the wall a suite may take before it reads as a WAIT: WAIT_FACTOR times
203
+ * the budget on an idle machine, grown with the square of the load share,
204
+ * and without end once the machine is saturated. A wall on a busy machine
205
+ * is the machine's (measured: 11 to 19s for suites burning 1s of CPU at
206
+ * load 20.5 on 8 cores, far past any fair share), so only a quiet machine
207
+ * can show a suite that sleeps; the ramp keeps the verdict continuous */
208
+ export const waitAllowance = (budgetMs: number, load: MachineLoad): number => {
209
+ const free = 1 - loadShare(load) ** 2
210
+ return free <= 0 ? Infinity : (budgetMs * WAIT_FACTOR) / free
211
+ }
195
212
  /** `ms` is the time the verdict used: cpu, or wall for a wait or when no cpu figure exists */
196
213
  export type Confirmed = { file: string; ms: number; wallMs: number; cpuMs: number | null; over: 'cpu' | 'wait' | 'wall' | null }
197
214
 
198
- /** the verdict on each suspect once it ran alone. A wall time carries the load
199
- * of every other process on the machine, and another gate can triple it, so
200
- * the budget judges CPU: what the suite itself burned, which load moves by
201
- * a bounded factor only (LOADED_CPU_FACTOR). Wall time still fails a suite
202
- * far over the budget while the machine was idle, because only then is the
203
- * wait the suite's own */
215
+ /** the verdict on each suspect once it ran alone, continuous in the load.
216
+ * A wall time carries the load of every other process on the machine, so
217
+ * the budget judges CPU, against an allowance that grows with the load share
218
+ * (cpuAllowance). A suite is charged the SMALLER of its CPU and its wall:
219
+ * JS runs on one thread, so CPU above the wall is bun's GC and JIT threads
220
+ * or children running side by side, time nobody waits on. A wall past its
221
+ * wait allowance fails as a wait (waitAllowance), never on a busy machine */
204
222
  export function confirmOver(suspects: Timed[], alone: Record<string, AloneRun>, budgetMs: number, load: MachineLoad): { over: Confirmed[]; cleared: Confirmed[] } {
205
223
  const byFile = new Map(Object.entries(alone).map(([f, run]) => [normSuitePath(f), run]))
206
- const idle = load.load1 < load.cores
207
224
  const judged = suspects.map((t): Confirmed => {
208
225
  const run = byFile.get(t.file)
209
226
  const wallMs = run?.wallMs ?? t.ms
210
227
  const cpuMs = run?.cpuMs ?? null
211
228
  if (cpuMs == null) return { file: t.file, ms: wallMs, wallMs, cpuMs, over: wallMs > budgetMs ? 'wall' : null }
212
- if (cpuMs > budgetMs * (idle ? 1 : LOADED_CPU_FACTOR)) return { file: t.file, ms: cpuMs, wallMs, cpuMs, over: 'cpu' }
213
- if (idle && wallMs > budgetMs * WAIT_FACTOR) return { file: t.file, ms: wallMs, wallMs, cpuMs, over: 'wait' }
214
- return { file: t.file, ms: cpuMs, wallMs, cpuMs, over: null }
229
+ const charged = Math.min(cpuMs, wallMs)
230
+ if (charged > cpuAllowance(budgetMs, load)) return { file: t.file, ms: charged, wallMs, cpuMs, over: 'cpu' }
231
+ if (wallMs > waitAllowance(budgetMs, load)) return { file: t.file, ms: wallMs, wallMs, cpuMs, over: 'wait' }
232
+ return { file: t.file, ms: charged, wallMs, cpuMs, over: null }
215
233
  })
216
234
  return { over: judged.filter((c) => c.over != null), cleared: judged.filter((c) => c.over == null) }
217
235
  }
218
236
 
237
+ const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
238
+
239
+ /** the line a confirmed suite fails with */
240
+ export const overWords = (c: Confirmed, load: MachineLoad, budgetMs: number = DEFAULT_BUDGET_MS): string =>
241
+ c.over === 'cpu' ? `${c.file} ${secs(c.cpuMs ?? c.ms)} of CPU (${secs(c.wallMs)} wall)`
242
+ : c.over === 'wait' ? `${c.file} ${secs(c.wallMs)} wall at load ${load.load1.toFixed(1)} on ${load.cores} cores, past its ${secs(waitAllowance(budgetMs, load))} wait allowance, with ${secs(c.cpuMs ?? 0)} of CPU: it waits (a timer, a sleep, a poll)`
243
+ : `${c.file} ${secs(c.wallMs)} wall (no CPU figure)`
244
+
219
245
  /** how the parallel phase's workers hand the runner the CPU of each suite
220
246
  * (suite-cpu-preload.ts writes it, readPhaseCpu reads it) */
221
247
  export const SUITE_CPU_ENV = 'SHIPKIT_SUITE_CPU'
@@ -253,16 +279,15 @@ export function readPhaseCpu(text: string, cwd: string): { ran: string[]; cpu: R
253
279
  * what the worker computed by the suite's end, its whole CPU before the
254
280
  * suite (priorMs) plus what the suite burned, and sharing the machine only
255
281
  * raises CPU, so a suspect whose sum is inside the budget is inside it alone
256
- * too. A suspect with no figure (a child not yet reaped) runs alone. Only the alone run can see a WAIT, so a
257
- * suite past WAIT_FACTOR times the budget in wall time still gets it while
258
- * the machine is idle */
282
+ * too. A suspect with no figure (a child not yet reaped) runs alone. Only the
283
+ * alone run can see a WAIT, so a suite whose wall is past its wait
284
+ * allowance still gets it */
259
285
  export function aloneRunsNeeded(suspects: Timed[], parallelCpu: Record<string, PhaseCpu>, budgetMs: number, load: MachineLoad): { rerun: Timed[]; cleared: Confirmed[] } {
260
- const idle = load.load1 < load.cores
261
286
  const rerun: Timed[] = []
262
287
  const cleared: Confirmed[] = []
263
288
  for (const t of suspects) {
264
289
  const f = parallelCpu[t.file]
265
- const mayWait = idle && t.ms > budgetMs * WAIT_FACTOR
290
+ const mayWait = t.ms > waitAllowance(budgetMs, load)
266
291
  if (f != null && f.cpuMs + f.priorMs <= budgetMs && !mayWait) cleared.push({ file: t.file, ms: f.cpuMs, wallMs: t.ms, cpuMs: f.cpuMs, over: null })
267
292
  else rerun.push(t)
268
293
  }
@@ -16,7 +16,7 @@ import { killTree } from '../core/spawn-timeout.ts'
16
16
  import { parallelCap } from '../core/parallel.ts'
17
17
  import { poolDir, poolOccupancy } from '../core/slots.ts'
18
18
  import { keepTimings, seedTimings, timingsHome } from './kept-timings.ts'
19
- import { aloneRunsNeeded, bunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, WAIT_FACTOR, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
19
+ import { aloneRunsNeeded, bunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, overWords, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
20
20
 
21
21
  type Phase = { code: number; signal: string | null; stalled: boolean; out: string; timings: Record<string, number>; cpuMs: number | null }
22
22
 
@@ -63,11 +63,6 @@ const CPU_PRELOAD = join(import.meta.dir, 'suite-cpu-preload.ts')
63
63
 
64
64
  const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
65
65
 
66
- const overWords = (c: Confirmed): string =>
67
- c.over === 'cpu' ? `${c.file} ${secs(c.ms)} of CPU (${secs(c.wallMs)} wall)`
68
- : c.over === 'wait' ? `${c.file} ${secs(c.wallMs)} wall on an idle machine, more than ${WAIT_FACTOR}x the budget, with ${secs(c.cpuMs ?? 0)} of CPU: it waits (a timer, a sleep, a poll)`
69
- : `${c.file} ${secs(c.wallMs)} wall (no CPU figure)`
70
-
71
66
  /** the suite FILES a phase names: a flag is not a file, and the repo's own
72
67
  * `bun test` arguments are not files this runner put there */
73
68
  const phaseFiles = (phase: string[], rawArgs: string[]): string[] => phase.filter((a) => !a.startsWith('-') && !rawArgs.includes(a))
@@ -117,6 +112,9 @@ export async function suiteCli(argv: string[]): Promise<number> {
117
112
  const kept = timed && parallel ? timingsHome(process.cwd(), unit) : null
118
113
  if (kept != null) seedTimings(kept, join(dir, 'parallel.json'))
119
114
  const cpuFile = timed && parallel ? join(dir, 'cpu.tsv') : null
115
+ // the load BEFORE this run: read after it, the one minute average
116
+ // carries this run's own parallel phase, which is no other process
117
+ const load = machineLoad()
120
118
  const p1At = Date.now()
121
119
  const p1 = await runPhase(plan.parallel, timed ? join(dir, 'parallel.json') : null, ceilingMs, tmp, true, cpuFile)
122
120
  const p1Ms = Date.now() - p1At
@@ -148,7 +146,6 @@ export async function suiteCli(argv: string[]): Promise<number> {
148
146
  // before it can fail the task. One at a time: four alone runs side by
149
147
  // side each burned about 40% more CPU than one alone (8 cores)
150
148
  const aloneRuns: Record<string, AloneRun> = {}
151
- const load = machineLoad()
152
149
  const parallelCpu = phaseCpu.cpu
153
150
  const { rerun, cleared: clearedInPhase } = aloneRunsNeeded(judged.over, parallelCpu, budget, load)
154
151
  const aloneAt = Date.now()
@@ -224,7 +221,7 @@ export async function suiteCli(argv: string[]): Promise<number> {
224
221
  ...(!vacuous && (failedTests > 0 || (phases.some((p) => p.code !== 0 && p.signal == null) && killed.length === 0))
225
222
  ? [`FAIL: ${failedTests} test(s) failed, Ran ${ran} tests${slowest}`] : []),
226
223
  ...(over.length > 0
227
- ? [`FAIL: ${over.length} suite(s) over the ${budget}ms budget: ${over.map(overWords).join(', ')} (run alone, load ${load.load1.toFixed(1)} on ${load.cores} cores). Make each fast, or declare it in ${declaredIn}.slow with what it catches and why it cannot be fast`]
224
+ ? [`FAIL: ${over.length} suite(s) over the ${budget}ms budget: ${over.map((c) => overWords(c, load, budget)).join(', ')} (run alone, load ${load.load1.toFixed(1)} on ${load.cores} cores before the run). Make each fast, or declare it in ${declaredIn}.slow with what it catches and why it cannot be fast`]
228
225
  : []),
229
226
  ]
230
227
  // the most actionable failure prints LAST, where the gate reads its headline
@@ -6,6 +6,7 @@ import { scanMd, type MdDanglingRef, type ScanMdConfig } from 'rvlib-shipkit/src
6
6
  export type MdLintReport = {
7
7
  index: string | null // resolved entrypoint (null = neither index.md nor README.md found)
8
8
  files: number
9
+ pages: string[] // every scanned .md file, scan-root relative
9
10
  orphans: string[] // .md files with no path from the entrypoint
10
11
  dangling: MdDanglingRef[] // refs whose target does not exist in the scan
11
12
  ok: boolean
@@ -17,6 +18,7 @@ export function lintMd(cfg: ScanMdConfig): MdLintReport {
17
18
  return {
18
19
  index: scan.index,
19
20
  files: scan.graph.nodes.length,
21
+ pages: scan.graph.nodes.map((n) => n.id),
20
22
  orphans,
21
23
  dangling: scan.dangling,
22
24
  ok: scan.index != null && orphans.length === 0 && scan.dangling.length === 0,
@@ -4,10 +4,14 @@
4
4
  // → path.md lazy — resolved from the scan root, then file-relative
5
5
  // [text](path.md) lazy — file-relative
6
6
  // `path.md` lazy — file-relative, then from the scan root
7
+ // [text](dir/) lazy — a folder: an edge to every page under it
8
+ // `dir/` lazy — the same, from a backtick mention
9
+ // A ref that leaves the scan root but stays under `boundary` is checked on
10
+ // disk: no edge, and dangling only when the target is really missing.
7
11
  // Depth = 0-1 BFS from the index (eager edge keeps depth, lazy adds 1);
8
12
  // unreachable files stay in the graph with no depth (lint-md.ts turns those,
9
13
  // plus dangling refs, into failures — the graph itself is a viewer).
10
- import { readdirSync, readFileSync, statSync } from 'fs'
14
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
11
15
  import { basename, dirname, join, relative, resolve } from 'path'
12
16
  import type { MdEdge, MdGraph, MdNode } from 'rvlib-shipkit/src/wiki/md-graph.ts'
13
17
 
@@ -15,6 +19,7 @@ export type ScanMdConfig = {
15
19
  root: string // absolute dir to scan
16
20
  index?: string // root-relative entry the BFS starts from (default: index.md AND README.md, both)
17
21
  skipDirs?: string[] // dir names to skip on top of node_modules/.git/dist/tmp
22
+ boundary?: string // absolute dir enclosing root (a repo): refs leaving root but staying here resolve on disk
18
23
  }
19
24
 
20
25
  export type MdDanglingRef = { from: string; token: string } // an @/→/[](…) ref whose target is not in the scan
@@ -46,13 +51,33 @@ export function scanMd(cfg: ScanMdConfig): MdScan {
46
51
  // writers inside a scanned dir often name paths from the REPO root
47
52
  // ("agent/architecture.md" while scanning agent/) — accept that form too
48
53
  const rootBase = `${basename(cfg.root)}/`
49
- const resolveRef = (fromRel: string, token: string): string | null => {
50
- const cands = [resolve(join(cfg.root, dirname(fromRel)), token), resolve(cfg.root, token)]
51
- if (token.startsWith(rootBase)) cands.push(resolve(cfg.root, token.slice(rootBase.length)))
52
- for (const cand of cands) {
54
+ const cands = (fromRel: string, token: string): string[] => {
55
+ const out = [resolve(join(cfg.root, dirname(fromRel)), token), resolve(cfg.root, token)]
56
+ if (token.startsWith(rootBase)) out.push(resolve(cfg.root, token.slice(rootBase.length)))
57
+ return out
58
+ }
59
+ // pages a ref reaches: one file, every page under a folder (never the
60
+ // root itself), or none. `onDisk` = the target exists in the boundary
61
+ // outside the scan, which is resolved but carries no edge.
62
+ type Hit = { to: string[]; onDisk: boolean }
63
+ const resolveRef = (fromRel: string, token: string): Hit | null => {
64
+ const folder = token.endsWith('/')
65
+ for (const cand of cands(fromRel, token)) {
53
66
  const rel = relative(cfg.root, cand)
54
- if (!rel.startsWith('..') && inGraph.has(rel)) return rel
67
+ if (rel.startsWith('..')) continue
68
+ if (!folder && inGraph.has(rel)) return { to: [rel], onDisk: false }
69
+ if (folder && rel === '') return { to: [], onDisk: false }
70
+ if (folder) {
71
+ const under = files.filter((f) => f.startsWith(`${rel}/`))
72
+ if (under.length > 0) return { to: under, onDisk: false }
73
+ if (existsSync(cand)) return { to: [], onDisk: true }
74
+ }
55
75
  }
76
+ if (cfg.boundary == null) return null
77
+ const cand = resolve(join(cfg.root, dirname(fromRel)), token)
78
+ const inRoot = !relative(cfg.root, cand).startsWith('..')
79
+ const inBoundary = !relative(cfg.boundary, cand).startsWith('..')
80
+ if (!inRoot && inBoundary && existsSync(cand)) return { to: [], onDisk: true }
56
81
  return null
57
82
  }
58
83
  const edgesOf = (rel: string): MdEdge[] => {
@@ -64,14 +89,16 @@ export function scanMd(cfg: ScanMdConfig): MdScan {
64
89
  // strict: @/→/[](…) are deliberate wiki refs — a miss is a dangling ref.
65
90
  // Backtick spans mention paths casually (other repos, deleted examples) — resolve or ignore, never fail.
66
91
  const push = (token: string, eager: boolean, strict: boolean) => {
67
- const to = resolveRef(rel, token)
68
- if (to != null && to !== rel) out.push({ from: rel, to, eager })
69
- else if (to == null && strict) dangling.push({ from: rel, token })
92
+ const hit = resolveRef(rel, token)
93
+ if (hit == null) { if (strict) dangling.push({ from: rel, token }); return }
94
+ for (const to of hit.to) if (to !== rel) out.push({ from: rel, to, eager })
70
95
  }
71
- for (const m of prose.matchAll(/(?:^|[\s(])@([\w][\w./-]*\.md)/gm)) push(m[1]!, true, true)
96
+ for (const m of prose.matchAll(/(?:^|[\s(])@((?:\.{1,2}\/)*[\w][\w./-]*\.md)/gm)) push(m[1]!, true, true)
72
97
  for (const m of prose.matchAll(/→\s*([\w][\w./-]*\.md)\b/g)) push(m[1]!, false, true)
73
98
  for (const m of prose.matchAll(/\]\(([^)\s:]+\.md)\)/g)) push(m[1]!, false, true)
99
+ for (const m of prose.matchAll(/\]\(([^)\s:]*\/)\)/g)) push(m[1]!, false, true)
74
100
  for (const s of spans) for (const m of s.matchAll(/[\w][\w./-]*\.md/g)) push(m[0], false, false)
101
+ for (const s of spans) for (const m of s.matchAll(/(?:^|\s)([\w.][\w./-]*\/)(?=$|[\s<*])/g)) push(m[1]!, false, false)
75
102
  return out
76
103
  }
77
104
  const dangling: MdDanglingRef[] = []