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
@@ -56,20 +56,23 @@ export type DiscoveredCheck = {
56
56
  from: CheckFrom
57
57
  }
58
58
 
59
- function walk(dir: string, out: string[], suffix = SUFFIX): void {
59
+ function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[]): void {
60
+ dirs?.push(dir)
60
61
  let entries
61
62
  try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
62
63
  for (const e of entries) {
63
64
  if (e.isDirectory()) {
64
- if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix)
65
+ if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix, dirs)
65
66
  } else if (e.name.endsWith(suffix)) out.push(join(dir, e.name))
66
67
  }
67
68
  }
68
69
 
69
- /** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted */
70
- export function repoUnitFiles(root: string): string[] {
70
+ /** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted.
71
+ * `dirs` collects every dir the walk read: a file added to one moves its
72
+ * mtime, which is how a reader tells the list moved without walking again */
73
+ export function repoUnitFiles(root: string, dirs?: string[]): string[] {
71
74
  const out: string[] = []
72
- walk(root, out, UNIT_SUFFIX)
75
+ walk(root, out, UNIT_SUFFIX, dirs)
73
76
  return out.sort()
74
77
  }
75
78
 
@@ -32,6 +32,8 @@ export const LAYOUT = {
32
32
  reviews: sk('reviews'),
33
33
  // scratch: plans, logs, leases, detached runs, screenshots
34
34
  tmp: sk('tmp'),
35
+ // built app bundles, one per global process opened as an app (home/gui-app.ts)
36
+ wrapper: sk('wrapper'),
35
37
  // generated on every run
36
38
  status: sk('STATUS.md'),
37
39
  statusFull: sk('STATUS.full.md'),
@@ -1,15 +1,20 @@
1
- // Report to the motherhome — upsert this repo's row into the docsBase
2
- // wiki's `build/shipkit/local-projects.md`, when that page ALREADY EXISTS
3
- // on this disk. The lib NEVER creates the page and never writes into a
4
- // relative or URL docsBase: the page is a tracked wiki file, so a real
5
- // docs checkout has it, and a consumer that merely set docsBase as a
6
- // fix-pointer prefix must never find surprise files in its docs tree.
7
- // The whole read-modify-write runs under the shared file lock
8
- // (core/locked-write) — concurrent reporters never lose rows or tear the
9
- // file; the loser skips and self-heals on its next run. Rows whose repo
10
- // path no longer exists are pruned on every write. Churn control: a row
11
- // rewrites only when its DATA changes or its last-seen day is older than
12
- // 7 days. Never fails the calling command.
1
+ // Report to the motherhome: a repo's row in the docsBase wiki's
2
+ // `build/shipkit/local-projects.md`, in two halves.
3
+ //
4
+ // - `reportToMotherhome` (every ci/check/init) files the row in the MACHINE
5
+ // DB and nothing else. A gate answers about its own repo, so it never
6
+ // writes, let alone commits, in the docs repo.
7
+ // - `landMotherhome` (the daemon's hourly sweep) renders every filed row into
8
+ // its page and lands the page as ONE commit, at most once per LAND_GAP_MS
9
+ // per page, only when a row's data changed or its last-seen day is older
10
+ // than STALE_DAYS.
11
+ //
12
+ // The lib NEVER creates the page and never reads a relative or URL docsBase:
13
+ // the page is a tracked wiki file, so a real docs checkout has it, and a
14
+ // consumer that merely set docsBase as a fix-pointer prefix must never find
15
+ // surprise files in its docs tree. The page's read-modify-write runs under
16
+ // the shared file lock (core/locked-write). Rows whose repo path no longer
17
+ // exists are pruned from the page and from the machine db.
13
18
  import { existsSync, readFileSync } from 'fs'
14
19
  import { homedir } from 'os'
15
20
  import { join, resolve } from 'path'
@@ -17,46 +22,31 @@ import type { RepoConfig } from '../repo-config.ts'
17
22
  import { atomicWrite, withFileLock } from './locked-write.ts'
18
23
  import { isLinkedWorktree } from './git-root.ts'
19
24
  import { machineCommit } from './machine-commit.ts'
25
+ import { machineDelete, machineGet, machineList, machineSet } from './machine-db.ts'
20
26
  import { LIB_VERSION } from './lib-version.ts'
21
27
 
22
28
  const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
23
29
  const END = '<!-- <<< shipkit local-projects -->'
24
30
  const STALE_DAYS = 7
31
+ /** the longest the page waits for a changed row: one commit a day at most */
32
+ export const LAND_GAP_MS = 24 * 60 * 60 * 1000
33
+ /** a row filed in the machine db, by its repo's path */
34
+ const ROW = 'motherhome:'
35
+ /** when a page last landed, by its path */
36
+ const LANDED = 'motherhome-landed:'
25
37
 
26
38
  type Row = { name: string; root: string; version: string; mode: string; seen: string }
39
+ type Filed = Row & { page: string }
27
40
 
28
41
  const rowLine = (r: Row): string => `- ${r.name} — \`${r.root}\` — shipkit ${r.version} (${r.mode}) — last seen ${r.seen}`
29
42
 
30
- /**
31
- * Commit the row we just wrote, and ONLY that file.
32
- *
33
- * This page is machine written and rewrites whenever a repo's shipkit version
34
- * or consumption mode changes, so left alone it dirties the docs repo from a
35
- * gate run nobody associates with it, and the dirt then rides into an unrelated
36
- * commit or blocks a release. The row is not worth a human's attention in
37
- * either direction, so the machine that writes it also lands it.
38
- *
39
- * The scoping, the refusals and the escape hatch live in `core/machine-commit`,
40
- * which every machine written path commits through. This one fires only when
41
- * the row's DATA changed, not on every gate, so the hooks it runs are affordable.
42
- */
43
- function autoCommitRow(page: string, name: string): string {
44
- const landed = machineCommit({
45
- anchor: resolve(page, '..'),
46
- message: `chore(machine): refresh local-projects row (${name})`,
47
- paths: [page],
48
- })
49
- return landed ? ' + committed' : ''
50
- }
51
-
52
43
  const parseRow = (line: string): Row | null => {
53
44
  const m = line.match(/^- (.+?) — `(.+?)` — shipkit (\S+) \((.+?)\) — last seen (\d{4}-\d{2}-\d{2})$/)
54
45
  return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
55
46
  }
56
47
 
57
-
58
- // how this repo consumes the lib — `?` on an unreadable package.json (a
59
- // read failure must never be rendered as the fact 'path')
48
+ // how this repo consumes the lib: `?` on an unreadable package.json (a read
49
+ // failure must never be rendered as the fact 'path')
60
50
  function consumptionMode(root: string): string {
61
51
  try {
62
52
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
@@ -70,47 +60,79 @@ function consumptionMode(root: string): string {
70
60
 
71
61
  export type MotherhomeOutcome = { wrote: boolean; note?: string }
72
62
 
63
+ /** file this repo's row in the machine db. `wrote` says the filed row changed */
73
64
  export function reportToMotherhome(root: string, config: RepoConfig, today: string = new Date().toISOString().slice(0, 10)): MotherhomeOutcome {
74
65
  try {
75
66
  if (config.docsBase == null) return { wrote: false }
76
67
  const base = config.docsBase.startsWith('~/') ? join(homedir(), config.docsBase.slice(2)) : config.docsBase
77
- // absolute local paths only — a relative docsBase would resolve
68
+ // absolute local paths only: a relative docsBase would resolve
78
69
  // against CWD, a URL is a display prefix
79
70
  if (!base.startsWith('/')) return { wrote: false }
80
71
  const page = join(base, 'build/shipkit/local-projects.md')
81
72
  if (!existsSync(page)) return { wrote: false }
82
- // a gate run inside a linked worktree must not write the MAIN
83
- // checkout's tracked page: the row it would add describes a temporary
84
- // side checkout, and the write lands as unexplained dirt in a tree
85
- // somebody else is working in. The worktree's own runs are covered
86
- // when its branch merges.
73
+ // a linked worktree is a temporary side checkout: its runs are
74
+ // covered when its branch merges
87
75
  if (isLinkedWorktree(root)) return { wrote: false, note: `motherhome: skipped (${config.name} is a linked worktree — its row belongs to the main checkout)` }
88
-
89
76
  const mode = consumptionMode(root)
90
77
  // source-consumed code is not the published release its version
91
- // string names — disclose it
78
+ // string names: disclose it
92
79
  const version = mode === 'npm' ? LIB_VERSION : `${LIB_VERSION}+src`
93
- const me: Row = { name: config.name, root: resolve(root), version, mode, seen: today }
94
-
95
- const r = withFileLock(page, (): MotherhomeOutcome => {
96
- const lines = readFileSync(page, 'utf8').split('\n')
97
- const b = lines.indexOf(BEGIN)
98
- const e = lines.indexOf(END)
99
- if (b === -1 || e === -1 || e < b) return { wrote: false, note: `WARN motherhome upsert skipped — markers missing/corrupted in ${page}` }
100
- const rows = lines.slice(b + 1, e).map(parseRow).filter((row): row is Row => row != null)
101
- const cur = rows.find((row) => row.root === me.root)
102
- const dataSame = cur != null && cur.name === me.name && cur.version === me.version && cur.mode === me.mode
103
- const fresh = cur != null && (Date.parse(today) - Date.parse(cur.seen)) / 86_400_000 < STALE_DAYS
104
- if (dataSame && fresh) return { wrote: false }
105
- // prune rows for repos that no longer exist at their recorded path
106
- const next = [...rows.filter((row) => row.root !== me.root && existsSync(row.root)), me].sort((a, z) => a.name.localeCompare(z.name))
107
- atomicWrite(page, [...lines.slice(0, b + 1), ...next.map(rowLine), ...lines.slice(e)].join('\n'))
108
- const committed = autoCommitRow(page, me.name)
109
- return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})${committed}` }
110
- })
111
- // lock lost to a racing reporter — self-heals on the next run
112
- return r.ok ? r.value : { wrote: false }
80
+ const me: Filed = { page, name: config.name, root: resolve(root), version, mode, seen: today }
81
+ const key = `${ROW}${me.root}`
82
+ if (JSON.stringify(machineGet<Filed>(key)) === JSON.stringify(me)) return { wrote: false }
83
+ return machineSet(key, me) ? { wrote: true } : { wrote: false, note: 'WARN motherhome row not filed: the machine db could not be written' }
113
84
  } catch (err) {
114
- return { wrote: false, note: `WARN motherhome upsert failed (${String(err).slice(0, 120)}) — registry may be stale` }
85
+ return { wrote: false, note: `WARN motherhome row not filed (${String(err).slice(0, 120)})` }
86
+ }
87
+ }
88
+
89
+ /** a page's rows once the filed ones are merged in: a filed row replaces the
90
+ * page's only when its data changed or the page's day is STALE_DAYS old,
91
+ * and a row whose repo path is gone is pruned */
92
+ function mergeRows(onPage: Row[], filed: Row[]): Row[] {
93
+ const by = new Map(onPage.map((r) => [r.root, r]))
94
+ for (const f of filed) {
95
+ const cur = by.get(f.root)
96
+ const same = cur != null && cur.name === f.name && cur.version === f.version && cur.mode === f.mode
97
+ const fresh = cur != null && (Date.parse(f.seen) - Date.parse(cur.seen)) / 86_400_000 < STALE_DAYS
98
+ if (!(same && fresh)) by.set(f.root, { name: f.name, root: f.root, version: f.version, mode: f.mode, seen: f.seen })
99
+ }
100
+ return [...by.values()].filter((r) => existsSync(r.root)).sort((a, z) => a.name.localeCompare(z.name) || a.root.localeCompare(z.root))
101
+ }
102
+
103
+ /** render every filed row into its page and land each changed page as one
104
+ * commit, at most once per LAND_GAP_MS. Returns what to print */
105
+ export function landMotherhome(o: { now?: number; gapMs?: number } = {}): string[] {
106
+ const now = o.now ?? Date.now()
107
+ const notes: string[] = []
108
+ const filed = machineList<Filed>(ROW)
109
+ for (const f of filed) if (!existsSync(f.value.root)) machineDelete(f.name)
110
+ const pages = new Map<string, Row[]>()
111
+ for (const f of filed) if (existsSync(f.value.root)) pages.set(f.value.page, [...(pages.get(f.value.page) ?? []), f.value])
112
+ for (const [page, rows] of pages) {
113
+ try {
114
+ if (!existsSync(page)) continue
115
+ const last = machineGet<number>(`${LANDED}${page}`)
116
+ if (last != null && now - last < (o.gapMs ?? LAND_GAP_MS)) continue
117
+ const r = withFileLock(page, (): boolean => {
118
+ const text = readFileSync(page, 'utf8')
119
+ const lines = text.split('\n')
120
+ const b = lines.indexOf(BEGIN)
121
+ const e = lines.indexOf(END)
122
+ if (b === -1 || e === -1 || e < b) { notes.push(`WARN motherhome: markers missing or out of order in ${page}, rows not written`); return false }
123
+ const onPage = lines.slice(b + 1, e).map(parseRow).filter((row): row is Row => row != null)
124
+ const next = [...lines.slice(0, b + 1), ...mergeRows(onPage, rows).map(rowLine), ...lines.slice(e)].join('\n')
125
+ if (next === text) return false
126
+ atomicWrite(page, next)
127
+ return true
128
+ })
129
+ if (!r.ok || !r.value) continue
130
+ machineSet(`${LANDED}${page}`, now)
131
+ const landed = machineCommit({ anchor: resolve(page, '..'), message: 'chore(machine): refresh local-projects rows', paths: [page] })
132
+ notes.push(`motherhome: ${page} rows refreshed${landed ? ' + committed' : ', not committed'}`)
133
+ } catch (err) {
134
+ notes.push(`WARN motherhome: ${page} not refreshed (${String(err).slice(0, 120)})`)
135
+ }
115
136
  }
137
+ return notes
116
138
  }
@@ -87,11 +87,8 @@ export type CheckOpts = {
87
87
  /** the gate binary that own ledger is keyed on (default: the one running
88
88
  * it). Ignored when `ledger` is passed, which carries its own. */
89
89
  cliEntry?: string
90
- /** refresh the motherhome registry row. runCi passes false and does it
91
- * ITSELF, after the ledger has committed: that row is a TRACKED file under
92
- * `wiki/`, so writing it before the post-run re-hash makes the gate look
93
- * like an agent editing the tree and throws away every result the run just
94
- * measured. */
90
+ /** file the motherhome row in the machine db (core/motherhome.ts). runCi
91
+ * passes false and does it ITSELF, once, after its verdict. */
95
92
  reportHome?: boolean
96
93
  }
97
94
 
@@ -289,9 +289,9 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
289
289
  // a narrowed run asked no question about
290
290
  const verdict = code === 0 && !run.interrupted ? verdictLine(run.results, scope.fullGate ? { tier, cache: ledger?.counts ?? null } : null) : null
291
291
  if (verdict != null) sink(verdict)
292
- // motherhome row (docsBase wiki on this disk) — never affects the gate,
293
- // and never fires under frozen: a bump/release gate must not re-dirty the
294
- // tree it just proved clean
292
+ // the motherhome row, filed in the machine db and landed in the docsBase
293
+ // page by the daemon (core/motherhome.ts): never affects the gate, never
294
+ // writes in another repo, and a frozen run files nothing
295
295
  if (o.frozen !== true) {
296
296
  const mh = reportToMotherhome(root, c)
297
297
  if (mh.note != null) print(mh.note)
package/src/core/wire.ts CHANGED
@@ -3,22 +3,51 @@
3
3
  // exit 0/1. FROZEN: consumers grep startsWith('FAIL'/'WARN'/'skip:') — a
4
4
  // prefix change is a breaking change of the whole toolkit.
5
5
 
6
- export type Line = { kind: 'ok' | 'warn' | 'fail' | 'note' | 'fix-warn' | 'fix-fail' | 'skip'; text: string }
6
+ import { CACHED, RAN } from './outcome-words.ts'
7
+
8
+ /** when a remembered world answer was measured, and until when it keeps
9
+ * (null: until its declaration changes). Kept as data, never as text, and
10
+ * rendered against the reader's clock: a time frozen into the line reads
11
+ * as a future expiry long after it passed */
12
+ export type Stamp = { at: number; until: number | null; cached?: boolean }
13
+
14
+ export type Line = { kind: 'ok' | 'warn' | 'fail' | 'note' | 'fix-warn' | 'fix-fail' | 'skip'; text: string; stamp?: Stamp }
15
+
16
+ /** a span in its one biggest unit: `45s`, `12m`, `3h`, `2d` */
17
+ export function spanShort(ms: number): string {
18
+ const s = Math.max(0, Math.round(ms / 1000))
19
+ if (s < 60) return `${s}s`
20
+ if (s < 3600) return `${Math.floor(s / 60)}m`
21
+ if (s < 86400) return `${Math.floor(s / 3600)}h`
22
+ return `${Math.floor(s / 86400)}d`
23
+ }
24
+
25
+ /** `cached, ran 3h ago, kept 57m more`, `ran 1d ago, expired 1d ago` */
26
+ export function stampWords(s: Stamp, now: number): string {
27
+ const age = now - s.at
28
+ const ran = age < 1000 ? `${RAN} just now` : `${RAN} ${spanShort(age)} ago`
29
+ const keeps = s.until == null ? 'kept until its declaration changes' : s.until > now ? `kept ${spanShort(s.until - now)} more` : `expired ${spanShort(now - s.until)} ago`
30
+ return `${s.cached === true ? `${CACHED}, ` : ''}${ran}, ${keeps}`
31
+ }
32
+
33
+ /** a line's text as a reader sees it now, its stamp rendered */
34
+ export const lineText = (l: Line, now: number = Date.now()): string => (l.stamp == null ? l.text : `${l.text} (${stampWords(l.stamp, now)})`)
7
35
 
8
36
  const green = (s: string): string => `\x1b[32m${s}\x1b[0m`
9
37
  const red = (s: string): string => `\x1b[31m${s}\x1b[0m`
10
38
  const yellow = (s: string): string => `\x1b[33m${s}\x1b[0m`
11
39
 
12
- export function formatLine(l: Line, color: boolean): string {
40
+ export function formatLine(l: Line, color: boolean, now: number = Date.now()): string {
13
41
  const c = (f: (s: string) => string, s: string) => (color ? f(s) : s)
42
+ const text = lineText(l, now)
14
43
  switch (l.kind) {
15
- case 'ok': return `${c(green, 'ok:')} ${l.text}`
16
- case 'warn': return c(yellow, `WARN ${l.text}`)
17
- case 'fail': return c(red, `FAIL: ${l.text}`)
18
- case 'fix-warn': return ` ${c(yellow, `fix: ${l.text}`)}`
19
- case 'fix-fail': return ` ${c(red, `fix: ${l.text}`)}`
20
- case 'skip': return `skip: ${l.text}`
21
- case 'note': return l.text
44
+ case 'ok': return `${c(green, 'ok:')} ${text}`
45
+ case 'warn': return c(yellow, `WARN ${text}`)
46
+ case 'fail': return c(red, `FAIL: ${text}`)
47
+ case 'fix-warn': return ` ${c(yellow, `fix: ${text}`)}`
48
+ case 'fix-fail': return ` ${c(red, `fix: ${text}`)}`
49
+ case 'skip': return `skip: ${text}`
50
+ case 'note': return text
22
51
  }
23
52
  }
24
53
 
@@ -64,8 +64,11 @@ export type StatusCheck = {
64
64
  status: 'fail' | 'warn'
65
65
  /** when that outcome was recorded, epoch ms */
66
66
  at: number | null
67
- /** the first line it printed about the problem, '' when it printed none */
67
+ /** the worst line it printed about the problem, '' when it printed none */
68
68
  line: string
69
+ /** every fail and warn line it printed, the worst weight first: a check
70
+ * reporting three findings lists three */
71
+ lines: string[]
69
72
  }
70
73
 
71
74
  export type ActivityAnswer = { now: number; minutes: number; rows: ActivityRow[]; ms: number }
@@ -11,7 +11,7 @@
11
11
  import { gitBin } from '../core/git-bin.ts'
12
12
  import { readLastStatusReadonly } from '../ledger/run-log.ts'
13
13
  import { checkStateOf, recordOf } from '../serve/project-gutter.ts'
14
- import type { Line } from '../core/wire.ts'
14
+ import { lineText, type Line } from '../core/wire.ts'
15
15
  import { MAX_COMMITS, MAX_FILES, type ActivityAnswer, type ActivityCommit, type ActivityRepo, type ActivityRow, type FileKind, type StatusCheck } from './activity-shape.ts'
16
16
 
17
17
  export * from './activity-shape.ts'
@@ -93,26 +93,32 @@ export type Declared = (name: string) => ReadonlySet<string> | null
93
93
  /** each declared check's title by id; null = not known, the id stands in */
94
94
  export type Titles = (name: string) => ReadonlyMap<string, string> | null
95
95
 
96
- /** the line a check printed about its problem: its first line of the same
97
- * weight (a fail for a failure, a warning for a warning), else its first
98
- * line that is not a pass, without the id its line leads with */
99
- export function findingOf(lines: string, status: 'fail' | 'warn', id: string): string {
96
+ /** every line a check printed about its problems: its fail and warn lines,
97
+ * those of its own weight first (a fail for a failure, a warning for a
98
+ * warning), else its first line that is not a pass, each without the id it
99
+ * leads with */
100
+ export function findingsOf(lines: string, status: 'fail' | 'warn', id: string): string[] {
100
101
  let parsed: Line[]
101
102
  try {
102
103
  parsed = JSON.parse(lines) as Line[]
103
104
  } catch {
104
- return ''
105
+ return []
105
106
  }
106
- if (!Array.isArray(parsed)) return ''
107
- const kinds = status === 'fail' ? ['fail', 'fix-fail'] : ['warn', 'fix-warn']
108
- const hit = parsed.find((l) => kinds.includes(l.kind)) ?? parsed.find((l) => l.kind !== 'ok' && l.kind !== 'skip')
109
- if (hit == null) return ''
110
- const t = hit.text.trim()
111
- // `[ID] text`, and a runner line's `ID — text`: the list names the id already
112
- for (const lead of [`[${id}]`, `${id} —`]) if (t.startsWith(lead)) return t.slice(lead.length).trim()
113
- return t
107
+ if (!Array.isArray(parsed)) return []
108
+ const of = (k: Line['kind']): Line[] => parsed.filter((l) => l.kind === k)
109
+ let hits = status === 'fail' ? [...of('fail'), ...of('warn')] : [...of('warn'), ...of('fail')]
110
+ if (hits.length === 0) hits = parsed.filter((l) => l.kind !== 'ok' && l.kind !== 'skip').slice(0, 1)
111
+ return hits.map((l) => {
112
+ const t = lineText(l).trim()
113
+ // `[ID] text`, and a runner line's `ID — text`: the list names the id already
114
+ for (const lead of [`[${id}]`, `${id} —`]) if (t.startsWith(lead)) return t.slice(lead.length).trim()
115
+ return t
116
+ })
114
117
  }
115
118
 
119
+ /** the worst of them, '' when it printed none */
120
+ export const findingOf = (lines: string, status: 'fail' | 'warn', id: string): string => findingsOf(lines, status, id)[0] ?? ''
121
+
116
122
  /** a repo's checks as its run record last saw them, counted over what it
117
123
  * declares, and the failing and warning ones with what each said */
118
124
  export function statusOf(repo: ActivityRepo, declared: Declared | null = null, titles: Titles | null = null): Pick<ActivityRow, 'status' | 'failing'> {
@@ -125,7 +131,8 @@ export function statusOf(repo: ActivityRepo, declared: Declared | null = null, t
125
131
  const named = titles?.(repo.name) ?? null
126
132
  const one = (id: string, st: 'fail' | 'warn'): StatusCheck => {
127
133
  const r = byId.get(id)
128
- return { id, title: named?.get(id) ?? id, status: st, at: status.since[id] ?? null, line: r == null ? '' : findingOf(r.lines, st, id) }
134
+ const lines = r == null ? [] : findingsOf(r.lines, st, id)
135
+ return { id, title: named?.get(id) ?? id, status: st, at: status.since[id] ?? null, line: lines[0] ?? '', lines }
129
136
  }
130
137
  return { status, failing: [...status.reds.map((id) => one(id, 'fail')), ...status.warns.map((id) => one(id, 'warn'))] }
131
138
  }