rvlib-shipkit 0.38.1 → 1.0.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.
- package/CHANGELOG.md +367 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +209 -15
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// THE RUNS PAGE: four lists behind four tabs, one visible at a time: every
|
|
2
|
+
// invocation on record as a tree of one line runs whose checks unfold as
|
|
3
|
+
// child lines, the test files to fix, the slowest checks, the longest runs.
|
|
4
|
+
// The tab rides in the address (`#item:runs:runs:files`), so a reload restores it.
|
|
5
|
+
// Every line sits on a grid of fixed columns (the .rline grids in hub.css):
|
|
6
|
+
// numbers right aligned in tabular figures, ran and cached each a column of
|
|
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). The chart of every run in
|
|
9
|
+
// time is the live strip under the header, not this page.
|
|
10
|
+
import { observer } from 'mobx-react-lite'
|
|
11
|
+
import { useEffect } from 'react'
|
|
12
|
+
import { CACHED, RAN } from '../../core/outcome-words.ts'
|
|
13
|
+
import type { InvocationSummary, SlowCheck, SuiteToFix, Timeline, TimelineBar, TimelineInvocation } from '../../ledger/run-timeline.ts'
|
|
14
|
+
import { byWords, verdictWords } from './timeline-chart.tsx'
|
|
15
|
+
import { durationTone, fmtMs } from './dur.ts'
|
|
16
|
+
import { Help } from './help.tsx'
|
|
17
|
+
import type { HubSt } from './hub-st.ts'
|
|
18
|
+
import { TimeAgo, stampWords } from './time-ago.tsx'
|
|
19
|
+
import { checkName, editorHref } from './check-words.ts'
|
|
20
|
+
import { RUNS_TABS, runsTab, runsTabId, type RunsTab } from './runs-tabs.ts'
|
|
21
|
+
export { RUNS_TABS, runsTab, runsTabId, type RunsTab }
|
|
22
|
+
import type { Resource } from './hub-types.ts'
|
|
23
|
+
|
|
24
|
+
export type TabBadge = { n: number; tone: 'red' | 'warn' | 'zero' }
|
|
25
|
+
|
|
26
|
+
/** a tab's count, or null where a count says nothing: the red runs, the test
|
|
27
|
+
* files to fix (a zero is news too), the flagged checks */
|
|
28
|
+
export function tabBadge(tab: RunsTab, d: Timeline): TabBadge | null {
|
|
29
|
+
if (tab === 'runs') {
|
|
30
|
+
const n = d.invocations.filter((i) => i.reds > 0).length
|
|
31
|
+
return n > 0 ? { n, tone: 'red' } : null
|
|
32
|
+
}
|
|
33
|
+
if (tab === 'files') return d.suitesRecorded ? { n: d.testFiles.length, tone: d.testFiles.length > 0 ? 'red' : 'zero' } : null
|
|
34
|
+
if (tab === 'slowest') {
|
|
35
|
+
const n = d.slowest.filter((r) => r.flag != null).length
|
|
36
|
+
return n > 0 ? { n, tone: d.slowest.some((r) => r.flag === 'over') ? 'red' : 'warn' } : null
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** who started a run, as a tag short enough for one fixed column. A starter
|
|
42
|
+
* this build does not name reads as the word the record holds */
|
|
43
|
+
export const BY_TAG: Record<string, string> = { cli: 'terminal', hub: 'hub', schedule: 'daily', save: 'save', unknown: 'unknown' }
|
|
44
|
+
export const byTag = (by: string): string => BY_TAG[by] ?? by
|
|
45
|
+
|
|
46
|
+
/** a check's verdict, as the label's colour class: the tree's `tl` tones */
|
|
47
|
+
const verdictTone = (b: TimelineBar): string => (b.status === 'fail' ? 'red' : b.status === 'warn' ? 'warn' : b.status === 'skip' ? 'quiet' : 'ok')
|
|
48
|
+
|
|
49
|
+
const Who = ({ by }: { by: string }) => <span className="rwho" title={`started from ${byWords(by)}`}>{byTag(by)}</span>
|
|
50
|
+
|
|
51
|
+
const Reds = ({ n }: { n: number }) => <span className="rred">{n > 0 ? `${n} red` : ''}</span>
|
|
52
|
+
|
|
53
|
+
/** the ran and the cached columns of a run, a count and its word each */
|
|
54
|
+
const Counts = ({ checks, cached }: { checks: number; cached: number }) => {
|
|
55
|
+
const ran = checks - cached
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
<span className={`rran${ran === 0 ? ' zero' : ''}`}>{`${ran} ${RAN}`}</span>
|
|
59
|
+
<span className={`rcached${cached === 0 ? ' zero' : ''}`}>{`${cached} ${CACHED}`}</span>
|
|
60
|
+
</>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** a check's name on a child line: its title, with its id as the small tag
|
|
65
|
+
* the tree gives it, and its description for the tooltip */
|
|
66
|
+
export type ChildName = { tag: string | null; title: string; description: string | null }
|
|
67
|
+
|
|
68
|
+
/** every check the hub lists, by id, named the way the tree names it */
|
|
69
|
+
export function checkNames(resources: readonly Resource[]): Map<string, ChildName> {
|
|
70
|
+
const out = new Map<string, ChildName>()
|
|
71
|
+
for (const r of resources) {
|
|
72
|
+
if (r.check == null) continue
|
|
73
|
+
out.set(r.check.checkId, { ...checkName(r.check.checkId, r.title), description: r.check.description })
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** a check the hub no longer lists (removed since the run) is named by its id */
|
|
79
|
+
export const nameOf = (names: Map<string, ChildName>, id: string): ChildName => names.get(id) ?? { tag: null, title: id, description: null }
|
|
80
|
+
|
|
81
|
+
/** an unfolded run's order: its reds, then what ran, slowest first, then what
|
|
82
|
+
* the cache answered, so what matters shows without scrolling */
|
|
83
|
+
export function childOrder(bars: readonly TimelineBar[]): TimelineBar[] {
|
|
84
|
+
const rank = (b: TimelineBar): number => (b.status === 'fail' ? 0 : b.replayed ? 2 : 1)
|
|
85
|
+
return bars
|
|
86
|
+
.map((b, i) => ({ b, i }))
|
|
87
|
+
.sort((x, y) => rank(x.b) - rank(y.b) || (rank(x.b) === 2 ? 0 : y.b.ms - x.b.ms) || x.i - y.i)
|
|
88
|
+
.map((x) => x.b)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** one check of a run, a child line on the run's grid: its word sits in the
|
|
92
|
+
* column its run counts it in */
|
|
93
|
+
const CheckLine = ({ app, b, name }: { app: HubSt; b: TimelineBar; name: ChildName }) => (
|
|
94
|
+
<div className={`rline rchild${b.status === 'fail' ? ' red' : ''}`} onClick={() => app.timeline.openCheck(b.id)}
|
|
95
|
+
title={[name.description ?? '', `${b.id}: ${verdictWords(b)}`, stampWords(b.start)].filter((x) => x !== '').join('\n')}>
|
|
96
|
+
<span />
|
|
97
|
+
<span className="rid"><span className={`rname tl ${verdictTone(b)}`}>{name.tag == null ? null : <span className="tid">{name.tag}</span>}{name.title}</span></span>
|
|
98
|
+
<span className={`rnum ${b.replayed ? 'dim' : durationTone(b.ms) ?? ''}`}>{b.replayed ? '' : fmtMs(b.ms)}</span>
|
|
99
|
+
<span className="rran">{b.replayed ? '' : RAN}</span>
|
|
100
|
+
<span className="rcached">{b.replayed ? CACHED : ''}</span>
|
|
101
|
+
<span className={`rflag ${b.status === 'fail' ? 'red' : 'warn'}`}>{b.status === 'fail' ? 'red' : b.status === 'warn' ? 'warn' : ''}</span>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const RunLine = observer(({ app, inv, n }: { app: HubSt; inv: TimelineInvocation; n: number }) => {
|
|
106
|
+
const open = app.timeline.isOpen(inv.runId, n)
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
<div className={`rline rrun${inv.reds > 0 ? ' red' : ''}`} onClick={() => app.timeline.toggle(inv.runId, n)}>
|
|
110
|
+
<span className="rcaret">{open ? '▾' : '▸'}</span>
|
|
111
|
+
<TimeAgo clock={app.clock} at={inv.started} />
|
|
112
|
+
<Who by={inv.by} />
|
|
113
|
+
<span className="rwhat">{inv.label}</span>
|
|
114
|
+
<span className="rnum">{fmtMs(inv.wallMs)}</span>
|
|
115
|
+
<Counts checks={inv.bars.length} cached={inv.replays} />
|
|
116
|
+
<Reds n={inv.reds} />
|
|
117
|
+
</div>
|
|
118
|
+
{open ? <RunChecks app={app} bars={inv.bars} /> : null}
|
|
119
|
+
</>
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
/** the checks of an unfolded run, in a box of their own that scrolls: a full
|
|
124
|
+
* gate is fifty checks, which would push every other run off the page */
|
|
125
|
+
const RunChecks = observer(({ app, bars }: { app: HubSt; bars: TimelineBar[] }) => {
|
|
126
|
+
const names = checkNames(app.cols.resources)
|
|
127
|
+
return <div className="rkids">{childOrder(bars).map((b) => <CheckLine key={`${b.id}:${b.start}`} app={app} b={b} name={nameOf(names, b.id)} />)}</div>
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const Runs = ({ app, rows }: { app: HubSt; rows: TimelineInvocation[] }) => (
|
|
131
|
+
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} />)}</>
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
/** what a test file to fix did: `red`, or its time against the budget */
|
|
135
|
+
export const suiteWords = (r: SuiteToFix): string => (r.red ? 'red' : `over ${fmtMs(r.budgetMs)}`)
|
|
136
|
+
|
|
137
|
+
/** a sub-unit's file, named from its unit's dir: the tag beside it already
|
|
138
|
+
* says which unit, and the repo path is the tooltip */
|
|
139
|
+
const inUnit = (r: SuiteToFix): string => {
|
|
140
|
+
if (r.unit === '' || r.unit === 'root') return r.file
|
|
141
|
+
const at = r.file.indexOf(`/${r.unit}/`)
|
|
142
|
+
return at < 0 ? r.file : r.file.slice(at + r.unit.length + 2)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const TestFiles = ({ app, rows, recorded }: { app: HubSt; rows: SuiteToFix[]; recorded: boolean }) => {
|
|
146
|
+
if (!recorded) return <div className="rempty">no test file run recorded yet: the next run of a test check records one</div>
|
|
147
|
+
if (rows.length === 0) return <div className="rempty">every test file passed and fits the budget</div>
|
|
148
|
+
return (
|
|
149
|
+
<>
|
|
150
|
+
{rows.map((r) => {
|
|
151
|
+
return (
|
|
152
|
+
<div key={`${r.check}:${r.file}`} className={`rline rtest${r.red ? ' red' : ''}`} title={r.failLine ?? undefined} onClick={() => app.timeline.openTest(r.file)}>
|
|
153
|
+
<span className={`rtag${r.check === '' ? ' dim' : ''}`} title={r.check === '' ? 'recorded before the record named its check' : `the check that ran it: ${r.check}`}
|
|
154
|
+
onClick={(e) => { if (r.check === '') return; e.stopPropagation(); app.timeline.openCheck(r.check) }}>{r.check === '' ? 'unnamed' : r.check}</span>
|
|
155
|
+
<span className="rwhat" title={r.file}>{inUnit(r)}</span>
|
|
156
|
+
<span className={`rnum ${r.slow ? 'bad' : ''}`}>{fmtMs(r.ms)}</span>
|
|
157
|
+
<span className={r.red ? 'rflag red' : 'rflag warn'}>{suiteWords(r)}</span>
|
|
158
|
+
<TimeAgo clock={app.clock} at={r.at} />
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
})}
|
|
162
|
+
</>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const FLAG_WORD = { over: 'at its ceiling', near: 'past half its ceiling' } as const
|
|
167
|
+
|
|
168
|
+
/** two numbers side by side need their names once, above the column */
|
|
169
|
+
const Slowest = ({ app, rows }: { app: HubSt; rows: SlowCheck[] }) => {
|
|
170
|
+
if (rows.length === 0) return <div className="rempty">nothing ran yet</div>
|
|
171
|
+
return (
|
|
172
|
+
<>
|
|
173
|
+
<div className="rhead rslow"><span /><span>mean</span><span>worst</span><span /></div>
|
|
174
|
+
{rows.map((r) => (
|
|
175
|
+
<div key={r.id} className="rline rslow" onClick={() => app.timeline.openCheck(r.id)}>
|
|
176
|
+
<span className="rtag">{r.id}</span>
|
|
177
|
+
<span className={`rnum ${durationTone(r.meanMs, r.ceilingMs) ?? ''}`} title="mean of the runs that ran">{fmtMs(r.meanMs)}</span>
|
|
178
|
+
<span className={`rnum ${durationTone(r.worstMs, r.ceilingMs) ?? ''}`} title="the worst run that ran">{fmtMs(r.worstMs)}</span>
|
|
179
|
+
<span className={r.flag === 'over' ? 'rflag red' : 'rflag warn'} title={r.ceilingMs == null ? undefined : `ceiling ${fmtMs(r.ceilingMs)}`}>{r.flag == null ? '' : FLAG_WORD[r.flag]}</span>
|
|
180
|
+
</div>
|
|
181
|
+
))}
|
|
182
|
+
</>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const Longest = ({ app, rows }: { app: HubSt; rows: InvocationSummary[] }) => {
|
|
187
|
+
if (rows.length === 0) return <div className="rempty">no run on record yet</div>
|
|
188
|
+
return (
|
|
189
|
+
<>
|
|
190
|
+
{rows.map((r) => (
|
|
191
|
+
<div key={r.runId} className={`rline rrun${r.reds > 0 ? ' red' : ''}`} title={stampWords(r.started)}>
|
|
192
|
+
<span />
|
|
193
|
+
<TimeAgo clock={app.clock} at={r.started} />
|
|
194
|
+
<Who by={r.by} />
|
|
195
|
+
<span className="rwhat">{r.label}</span>
|
|
196
|
+
<span className="rnum">{fmtMs(r.wallMs)}</span>
|
|
197
|
+
<Counts checks={r.checks} cached={r.replays} />
|
|
198
|
+
<Reds n={r.reds} />
|
|
199
|
+
</div>
|
|
200
|
+
))}
|
|
201
|
+
</>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const Tabs = observer(({ app, on }: { app: HubSt; on: RunsTab }) => {
|
|
206
|
+
const t = app.timeline
|
|
207
|
+
const d = t.data
|
|
208
|
+
return (
|
|
209
|
+
<div className="rtabs">
|
|
210
|
+
{RUNS_TABS.map((tab) => {
|
|
211
|
+
const b = d == null ? null : tabBadge(tab.key, d)
|
|
212
|
+
return (
|
|
213
|
+
<button key={tab.key} className={`rtab${tab.key === on ? ' on' : ''}`} onClick={() => app.cols.select('runs', runsTabId(tab.key))}>
|
|
214
|
+
{tab.label}
|
|
215
|
+
{b == null ? null : <span className={`rbadge ${b.tone}`}>{b.n}</span>}
|
|
216
|
+
</button>
|
|
217
|
+
)
|
|
218
|
+
})}
|
|
219
|
+
<Help text={RUNS_TABS.find((x) => x.key === on)!.help} />
|
|
220
|
+
<span className="sp" />
|
|
221
|
+
<button disabled={t.loading} onClick={() => void t.load()}>{t.loading ? 'reading…' : 'reload'}</button>
|
|
222
|
+
</div>
|
|
223
|
+
)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
export const TimelineView = observer(({ app }: { app: HubSt }) => {
|
|
227
|
+
const t = app.timeline
|
|
228
|
+
useEffect(() => { void t.load() }, [t])
|
|
229
|
+
const d = t.data
|
|
230
|
+
const on = runsTab(app.sel?.t === 'item' ? app.sel.id : null)
|
|
231
|
+
return (
|
|
232
|
+
<div className="colcard tlcard">
|
|
233
|
+
<Tabs app={app} on={on} />
|
|
234
|
+
{t.error !== '' ? <div className="warnline">{t.error}</div> : null}
|
|
235
|
+
{d == null ? <div className="dim">reading the run record…</div> : (
|
|
236
|
+
<div className={`rlist ${on}`}>
|
|
237
|
+
{on === 'runs' ? <Runs app={app} rows={d.invocations} />
|
|
238
|
+
: on === 'files' ? <TestFiles app={app} rows={d.testFiles} recorded={d.suitesRecorded} />
|
|
239
|
+
: on === 'slowest' ? <Slowest app={app} rows={d.slowest} />
|
|
240
|
+
: <Longest app={app} rows={d.longest} />}
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
)
|
|
245
|
+
})
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// The trie, as pure functions over one flat Resource[]. No react, no mobx,
|
|
2
|
+
// no dom: TreeSvc caches these as computeds and the tests drive them
|
|
3
|
+
// headless. Both tabs (by path, by type) are the SAME array grouped twice,
|
|
4
|
+
// never a second list.
|
|
5
|
+
import { NO_CHECK, type Resource, type ResourceState, type TestFacts } from '../../collections/collection-types.ts'
|
|
6
|
+
import { checkVerdictWords, plural } from '../../core/outcome-words.ts'
|
|
7
|
+
import { checkName } from './check-words.ts'
|
|
8
|
+
import { durationTone, fmtMs, type DurTone } from './dur.ts'
|
|
9
|
+
import type { HashSel } from './hash-route.ts'
|
|
10
|
+
|
|
11
|
+
export type TreeNode = {
|
|
12
|
+
/** stable across reloads: the expansion set and the cursor are keys */
|
|
13
|
+
key: string
|
|
14
|
+
label: string
|
|
15
|
+
state: ResourceState
|
|
16
|
+
/** null on a directory */
|
|
17
|
+
res: Resource | null
|
|
18
|
+
/** a by type group: the kind it gathers, so its row wears that kind's
|
|
19
|
+
* mark. null on a real folder and on a leaf */
|
|
20
|
+
kind: string | null
|
|
21
|
+
/** leaves under this node, and how many of them are red */
|
|
22
|
+
total: number
|
|
23
|
+
reds: number
|
|
24
|
+
/** every gate check under this node: the row pulses while one of them runs */
|
|
25
|
+
checks: string[]
|
|
26
|
+
/** the archive: everything under it is already done. It stays in the
|
|
27
|
+
* tree and out of every count above it */
|
|
28
|
+
archived: boolean
|
|
29
|
+
/** a folder with a page of its own: its label opens the page, its
|
|
30
|
+
* chevron folds. null on every other folder and on a leaf */
|
|
31
|
+
page: HashSel | null
|
|
32
|
+
children: TreeNode[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type TreeRow = {
|
|
36
|
+
key: string
|
|
37
|
+
label: string
|
|
38
|
+
depth: number
|
|
39
|
+
dir: boolean
|
|
40
|
+
state: ResourceState
|
|
41
|
+
res: Resource | null
|
|
42
|
+
kind: string | null
|
|
43
|
+
total: number
|
|
44
|
+
reds: number
|
|
45
|
+
checks: string[]
|
|
46
|
+
page: HashSel | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// a directory wears the WORST state under it, so a collapsed branch can
|
|
50
|
+
// never hide a red, and OK outranks idle: a folder of passing checks read as
|
|
51
|
+
// plain white because one unmeasured row in it outvoted twenty nine greens
|
|
52
|
+
const RANK: Record<ResourceState, number> = { red: 4, warn: 3, ok: 2, idle: 1, quiet: 0 }
|
|
53
|
+
const worst = (a: ResourceState, b: ResourceState): ResourceState => (RANK[b] > RANK[a] ? b : a)
|
|
54
|
+
|
|
55
|
+
/** an ask that is already done: it rides quiet under `done/`, filed away so
|
|
56
|
+
* past answers stay consultable. Everything else a repo owns is either open
|
|
57
|
+
* or a check, so this is the one archive the tree has */
|
|
58
|
+
const isDone = (r: Resource): boolean => r.type === 'ask' && r.slot === 'done'
|
|
59
|
+
|
|
60
|
+
type Build = { dirs: Map<string, Build>; leaves: Resource[] }
|
|
61
|
+
const emptyBuild = (): Build => ({ dirs: new Map(), leaves: [] })
|
|
62
|
+
|
|
63
|
+
const byLabel = (a: TreeNode, b: TreeNode): number => {
|
|
64
|
+
const ad = a.res == null
|
|
65
|
+
const bd = b.res == null
|
|
66
|
+
if (ad !== bd) return ad ? -1 : 1 // directories first, then leaves
|
|
67
|
+
return a.label.localeCompare(b.label)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const leafNode = (r: Resource): TreeNode => ({
|
|
71
|
+
// the resource id, never its path: two resources may share a file
|
|
72
|
+
key: r.id,
|
|
73
|
+
// the TITLE, never the path segment: the path decides where a row sits,
|
|
74
|
+
// the title is what a reader reads
|
|
75
|
+
label: r.title,
|
|
76
|
+
state: r.state,
|
|
77
|
+
res: r,
|
|
78
|
+
kind: null,
|
|
79
|
+
total: 1,
|
|
80
|
+
reds: r.state === 'red' ? 1 : 0,
|
|
81
|
+
checks: r.check == null ? [] : [r.check.checkId],
|
|
82
|
+
archived: isDone(r),
|
|
83
|
+
page: null,
|
|
84
|
+
children: [],
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const rollup = (children: TreeNode[]): Pick<TreeNode, 'state' | 'total' | 'reds' | 'checks' | 'archived'> => {
|
|
88
|
+
const archived = children.length > 0 && children.every((c) => c.archived)
|
|
89
|
+
return {
|
|
90
|
+
state: children.reduce<ResourceState>((acc, c) => worst(acc, c.state), 'quiet'),
|
|
91
|
+
// an archive is out of the count of the folder that holds it: an
|
|
92
|
+
// asks row reading 25 when 9 of them want hands answers a question
|
|
93
|
+
// nobody asked. The archive itself still counts its own, so the row
|
|
94
|
+
// says how much is filed in there
|
|
95
|
+
total: children.reduce((n, c) => n + (archived || !c.archived ? c.total : 0), 0),
|
|
96
|
+
reds: children.reduce((n, c) => n + c.reds, 0),
|
|
97
|
+
checks: children.flatMap((c) => c.checks),
|
|
98
|
+
archived,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** a chain of directories with one child and nothing of its own renders as
|
|
103
|
+
* ONE row (wiki/build/shipkit rather than three nested rows), and keeps the
|
|
104
|
+
* DEEPEST path as its key so an expansion survives a reload */
|
|
105
|
+
const dirNode = (prefix: string, label: string, b: Build): TreeNode => {
|
|
106
|
+
if (b.leaves.length === 0 && b.dirs.size === 1) {
|
|
107
|
+
const [seg, only] = [...b.dirs.entries()][0]!
|
|
108
|
+
return dirNode(`${prefix}/${seg}`, `${label}/${seg}`, only)
|
|
109
|
+
}
|
|
110
|
+
const children = [
|
|
111
|
+
...[...b.dirs.entries()].map(([seg, sub]) => dirNode(`${prefix}/${seg}`, seg, sub)),
|
|
112
|
+
...b.leaves.map(leafNode),
|
|
113
|
+
].sort(byLabel)
|
|
114
|
+
return { key: prefix, label, res: null, kind: null, ...rollup(children), page: pathPage(prefix, children), children }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const holdsTests = (nodes: TreeNode[]): boolean => nodes.some((n) => n.res?.test != null || holdsTests(n.children))
|
|
118
|
+
|
|
119
|
+
/** the virtual `tests/` root of the path tab, and each `tests/<check>` under
|
|
120
|
+
* it, open the pages of the same groups on the by type tab. A real `tests/`
|
|
121
|
+
* folder that holds no test resource stays a plain folder */
|
|
122
|
+
function pathPage(prefix: string, children: TreeNode[]): HashSel | null {
|
|
123
|
+
if (!holdsTests(children)) return null
|
|
124
|
+
if (prefix === 'tests') return { t: 'tests', check: null }
|
|
125
|
+
const m = prefix.match(/^tests\/([^/]+)$/)
|
|
126
|
+
return m == null || m[1] === NO_CHECK ? null : { t: 'tests', check: m[1]! }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** the by type groups of test files: `tests` itself, and one group per test
|
|
130
|
+
* check. The files no check reaches have no time to show */
|
|
131
|
+
function typePage(key: string, label: string): HashSel | null {
|
|
132
|
+
if (key === 'type:test') return { t: 'tests', check: null }
|
|
133
|
+
return key.startsWith('type:test:') && label !== NO_CHECK ? { t: 'tests', check: label } : null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function buildPathTree(resources: Resource[]): TreeNode[] {
|
|
137
|
+
const root = emptyBuild()
|
|
138
|
+
for (const r of resources) {
|
|
139
|
+
const segs = r.path.split('/').filter((s) => s !== '')
|
|
140
|
+
let cur = root
|
|
141
|
+
for (const seg of segs.slice(0, -1)) {
|
|
142
|
+
let next = cur.dirs.get(seg)
|
|
143
|
+
if (next == null) {
|
|
144
|
+
next = emptyBuild()
|
|
145
|
+
cur.dirs.set(seg, next)
|
|
146
|
+
}
|
|
147
|
+
cur = next
|
|
148
|
+
}
|
|
149
|
+
cur.leaves.push(r)
|
|
150
|
+
}
|
|
151
|
+
return [
|
|
152
|
+
...[...root.dirs.entries()].map(([seg, sub]) => dirNode(seg, seg, sub)),
|
|
153
|
+
...root.leaves.map(leafNode),
|
|
154
|
+
].sort(byLabel)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const groupNode = (key: string, label: string, kind: string, children: TreeNode[]): TreeNode => ({
|
|
158
|
+
key,
|
|
159
|
+
label,
|
|
160
|
+
res: null,
|
|
161
|
+
kind,
|
|
162
|
+
...rollup(children),
|
|
163
|
+
page: typePage(key, label),
|
|
164
|
+
children: [...children].sort(byLabel),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
/** a gate check is filed by WHOSE it is: shipkit ships the SK checks, a repo
|
|
168
|
+
* owns everything it declared itself. There is no second axis, because what
|
|
169
|
+
* a check does and when it runs are already slots on its own row. A test
|
|
170
|
+
* file is filed under the check that runs it, since a repo holds a hundred
|
|
171
|
+
* of them. Every other resource keeps its type word. */
|
|
172
|
+
type Seg = { key: string; label: string; kind: string }
|
|
173
|
+
|
|
174
|
+
const groupOf = (r: Resource): Seg[] => {
|
|
175
|
+
const u = r.check
|
|
176
|
+
if (r.test != null) {
|
|
177
|
+
const by = r.test.standing === 'orphan' || r.test.checkId == null ? NO_CHECK : r.test.checkId
|
|
178
|
+
return [{ key: 'type:test', label: 'tests', kind: 'test' }, { key: `type:test:${by}`, label: by, kind: by === NO_CHECK ? 'test' : 'check' }]
|
|
179
|
+
}
|
|
180
|
+
if (u == null)
|
|
181
|
+
return isDone(r)
|
|
182
|
+
? [{ key: `type:${r.type}`, label: r.type, kind: r.type }, { key: `type:${r.type}:done`, label: 'done', kind: 'done' }]
|
|
183
|
+
: [{ key: `type:${r.type}`, label: r.type, kind: r.type }]
|
|
184
|
+
return u.from === 'repo'
|
|
185
|
+
? [{ key: 'type:check:repo', label: 'from this repo', kind: 'check' }]
|
|
186
|
+
: [{ key: 'type:check:shipkit', label: 'from shipkit', kind: 'check' }]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
type Group = { label: string; kind: string; rs: Resource[]; subs: Map<string, Group> }
|
|
190
|
+
|
|
191
|
+
const groupTree = (key: string, g: Group): TreeNode =>
|
|
192
|
+
groupNode(key, g.label, g.kind, [...[...g.subs.entries()].map(([k, sub]) => groupTree(k, sub)), ...g.rs.map(leafNode)])
|
|
193
|
+
|
|
194
|
+
/** by type: one level per group, plus the `done` archive under the kind
|
|
195
|
+
* that has one, which mirrors the `asks/done/` folder the by path tab
|
|
196
|
+
* shows. Where a check RUNS is already on the row (its note slot) and
|
|
197
|
+
* counted above the tree, so a level for it costs a row to open and answers
|
|
198
|
+
* nothing again. */
|
|
199
|
+
export function buildTypeTree(resources: Resource[]): TreeNode[] {
|
|
200
|
+
const top = new Map<string, Group>()
|
|
201
|
+
for (const r of resources) {
|
|
202
|
+
let cur = top
|
|
203
|
+
let g: Group | null = null
|
|
204
|
+
for (const seg of groupOf(r)) {
|
|
205
|
+
let next = cur.get(seg.key)
|
|
206
|
+
if (next == null) {
|
|
207
|
+
next = { label: seg.label, kind: seg.kind, rs: [], subs: new Map() }
|
|
208
|
+
cur.set(seg.key, next)
|
|
209
|
+
}
|
|
210
|
+
g = next
|
|
211
|
+
cur = next.subs
|
|
212
|
+
}
|
|
213
|
+
g!.rs.push(r)
|
|
214
|
+
}
|
|
215
|
+
return [...top.entries()].map(([key, g]) => groupTree(key, g)).sort(byLabel)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** the visible rows, in screen order — a directory nobody expanded stops
|
|
219
|
+
* the walk, which is the whole point of the expansion set */
|
|
220
|
+
export function flattenTree(nodes: TreeNode[], expanded: Set<string>, depth = 0): TreeRow[] {
|
|
221
|
+
const out: TreeRow[] = []
|
|
222
|
+
for (const n of nodes) {
|
|
223
|
+
const dir = n.res == null
|
|
224
|
+
out.push({ key: n.key, label: n.label, depth, dir, state: n.state, res: n.res, kind: n.kind, total: n.total, reds: n.reds, checks: n.checks, page: n.page })
|
|
225
|
+
if (dir && expanded.has(n.key)) out.push(...flattenTree(n.children, expanded, depth + 1))
|
|
226
|
+
}
|
|
227
|
+
return out
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** the one line above the tree: how much of this repo runs on every gate,
|
|
231
|
+
* and how much does not. Counts CHECKS only, because nothing else declares a
|
|
232
|
+
* place to run */
|
|
233
|
+
export function whenCounts(resources: Resource[]): Record<'always' | 'release' | 'health' | 'off', number> {
|
|
234
|
+
const out = { always: 0, release: 0, health: 0, off: 0 }
|
|
235
|
+
for (const r of resources) if (r.check != null) out[r.check.when]++
|
|
236
|
+
return out
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** every directory key in the tree, which is what "expand all" writes.
|
|
240
|
+
* `archive: false` leaves the done branches folded: a search that yields
|
|
241
|
+
* every fold must not spill what was filed away as answered */
|
|
242
|
+
export function dirKeys(nodes: TreeNode[], archive = true): string[] {
|
|
243
|
+
return nodes.flatMap((n) => (n.res != null || (!archive && n.archived) ? [] : [n.key, ...dirKeys(n.children, archive)]))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** what the label slot says: a check row carries its id as a small tag before
|
|
247
|
+
* its title, every other row its label alone */
|
|
248
|
+
export function rowName(row: Pick<TreeRow, 'label' | 'res'>): { tag: string | null; title: string } {
|
|
249
|
+
const u = row.res?.check
|
|
250
|
+
return u == null ? { tag: null, title: row.label } : checkName(u.checkId, row.label)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── the row slots ────────────────────────────────────────────────────────
|
|
254
|
+
// A row is a fixed grid, so the slots line up down the whole column at any
|
|
255
|
+
// depth. Everything a row says in WORDS lives in `hint`, which is the
|
|
256
|
+
// tooltip: a label carries a name, never a verdict.
|
|
257
|
+
|
|
258
|
+
/** the six states a slot can show, one glyph each, everywhere */
|
|
259
|
+
export type SlotState = 'pass' | 'fail' | 'warn' | 'running' | 'idle' | 'off'
|
|
260
|
+
export const GLYPH: Record<SlotState, string> = { pass: '✓', fail: '✕', warn: '!', running: '◍', idle: '·', off: '○' }
|
|
261
|
+
|
|
262
|
+
/** the glyph's colour class. `adv` is a failure that cannot block: a muted
|
|
263
|
+
* fail rather than a word, and `run` is the one that pulses */
|
|
264
|
+
export type SlotTone = 'red' | 'warn' | 'adv' | 'ok' | 'quiet' | 'idle' | 'run'
|
|
265
|
+
|
|
266
|
+
export type RowSlots = {
|
|
267
|
+
state: SlotState
|
|
268
|
+
tone: SlotTone
|
|
269
|
+
/** right aligned, tabular, blank when nothing was measured */
|
|
270
|
+
dur: string
|
|
271
|
+
/** the same tone table the check page reads, so a row and the page it
|
|
272
|
+
* opens can never disagree about a number */
|
|
273
|
+
durTone: DurTone | null
|
|
274
|
+
/** the one short fact this kind has to offer */
|
|
275
|
+
note: string
|
|
276
|
+
/** directories only: what is under it, and how much of it is red */
|
|
277
|
+
count: { total: number; reds: number } | null
|
|
278
|
+
/** every word the row does not show, as the row's tooltip */
|
|
279
|
+
hint: string
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const DIR_STATE: Record<ResourceState, SlotState> = { red: 'fail', warn: 'warn', ok: 'pass', idle: 'idle', quiet: 'off' }
|
|
283
|
+
const DIR_TONE: Record<ResourceState, SlotTone> = { red: 'red', warn: 'warn', ok: 'ok', idle: 'idle', quiet: 'quiet' }
|
|
284
|
+
|
|
285
|
+
/** the four values, short enough for a 48px slot. The tooltip says them in
|
|
286
|
+
* full, because these are abbreviations and nobody was born knowing them */
|
|
287
|
+
const WHEN_NOTE = { always: 'ci', release: 'release', health: 'health', off: 'off' } as const
|
|
288
|
+
const WHEN_HINT = {
|
|
289
|
+
always: 'runs on every gate',
|
|
290
|
+
release: 'runs on the release path only',
|
|
291
|
+
health: 'runs in the health pass only, never on a gate',
|
|
292
|
+
off: 'does not run',
|
|
293
|
+
} as const
|
|
294
|
+
|
|
295
|
+
export function rowSlots(row: TreeRow, running: boolean): RowSlots {
|
|
296
|
+
if (row.dir)
|
|
297
|
+
return {
|
|
298
|
+
state: DIR_STATE[row.state],
|
|
299
|
+
tone: DIR_TONE[row.state],
|
|
300
|
+
dur: '',
|
|
301
|
+
durTone: null,
|
|
302
|
+
note: '',
|
|
303
|
+
count: { total: row.total, reds: row.reds },
|
|
304
|
+
hint: `${row.label} · ${row.total} under it${row.reds > 0 ? `, ${row.reds} red` : ''}`,
|
|
305
|
+
}
|
|
306
|
+
const r = row.res
|
|
307
|
+
if (r == null) return { state: 'idle', tone: 'idle', dur: '', durTone: null, note: '', count: null, hint: row.label }
|
|
308
|
+
if (r.test != null) return testSlots(r, r.test)
|
|
309
|
+
const u = r.check
|
|
310
|
+
if (u == null)
|
|
311
|
+
return {
|
|
312
|
+
state: DIR_STATE[r.state],
|
|
313
|
+
tone: DIR_TONE[r.state],
|
|
314
|
+
dur: '',
|
|
315
|
+
durTone: null,
|
|
316
|
+
note: r.slot ?? '',
|
|
317
|
+
count: null,
|
|
318
|
+
hint: [r.text, r.virtual === true ? `${r.path} (no file of its own)` : r.path].join('\n'),
|
|
319
|
+
}
|
|
320
|
+
const verdict: [SlotState, SlotTone] = running
|
|
321
|
+
? ['running', 'run']
|
|
322
|
+
: u.when === 'off'
|
|
323
|
+
? ['off', 'quiet']
|
|
324
|
+
: u.status === 'fail'
|
|
325
|
+
? ['fail', u.advisory ? 'adv' : 'red']
|
|
326
|
+
: u.status === 'warn'
|
|
327
|
+
? ['warn', 'warn']
|
|
328
|
+
: u.status === 'skip'
|
|
329
|
+
? ['off', 'quiet']
|
|
330
|
+
: u.status === 'pass'
|
|
331
|
+
? ['pass', u.replayed ? 'quiet' : 'ok']
|
|
332
|
+
: ['idle', 'idle']
|
|
333
|
+
const words = running ? 'running now' : checkVerdictWords(u.status, u)
|
|
334
|
+
return {
|
|
335
|
+
state: verdict[0],
|
|
336
|
+
tone: verdict[1],
|
|
337
|
+
dur: fmtMs(u.lastMs),
|
|
338
|
+
durTone: durationTone(u.lastMs, u.timeoutMs),
|
|
339
|
+
note: WHEN_NOTE[u.when],
|
|
340
|
+
count: null,
|
|
341
|
+
hint: [
|
|
342
|
+
`${u.checkId} (${u.build ? 'build step' : u.from === 'shipkit' ? 'from shipkit' : 'from this repo'}) · ${words}`,
|
|
343
|
+
// the sentence the check wrote about itself, so a row says what it
|
|
344
|
+
// is about without being opened
|
|
345
|
+
u.description != null && u.description !== '' ? u.description : null,
|
|
346
|
+
u.lastMs != null ? `last run ${fmtMs(u.lastMs)}` : null,
|
|
347
|
+
u.samples > 0 ? `average ${fmtMs(u.avgMs)} over ${plural(u.samples, 'run')}` : null,
|
|
348
|
+
`${WHEN_HINT[u.when]}${u.whenReason != null ? `: ${u.whenReason}` : ''}`,
|
|
349
|
+
].filter((l) => l != null).join('\n'),
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** a test file row: its verdict, its last time against its budget, its
|
|
354
|
+
* tier. Over budget is red on the duration, whatever the table says, unless
|
|
355
|
+
* it is declared slow and the budget is not its to break */
|
|
356
|
+
function testSlots(r: Resource, t: TestFacts): RowSlots {
|
|
357
|
+
const last = t.runs[0]
|
|
358
|
+
const over = last != null && last.tier === 'quick' && t.tier === 'quick' && last.ms > t.budgetMs
|
|
359
|
+
return {
|
|
360
|
+
state: DIR_STATE[r.state],
|
|
361
|
+
tone: DIR_TONE[r.state],
|
|
362
|
+
dur: last == null ? '' : fmtMs(last.ms),
|
|
363
|
+
durTone: last == null ? null : over ? 'bad' : durationTone(last.ms),
|
|
364
|
+
note: r.slot ?? '',
|
|
365
|
+
count: null,
|
|
366
|
+
hint: [
|
|
367
|
+
t.file,
|
|
368
|
+
r.text,
|
|
369
|
+
last == null ? null : `last run ${fmtMs(last.ms)} of a ${fmtMs(t.budgetMs)} budget`,
|
|
370
|
+
].filter((l) => l != null).join('\n'),
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── the KIND of a row ────────────────────────────────────────────────────
|
|
375
|
+
// A tree of a hundred rows said nothing about what any of them WAS. One
|
|
376
|
+
// glyph and one hue per kind, in the slot right after the caret, and the hue
|
|
377
|
+
// is the KIND's identity: it never carries state, which stays on the label
|
|
378
|
+
// and on the state glyph at the right edge. Hues are tokens (k1..k6 on
|
|
379
|
+
// :root), reused across kinds on purpose, because thirteen hues nobody can
|
|
380
|
+
// tell apart is not thirteen answers. The glyph is what identifies, the hue
|
|
381
|
+
// only groups.
|
|
382
|
+
|
|
383
|
+
export type TypeMark = { glyph: string; hue: string; word: string }
|
|
384
|
+
|
|
385
|
+
export const TYPE_MARK: Record<string, TypeMark> = {
|
|
386
|
+
folder: { glyph: '\u25a3', hue: 'k5', word: 'a folder of this repo' },
|
|
387
|
+
ask: { glyph: '\u25c9', hue: 'k2', word: 'an ask: something only a human can do' },
|
|
388
|
+
secret: { glyph: '\u25c8', hue: 'k4', word: 'a secret this repo needs, present or missing' },
|
|
389
|
+
asset: { glyph: '\u25c6', hue: 'k1', word: 'an asset of the kit, with its rolls and its pick' },
|
|
390
|
+
post: { glyph: '\u25a4', hue: 'k2', word: 'a post waiting in the social queue' },
|
|
391
|
+
workflow: { glyph: '\u29c9', hue: 'k6', word: 'a comfy workflow module' },
|
|
392
|
+
wiki: { glyph: '\u2b21', hue: 'k3', word: 'a markdown wiki, opened as its link graph' },
|
|
393
|
+
contract: { glyph: '\u00a7', hue: 'k4', word: 'a release contract of a venue' },
|
|
394
|
+
health: { glyph: '\u229e', hue: 'k5', word: 'a health row that belongs to no other kind' },
|
|
395
|
+
check: { glyph: '\u2299', hue: 'k6', word: 'a gate check: one question `shipkit ci` or `shipkit check` asks, whether it builds, guards a repo shape or probes the world. same page and same switch for all of them' },
|
|
396
|
+
test: { glyph: '\u2263', hue: 'k5', word: 'a test file: the check that runs it, its last runs against its budget, the command that runs it alone' },
|
|
397
|
+
reflection: { glyph: '\u203b', hue: 'k2', word: 'a reflection: friction this repo logged against the wiki, waiting to be folded in' },
|
|
398
|
+
done: { glyph: '\u2713', hue: 'k5', word: 'a done ask: answered, filed under done, out of every count above it' },
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** a kind nobody mapped still gets a mark: a blank slot in a fixed grid is a
|
|
402
|
+
* hole, and a new resource kind must never make the tree look broken */
|
|
403
|
+
const UNKNOWN: TypeMark = { glyph: '\u25cb', hue: 'k5', word: 'a kind this page does not know yet' }
|
|
404
|
+
|
|
405
|
+
/** a real folder wears the folder mark, a by type group the mark of the kind
|
|
406
|
+
* it gathers, and a leaf its own kind */
|
|
407
|
+
export const markOf = (res: Resource | null | undefined, dir: boolean, kind: string | null = null): TypeMark =>
|
|
408
|
+
dir
|
|
409
|
+
? kind == null ? TYPE_MARK.folder! : TYPE_MARK[kind] ?? UNKNOWN
|
|
410
|
+
: res == null ? UNKNOWN : isDone(res) ? TYPE_MARK.done! : TYPE_MARK[res.type] ?? UNKNOWN
|