rvlib-shipkit 1.4.0 → 1.5.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 +68 -0
- package/README.md +17 -17
- package/package.json +3 -2
- package/src/check.ts +68 -95
- package/src/checks/SK001-package-json-shape.check.ts +6 -2
- package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
- package/src/checks/SK003-claude-md-shape.check.ts +5 -1
- package/src/checks/SK004-gitignore-policy.check.ts +7 -4
- package/src/checks/SK005-rvlib-versions.check.ts +53 -21
- package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
- package/src/checks/SK007-github-visibility.check.ts +4 -2
- package/src/checks/SK008-reviewer-agent.check.ts +6 -4
- package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
- package/src/checks/SK010-undeclared-unit.check.ts +4 -0
- package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
- package/src/checks/SK012-config-barrel-import.check.ts +5 -1
- package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
- package/src/checks/SK014-prose-ste.check.ts +4 -0
- package/src/checks/SK015-nested-repo.check.ts +4 -2
- package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
- package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
- package/src/checks/SK018-test-files-run.check.ts +6 -2
- package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
- package/src/checks/SK020-self-spawning-check.check.ts +4 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
- package/src/checks/SK024-identity-colors.check.ts +5 -3
- package/src/checks/SK025-release-drift.check.ts +4 -5
- package/src/checks/SK026-asks-autoclose.check.ts +4 -4
- package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
- package/src/checks/SK028-readme.check.ts +5 -1
- package/src/checks/SK029-explicit-check-files.check.ts +95 -0
- package/src/cli/cmd_Asks.ts +5 -5
- package/src/cli/cmd_Assets.ts +7 -7
- package/src/cli/cmd_Bump.ts +12 -11
- package/src/cli/cmd_Check.ts +8 -7
- package/src/cli/cmd_Checks.ts +7 -6
- package/src/cli/cmd_Ci.ts +12 -11
- package/src/cli/cmd_Commands.ts +16 -13
- package/src/cli/cmd_ConfigKeys.ts +7 -6
- package/src/cli/cmd_Contracts.ts +3 -3
- package/src/cli/cmd_CrossRepoActivity.ts +6 -5
- package/src/cli/cmd_Fleet.ts +9 -7
- package/src/cli/cmd_GlobalProcesses.ts +116 -4
- package/src/cli/cmd_Home.ts +23 -17
- package/src/cli/cmd_Hooks.ts +6 -5
- package/src/cli/cmd_Init.ts +5 -4
- package/src/cli/cmd_Keys.ts +8 -7
- package/src/cli/cmd_Mv.ts +7 -6
- package/src/cli/cmd_Needs.ts +4 -3
- package/src/cli/cmd_Overview.ts +4 -3
- package/src/cli/cmd_Pb.ts +2 -2
- package/src/cli/cmd_Planned.ts +6 -5
- package/src/cli/cmd_Prose.ts +5 -5
- package/src/cli/cmd_Reflect.ts +7 -7
- package/src/cli/cmd_Release.ts +6 -5
- package/src/cli/cmd_Releases.ts +5 -4
- package/src/cli/cmd_Serve.ts +4 -3
- package/src/cli/cmd_Smoke.ts +5 -4
- package/src/cli/cmd_Start.ts +7 -6
- package/src/cli/cmd_Status.ts +4 -3
- package/src/cli/cmd_Tui.ts +3 -2
- package/src/cli/cmd_When.ts +71 -0
- package/src/cli/cmd_Wiki.ts +4 -4
- package/src/cli.ts +7 -2
- package/src/collections/checks.ts +2 -0
- package/src/collections/collection-types.ts +9 -4
- package/src/core/canonical-scripts.ts +1 -1
- package/src/core/check-runner.ts +37 -20
- package/src/core/config-rewrite.ts +37 -24
- package/src/core/discover-checks.ts +23 -2
- package/src/core/legacy-shapes.ts +39 -10
- package/src/core/one-way-in-policy.ts +2 -2
- package/src/core/outcome-words.ts +18 -2
- package/src/core/port-check-cli.ts +52 -0
- package/src/core/port-check-file.ts +295 -0
- package/src/core/port-check-files.ts +31 -0
- package/src/core/retention.ts +0 -14
- package/src/core/run-check.ts +12 -9
- package/src/core/run-ci.ts +8 -4
- package/src/core/run-fix.ts +30 -23
- package/src/core/run-fixers.ts +42 -5
- package/src/core/runs-matrix.ts +179 -0
- package/src/core/runs.ts +242 -0
- package/src/core/update-policy.ts +20 -0
- package/src/core/when-block.ts +29 -30
- package/src/core/when.ts +41 -103
- package/src/core/wire.ts +10 -14
- package/src/fleet/scan-fleet.ts +20 -4
- package/src/fleet/update-fleet.ts +6 -6
- package/src/home/home-launchd.ts +20 -4
- package/src/home/home-routes.ts +12 -1
- package/src/home/hubs.ts +5 -5
- package/src/home/pm.ts +8 -0
- package/src/home/run-pm.ts +1 -0
- package/src/home/serve-web.ts +2 -2
- package/src/home/supervise.ts +24 -1
- package/src/hooks/run-hooks.ts +48 -4
- package/src/init-repo.ts +6 -6
- package/src/ledger/gate-checks.ts +113 -59
- package/src/ledger/git-key.ts +5 -1
- package/src/ledger/ledger.ts +5 -5
- package/src/ledger/toolchain-identity.ts +45 -27
- package/src/plan/repo-source.ts +18 -39
- package/src/release/run-release.ts +5 -0
- package/src/repo-config.ts +30 -55
- package/src/rules/rule.ts +2 -2
- package/src/scaffold/files/agent-coding.md +1 -1
- package/src/scaffold/files/agent-index.md +1 -1
- package/src/scaffold/files/verify-skill.md +2 -2
- package/src/serve/hub-app/activity-st.ts +5 -4
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/admin-view.tsx +13 -11
- package/src/serve/hub-app/check-view.tsx +66 -46
- package/src/serve/hub-app/check-words.ts +1 -11
- package/src/serve/hub-app/checks-svc.ts +14 -35
- package/src/serve/hub-app/col-tables-view.tsx +13 -6
- package/src/serve/hub-app/cols-svc.ts +5 -4
- package/src/serve/hub-app/dock-st.ts +3 -2
- package/src/serve/hub-app/dock-view.tsx +36 -0
- package/src/serve/hub-app/dock.css +6 -0
- package/src/serve/hub-app/float-cards.tsx +30 -0
- package/src/serve/hub-app/floating.tsx +41 -4
- package/src/serve/hub-app/git-st.ts +42 -0
- package/src/serve/hub-app/git-view.tsx +61 -0
- package/src/serve/hub-app/hash-route.ts +45 -1
- package/src/serve/hub-app/header-bar.tsx +62 -72
- package/src/serve/hub-app/health-svc.ts +3 -2
- package/src/serve/hub-app/home-st.ts +14 -2
- package/src/serve/hub-app/home-strip.css +43 -8
- package/src/serve/hub-app/home-strip.tsx +111 -47
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +14 -4
- package/src/serve/hub-app/hub-types.ts +5 -2
- package/src/serve/hub-app/hub.css +65 -43
- package/src/serve/hub-app/icons.tsx +11 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/keys.ts +5 -2
- package/src/serve/hub-app/live-svc.ts +3 -2
- package/src/serve/hub-app/machine-st.ts +18 -22
- package/src/serve/hub-app/plan-view.tsx +51 -0
- package/src/serve/hub-app/planned-st.ts +9 -4
- package/src/serve/hub-app/processes-view.tsx +38 -0
- package/src/serve/hub-app/projects-svc.ts +15 -10
- package/src/serve/hub-app/prose-hits-st.ts +3 -2
- package/src/serve/hub-app/reach.ts +36 -0
- package/src/serve/hub-app/readme-svc.ts +3 -2
- package/src/serve/hub-app/repo-sections.ts +117 -0
- package/src/serve/hub-app/repo-st.ts +46 -9
- package/src/serve/hub-app/sidebar.tsx +12 -37
- package/src/serve/hub-app/timeline-svc.ts +3 -2
- package/src/serve/hub-app/tree-build.ts +149 -155
- package/src/serve/hub-app/tree-row.tsx +39 -9
- package/src/serve/hub-app/tree-svc.ts +70 -49
- package/src/serve/hub-app/when-view.tsx +89 -0
- package/src/serve/hub-app/when.css +17 -0
- package/src/serve/hub-runtime.ts +3 -16
- package/src/serve/run-serve.ts +1 -1
- package/src/serve/serve-hub.ts +4 -14
- package/src/serve/hub-app/processes-svc.ts +0 -63
- package/src/serve/hub-app/repo-sections.tsx +0 -61
- package/src/serve/repo-processes.ts +0 -89
|
@@ -1,47 +1,31 @@
|
|
|
1
|
-
// A repo page's left pane: its
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// the filter
|
|
1
|
+
// A repo page's left pane: the filter with its severity group on one line,
|
|
2
|
+
// then ONE tree whose top rows are the repo's sections (tree-svc.ts) and
|
|
3
|
+
// whose rows under them are every resource of the repo. Only the tree
|
|
4
|
+
// scrolls, so the filter stays in reach. The row itself is tree-row.tsx.
|
|
5
5
|
import { observer } from 'mobx-react-lite'
|
|
6
|
-
import { Help } from './help.tsx'
|
|
7
6
|
import { SEARCH_KEY } from './keys.ts'
|
|
8
|
-
import { Sections } from './repo-sections.tsx'
|
|
9
7
|
import type { RepoSt } from './repo-st.ts'
|
|
10
|
-
import { TYPE_MARK } from './tree-build.ts'
|
|
11
8
|
import { TreeRowView } from './tree-row.tsx'
|
|
12
9
|
|
|
13
10
|
const Tree = observer(({ app }: { app: RepoSt }) => {
|
|
14
11
|
const t = app.tree
|
|
12
|
+
const empty = app.cols.loaded && app.filter !== 'all' && app.query.trim() === '' && t.visible.length === 0
|
|
15
13
|
return (
|
|
16
14
|
// roving focus: the arrows belong to the tree only while it HAS the
|
|
17
15
|
// focus, so the atelier keeps its own keys
|
|
18
16
|
<div className="tree" tabIndex={0} onKeyDown={(e) => { if (t.key(e.key)) e.preventDefault() }}>
|
|
19
17
|
{t.rows.map((r) => <TreeRowView key={r.key} app={app} row={r} />)}
|
|
20
18
|
{!app.cols.loaded && !app.cols.cached ? <div className="tempty twait"><span className="spin" />reading the repo…</div> : null}
|
|
21
|
-
{
|
|
19
|
+
{empty ? <div className="tempty">{app.filter === 'red' ? 'no reds — nothing is blocked ✨' : 'nothing needs you ✨'}</div> : null}
|
|
20
|
+
{t.hidden > 0 ? (
|
|
21
|
+
<div className="tempty thidden" role="button" title="show every row (a)" onClick={() => app.setFilter('all')}>
|
|
22
|
+
{t.hidden} {t.hidden === 1 ? 'row' : 'rows'} hidden by <b>{app.filter}</b> · show all <kbd className="pjkey">a</kbd>
|
|
23
|
+
</div>
|
|
24
|
+
) : null}
|
|
22
25
|
</div>
|
|
23
26
|
)
|
|
24
27
|
})
|
|
25
28
|
|
|
26
|
-
/** what every glyph in the type slot means, behind one `(?)` */
|
|
27
|
-
const TypeLegend = () => (
|
|
28
|
-
<Help text={
|
|
29
|
-
<span className="legend">
|
|
30
|
-
{Object.entries(TYPE_MARK).map(([kind, m]) => (
|
|
31
|
-
<span key={kind} className="lrow"><i className={`ty ${m.hue}`}>{m.glyph}</i>{m.word}</span>
|
|
32
|
-
))}
|
|
33
|
-
</span>
|
|
34
|
-
} />
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
const TreeTabs = observer(({ app }: { app: RepoSt }) => (
|
|
38
|
-
<span className="seg">
|
|
39
|
-
{(['path', 'type'] as const).map((t) => (
|
|
40
|
-
<button key={t} className={app.tree.tab === t ? 'on' : ''} onClick={() => app.tree.setTab(t)}>by {t}</button>
|
|
41
|
-
))}
|
|
42
|
-
</span>
|
|
43
|
-
))
|
|
44
|
-
|
|
45
29
|
// segmented severity filter — all · needs (yellow+red) · red, the address
|
|
46
30
|
// rides at /?f=… so a filtered view is shareable. The shortcut letter rides
|
|
47
31
|
// INSIDE its own button, so the binding is discoverable without a legend
|
|
@@ -58,24 +42,15 @@ const SegFilter = observer(({ app }: { app: RepoSt }) => (
|
|
|
58
42
|
|
|
59
43
|
export const Sidebar = observer(({ app }: { app: RepoSt }) => (
|
|
60
44
|
<aside>
|
|
61
|
-
<Sections app={app} />
|
|
62
45
|
<div className="ssearch">
|
|
63
46
|
<span className="sfield">
|
|
64
|
-
<input ref={(el) => { app.searchEl = el }} placeholder="filter" aria-label="filter the tree" value={app.query}
|
|
47
|
+
<input ref={(el) => { app.searchEl = el }} placeholder="filter" aria-label="filter the tree by name or path" value={app.query}
|
|
65
48
|
onChange={(e) => app.setQuery(e.target.value)}
|
|
66
49
|
onKeyDown={(e) => { if (e.key === 'Escape') app.setQuery('') }} />
|
|
67
50
|
<kbd className="skey">{SEARCH_KEY}</kbd>
|
|
68
51
|
</span>
|
|
69
52
|
<SegFilter app={app} />
|
|
70
53
|
</div>
|
|
71
|
-
<div className="stabs">
|
|
72
|
-
<TreeTabs app={app} />
|
|
73
|
-
<span className="sp" />
|
|
74
|
-
<TypeLegend />
|
|
75
|
-
<span className="cnt">{app.tree.visible.length}</span>
|
|
76
|
-
</div>
|
|
77
|
-
{/* its line is held while the repo is read, so the tree never drops under it */}
|
|
78
|
-
{app.tree.whenLine === '' && app.cols.loaded ? null : <div className="swhen">{app.tree.whenLine === '' ? ' ' : app.tree.whenLine}</div>}
|
|
79
54
|
{app.media.dead != null ? <div className="tempty"><span className="warnline">media API down: {app.media.dead}</span></div> : null}
|
|
80
55
|
<div className="tscroll">
|
|
81
56
|
<Tree app={app} />
|
|
@@ -7,6 +7,7 @@ import { testId } from '../../collections/collection-types.ts'
|
|
|
7
7
|
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
8
8
|
import type { Timeline } from '../../ledger/run-timeline.ts'
|
|
9
9
|
import type { RepoSt } from './repo-st.ts'
|
|
10
|
+
import { ownTrouble, reach } from './reach.ts'
|
|
10
11
|
|
|
11
12
|
export type { Timeline }
|
|
12
13
|
|
|
@@ -50,12 +51,12 @@ export class TimelineSvc {
|
|
|
50
51
|
async load(fresh = false): Promise<void> {
|
|
51
52
|
runInAction(() => { this.loading = true })
|
|
52
53
|
try {
|
|
53
|
-
const r = await
|
|
54
|
+
const r = await reach(this.st.url(fresh ? '/api/timeline.json?fresh=1' : '/api/timeline.json'))
|
|
54
55
|
if (!r.ok) throw new Error(await r.text())
|
|
55
56
|
const t = (await r.json()) as Timeline
|
|
56
57
|
runInAction(() => { this.data = t; this.error = '' })
|
|
57
58
|
} catch (e) {
|
|
58
|
-
runInAction(() => { this.error = (e
|
|
59
|
+
runInAction(() => { this.error = ownTrouble(e, this.st.home.machine) })
|
|
59
60
|
} finally {
|
|
60
61
|
runInAction(() => { this.loading = false })
|
|
61
62
|
}
|
|
@@ -1,23 +1,30 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// no dom: TreeSvc caches these as computeds and the tests drive them
|
|
3
|
-
// headless.
|
|
4
|
-
//
|
|
5
|
-
|
|
1
|
+
// The left pane's tree, as pure functions over one flat Resource[]. No react,
|
|
2
|
+
// no mobx, no dom: TreeSvc caches these as computeds and the tests drive them
|
|
3
|
+
// headless. The top rows are the repo's sections (keys.ts SectionId), and
|
|
4
|
+
// every resource kind has exactly one section it lives under (KIND_SECTION),
|
|
5
|
+
// so no kind can fall out of the pane. A section a repo does not draw still
|
|
6
|
+
// gets its row when a resource lands in it.
|
|
7
|
+
import { type Resource, type ResourceState, type ResourceType, type TestFacts } from '../../collections/collection-types.ts'
|
|
8
|
+
import type { ProcessRow, RowDot } from '../../home/process-rows.ts'
|
|
6
9
|
import { checkVerdictWords, plural } from '../../core/outcome-words.ts'
|
|
10
|
+
import { runsWords } from '../../core/runs-matrix.ts'
|
|
7
11
|
import { checkName } from './check-words.ts'
|
|
8
12
|
import { durationTone, fmtMs, type DurTone } from './dur.ts'
|
|
9
13
|
import type { HashSel } from './hash-route.ts'
|
|
14
|
+
import { SECTION_LETTER, sectionLabel, type SectionId } from './keys.ts'
|
|
10
15
|
|
|
11
16
|
export type TreeNode = {
|
|
12
17
|
/** stable across reloads: the expansion set and the cursor are keys */
|
|
13
18
|
key: string
|
|
14
19
|
label: string
|
|
15
20
|
state: ResourceState
|
|
16
|
-
/** null on a
|
|
21
|
+
/** null on a group and on a section */
|
|
17
22
|
res: Resource | null
|
|
18
|
-
/** a
|
|
19
|
-
*
|
|
23
|
+
/** a group: the kind it gathers, so its row wears that kind's mark.
|
|
24
|
+
* null on a section and on a leaf */
|
|
20
25
|
kind: string | null
|
|
26
|
+
/** a top row: the section it is. null everywhere below */
|
|
27
|
+
section: SectionId | null
|
|
21
28
|
/** leaves under this node, and how many of them are red */
|
|
22
29
|
total: number
|
|
23
30
|
reds: number
|
|
@@ -26,8 +33,8 @@ export type TreeNode = {
|
|
|
26
33
|
/** the archive: everything under it is already done. It stays in the
|
|
27
34
|
* tree and out of every count above it */
|
|
28
35
|
archived: boolean
|
|
29
|
-
/** a
|
|
30
|
-
* chevron folds. null on every other
|
|
36
|
+
/** a group with a page of its own: its label opens the page, its
|
|
37
|
+
* chevron folds. null on every other group and on a leaf */
|
|
31
38
|
page: HashSel | null
|
|
32
39
|
children: TreeNode[]
|
|
33
40
|
}
|
|
@@ -36,46 +43,101 @@ export type TreeRow = {
|
|
|
36
43
|
key: string
|
|
37
44
|
label: string
|
|
38
45
|
depth: number
|
|
46
|
+
/** it has children, so it wears a chevron */
|
|
39
47
|
dir: boolean
|
|
40
48
|
state: ResourceState
|
|
41
49
|
res: Resource | null
|
|
42
50
|
kind: string | null
|
|
51
|
+
section: SectionId | null
|
|
43
52
|
total: number
|
|
44
53
|
reds: number
|
|
45
54
|
checks: string[]
|
|
46
55
|
page: HashSel | null
|
|
47
56
|
}
|
|
48
57
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
/** the sections, top to bottom */
|
|
59
|
+
export const SECTION_ORDER = Object.keys(SECTION_LETTER) as SectionId[]
|
|
60
|
+
|
|
61
|
+
/** where each resource kind lives. A Record over every kind, so a kind added
|
|
62
|
+
* to RESOURCE_TYPES does not compile until it has a home. Runs, plan, git and
|
|
63
|
+
* admin are pages only, so no kind lives there */
|
|
64
|
+
export const KIND_SECTION: Record<ResourceType, SectionId> = {
|
|
65
|
+
check: 'repo',
|
|
66
|
+
reflection: 'reflections',
|
|
67
|
+
secret: 'secrets',
|
|
68
|
+
contract: 'contracts',
|
|
69
|
+
ask: 'asks',
|
|
70
|
+
test: 'tests',
|
|
71
|
+
asset: 'media',
|
|
72
|
+
workflow: 'media',
|
|
73
|
+
post: 'media',
|
|
74
|
+
wiki: 'wikis',
|
|
75
|
+
process: 'processes',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** a process's colour: up reads green, crashed red, on its way orange,
|
|
79
|
+
* stopped plain, and unknown (pm not answering) out of play */
|
|
80
|
+
const PROCESS_STATE: Record<RowDot, ResourceState> = { up: 'ok', crashed: 'red', starting: 'warn', down: 'idle', unknown: 'quiet' }
|
|
81
|
+
|
|
82
|
+
/** the repo's global processes as tree leaves. pm owns them, not the hub, so
|
|
83
|
+
* they come from the machine and never from /api/resources.json */
|
|
84
|
+
export const processResources = (rows: readonly ProcessRow[]): Resource[] =>
|
|
85
|
+
rows.map((r) => ({
|
|
86
|
+
id: `process:${r.id}`,
|
|
87
|
+
type: 'process',
|
|
88
|
+
collection: 'processes',
|
|
89
|
+
path: `processes/${r.id}`,
|
|
90
|
+
virtual: true,
|
|
91
|
+
title: r.label,
|
|
92
|
+
text: r.description ?? r.label,
|
|
93
|
+
slot: r.status,
|
|
94
|
+
state: PROCESS_STATE[r.dot],
|
|
95
|
+
}))
|
|
96
|
+
|
|
97
|
+
/** a gate check lives under WHOSE it is: shipkit ships the SK checks, a
|
|
98
|
+
* repo owns what it declared. A kind the map does not know (a newer hub)
|
|
99
|
+
* still lands somewhere */
|
|
100
|
+
export const sectionOf = (r: Resource): SectionId =>
|
|
101
|
+
r.check != null ? (r.check.from === 'shipkit' ? 'shipkit' : 'repo') : (KIND_SECTION[r.type as ResourceType] ?? 'repo')
|
|
102
|
+
|
|
103
|
+
/** the severity filter, read off the COLOUR, so what `red` shows is what
|
|
104
|
+
* reads red on the tree. An ask is proactive work, so it is in needs and
|
|
105
|
+
* never in red (collection-types stateOf owns that rule) */
|
|
106
|
+
export const passesFilter = (r: Resource, f: 'all' | 'needs' | 'red'): boolean =>
|
|
107
|
+
f === 'red' ? r.state === 'red' : f === 'needs' ? r.severity != null : true
|
|
108
|
+
|
|
109
|
+
/** the filter input: a part of the title or of the path */
|
|
110
|
+
export const matchesQuery = (r: Resource, q: string): boolean =>
|
|
111
|
+
q === '' || r.title.toLowerCase().includes(q) || r.path.toLowerCase().includes(q)
|
|
112
|
+
|
|
113
|
+
// a group wears the WORST state under it, so a collapsed branch can never
|
|
114
|
+
// hide a red, and OK outranks idle: a group of passing checks read as plain
|
|
115
|
+
// white because one unmeasured row in it outvoted twenty nine greens
|
|
52
116
|
const RANK: Record<ResourceState, number> = { red: 4, warn: 3, ok: 2, idle: 1, quiet: 0 }
|
|
53
117
|
const worst = (a: ResourceState, b: ResourceState): ResourceState => (RANK[b] > RANK[a] ? b : a)
|
|
54
118
|
|
|
55
|
-
/** an ask that is already done: it rides quiet under `done
|
|
119
|
+
/** an ask that is already done: it rides quiet under `done`, filed away so
|
|
56
120
|
* past answers stay consultable. Everything else a repo owns is either open
|
|
57
121
|
* or a check, so this is the one archive the tree has */
|
|
58
122
|
const isDone = (r: Resource): boolean => r.type === 'ask' && r.slot === 'done'
|
|
59
123
|
|
|
60
|
-
type Build = { dirs: Map<string, Build>; leaves: Resource[] }
|
|
61
|
-
const emptyBuild = (): Build => ({ dirs: new Map(), leaves: [] })
|
|
62
|
-
|
|
63
124
|
const byLabel = (a: TreeNode, b: TreeNode): number => {
|
|
64
125
|
const ad = a.res == null
|
|
65
126
|
const bd = b.res == null
|
|
66
|
-
if (ad !== bd) return ad ? -1 : 1 //
|
|
127
|
+
if (ad !== bd) return ad ? -1 : 1 // groups first, then leaves
|
|
67
128
|
return a.label.localeCompare(b.label)
|
|
68
129
|
}
|
|
69
130
|
|
|
70
131
|
const leafNode = (r: Resource): TreeNode => ({
|
|
71
132
|
// the resource id, never its path: two resources may share a file
|
|
72
133
|
key: r.id,
|
|
73
|
-
// the TITLE, never the path
|
|
74
|
-
//
|
|
134
|
+
// the TITLE, never the path: the path is what the filter matches, the
|
|
135
|
+
// title is what a reader reads
|
|
75
136
|
label: r.title,
|
|
76
137
|
state: r.state,
|
|
77
138
|
res: r,
|
|
78
139
|
kind: null,
|
|
140
|
+
section: null,
|
|
79
141
|
total: 1,
|
|
80
142
|
reds: r.state === 'red' ? 1 : 0,
|
|
81
143
|
checks: r.check == null ? [] : [r.check.checkId],
|
|
@@ -88,10 +150,9 @@ const rollup = (children: TreeNode[]): Pick<TreeNode, 'state' | 'total' | 'reds'
|
|
|
88
150
|
const archived = children.length > 0 && children.every((c) => c.archived)
|
|
89
151
|
return {
|
|
90
152
|
state: children.reduce<ResourceState>((acc, c) => worst(acc, c.state), 'quiet'),
|
|
91
|
-
// an archive is out of the count of the
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// says how much is filed in there
|
|
153
|
+
// an archive is out of the count of the row that holds it: an asks
|
|
154
|
+
// row reading 25 when 9 of them want hands answers a question nobody
|
|
155
|
+
// asked. The archive itself still counts its own
|
|
95
156
|
total: children.reduce((n, c) => n + (archived || !c.archived ? c.total : 0), 0),
|
|
96
157
|
reds: children.reduce((n, c) => n + c.reds, 0),
|
|
97
158
|
checks: children.flatMap((c) => c.checks),
|
|
@@ -99,148 +160,90 @@ const rollup = (children: TreeNode[]): Pick<TreeNode, 'state' | 'total' | 'reds'
|
|
|
99
160
|
}
|
|
100
161
|
}
|
|
101
162
|
|
|
102
|
-
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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))
|
|
163
|
+
/** the groups a resource sits in under its section. A check, a contract, a
|
|
164
|
+
* secret, an ask, a test file, a wiki and an asset sit directly under their
|
|
165
|
+
* section, one row each, a done ask under the `done` archive. Every other
|
|
166
|
+
* kind gets one group named for it, and the group opens that collection's
|
|
167
|
+
* page */
|
|
168
|
+
type Seg = { key: string; label: string; kind: string; page: HashSel | null }
|
|
118
169
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
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)
|
|
170
|
+
const groupOf = (r: Resource): Seg[] => {
|
|
171
|
+
if (r.test != null) return []
|
|
172
|
+
if (r.check != null || r.type === 'contract' || r.type === 'secret') return []
|
|
173
|
+
if (r.type === 'ask') return isDone(r) ? [{ key: 'type:ask:done', label: 'done', kind: 'done', page: null }] : []
|
|
174
|
+
if (r.type === 'wiki' || r.type === 'asset' || r.type === 'test' || r.type === 'process' || r.type === 'reflection') return []
|
|
175
|
+
return [{ key: `type:${r.type}`, label: r.type, kind: r.type, page: { t: 'col', id: r.collection } }]
|
|
155
176
|
}
|
|
156
177
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
label,
|
|
160
|
-
res: null,
|
|
161
|
-
kind,
|
|
162
|
-
...rollup(children),
|
|
163
|
-
page: typePage(key, label),
|
|
164
|
-
children: [...children].sort(byLabel),
|
|
165
|
-
})
|
|
178
|
+
type Group = { label: string; kind: string; page: HashSel | null; rs: Resource[]; subs: Map<string, Group> }
|
|
179
|
+
const emptyGroup = (label: string, kind: string, page: HashSel | null): Group => ({ label, kind, page, rs: [], subs: new Map() })
|
|
166
180
|
|
|
167
|
-
|
|
168
|
-
|
|
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 }
|
|
181
|
+
const groupChildren = (g: Group): TreeNode[] =>
|
|
182
|
+
[...[...g.subs.entries()].map(([k, sub]) => groupNode(k, sub)), ...g.rs.map(leafNode)].sort(byLabel)
|
|
173
183
|
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
|
|
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' }]
|
|
184
|
+
const groupNode = (key: string, g: Group): TreeNode => {
|
|
185
|
+
const children = groupChildren(g)
|
|
186
|
+
return { key, label: g.label, res: null, kind: g.kind, section: null, ...rollup(children), page: g.page, children }
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
|
|
189
|
+
/** the section node's key, stable so its fold survives a reload */
|
|
190
|
+
export const sectionKey = (id: SectionId): string => `sec:${id}`
|
|
190
191
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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>()
|
|
192
|
+
/** the whole pane: one top row per section the repo draws, in the order
|
|
193
|
+
* given, then any other section a resource lives in, so nothing a provider
|
|
194
|
+
* emits is ever left without a row */
|
|
195
|
+
export function buildTree(sections: readonly SectionId[], resources: Resource[]): TreeNode[] {
|
|
196
|
+
const tops = new Map<SectionId, Group>()
|
|
201
197
|
for (const r of resources) {
|
|
202
|
-
|
|
203
|
-
|
|
198
|
+
const id = sectionOf(r)
|
|
199
|
+
const top = tops.get(id) ?? emptyGroup(id, id, null)
|
|
200
|
+
tops.set(id, top)
|
|
201
|
+
let g: Group = top
|
|
204
202
|
for (const seg of groupOf(r)) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
next = { label: seg.label, kind: seg.kind, rs: [], subs: new Map() }
|
|
208
|
-
cur.set(seg.key, next)
|
|
209
|
-
}
|
|
203
|
+
const next: Group = g.subs.get(seg.key) ?? emptyGroup(seg.label, seg.kind, seg.page)
|
|
204
|
+
g.subs.set(seg.key, next)
|
|
210
205
|
g = next
|
|
211
|
-
cur = next.subs
|
|
212
206
|
}
|
|
213
|
-
g
|
|
207
|
+
g.rs.push(r)
|
|
214
208
|
}
|
|
215
|
-
|
|
209
|
+
const order = [...sections, ...SECTION_ORDER.filter((id) => !sections.includes(id) && tops.has(id))]
|
|
210
|
+
return order.map((id) => {
|
|
211
|
+
const g = tops.get(id)
|
|
212
|
+
const children = g == null ? [] : groupChildren(g)
|
|
213
|
+
// a section is never an archive, even when all it holds is done:
|
|
214
|
+
// a search must still open it, and its count reads what is open
|
|
215
|
+
const up = rollup(children)
|
|
216
|
+
const total = children.reduce((n, c) => n + (c.archived ? 0 : c.total), 0)
|
|
217
|
+
return { key: sectionKey(id), label: sectionLabel(id), res: null, kind: null, section: id, ...up, total, archived: false, page: null, children }
|
|
218
|
+
})
|
|
216
219
|
}
|
|
217
220
|
|
|
218
|
-
/** the visible rows, in screen order
|
|
219
|
-
*
|
|
221
|
+
/** the visible rows, in screen order: a row nobody unfolded stops the walk,
|
|
222
|
+
* which is the whole point of the expansion set */
|
|
220
223
|
export function flattenTree(nodes: TreeNode[], expanded: Set<string>, depth = 0): TreeRow[] {
|
|
221
224
|
const out: TreeRow[] = []
|
|
222
225
|
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 })
|
|
226
|
+
const dir = n.res == null && n.children.length > 0
|
|
227
|
+
out.push({ key: n.key, label: n.label, depth, dir, state: n.state, res: n.res, kind: n.kind, section: n.section, total: n.total, reds: n.reds, checks: n.checks, page: n.page })
|
|
225
228
|
if (dir && expanded.has(n.key)) out.push(...flattenTree(n.children, expanded, depth + 1))
|
|
226
229
|
}
|
|
227
230
|
return out
|
|
228
231
|
}
|
|
229
232
|
|
|
230
|
-
/** the
|
|
231
|
-
* and how much does not. Counts CHECKS only, because nothing
|
|
232
|
-
* place to run */
|
|
233
|
+
/** the line at the top of the checks page: how much of this repo runs on
|
|
234
|
+
* every gate, and how much does not. Counts CHECKS only, because nothing
|
|
235
|
+
* else declares a place to run */
|
|
233
236
|
export function whenCounts(resources: Resource[]): Record<'always' | 'release' | 'health' | 'off', number> {
|
|
234
237
|
const out = { always: 0, release: 0, health: 0, off: 0 }
|
|
235
238
|
for (const r of resources) if (r.check != null) out[r.check.when]++
|
|
236
239
|
return out
|
|
237
240
|
}
|
|
238
241
|
|
|
239
|
-
/** every
|
|
242
|
+
/** every key that can unfold, which is what "expand all" writes.
|
|
240
243
|
* `archive: false` leaves the done branches folded: a search that yields
|
|
241
244
|
* every fold must not spill what was filed away as answered */
|
|
242
245
|
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)]))
|
|
246
|
+
return nodes.flatMap((n) => (n.res != null || n.children.length === 0 || (!archive && n.archived) ? [] : [n.key, ...dirKeys(n.children, archive)]))
|
|
244
247
|
}
|
|
245
248
|
|
|
246
249
|
/** what the label slot says: a check row carries its id as a small tag before
|
|
@@ -282,16 +285,6 @@ export type RowSlots = {
|
|
|
282
285
|
const DIR_STATE: Record<ResourceState, SlotState> = { red: 'fail', warn: 'warn', ok: 'pass', idle: 'idle', quiet: 'off' }
|
|
283
286
|
const DIR_TONE: Record<ResourceState, SlotTone> = { red: 'red', warn: 'warn', ok: 'ok', idle: 'idle', quiet: 'quiet' }
|
|
284
287
|
|
|
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
288
|
export function rowSlots(row: TreeRow, running: boolean): RowSlots {
|
|
296
289
|
if (row.dir)
|
|
297
290
|
return {
|
|
@@ -319,7 +312,7 @@ export function rowSlots(row: TreeRow, running: boolean): RowSlots {
|
|
|
319
312
|
}
|
|
320
313
|
const verdict: [SlotState, SlotTone] = running
|
|
321
314
|
? ['running', 'run']
|
|
322
|
-
: u.when === 'off'
|
|
315
|
+
: u.when === 'off' || u.runs.off != null
|
|
323
316
|
? ['off', 'quiet']
|
|
324
317
|
: u.status === 'fail'
|
|
325
318
|
? ['fail', u.advisory ? 'adv' : 'red']
|
|
@@ -336,7 +329,9 @@ export function rowSlots(row: TreeRow, running: boolean): RowSlots {
|
|
|
336
329
|
tone: verdict[1],
|
|
337
330
|
dur: fmtMs(u.lastMs),
|
|
338
331
|
durTone: durationTone(u.lastMs, u.timeoutMs, u.budgetMs),
|
|
339
|
-
|
|
332
|
+
// where it runs has seven contexts, too many for a slot: the tooltip
|
|
333
|
+
// lists them, the check page draws them
|
|
334
|
+
note: '',
|
|
340
335
|
count: null,
|
|
341
336
|
hint: [
|
|
342
337
|
`${u.checkId} (${u.build ? 'build step' : u.from === 'shipkit' ? 'from shipkit' : 'from this repo'}) · ${words}`,
|
|
@@ -345,7 +340,7 @@ export function rowSlots(row: TreeRow, running: boolean): RowSlots {
|
|
|
345
340
|
u.description != null && u.description !== '' ? u.description : null,
|
|
346
341
|
u.lastMs != null ? `last run ${fmtMs(u.lastMs)}` : null,
|
|
347
342
|
u.samples > 0 ? `average ${fmtMs(u.avgMs)} over ${plural(u.samples, 'run')}` : null,
|
|
348
|
-
|
|
343
|
+
runsWords(u.runs),
|
|
349
344
|
].filter((l) => l != null).join('\n'),
|
|
350
345
|
}
|
|
351
346
|
}
|
|
@@ -378,12 +373,11 @@ function testSlots(r: Resource, t: TestFacts): RowSlots {
|
|
|
378
373
|
// and on the state glyph at the right edge. Hues are tokens (k1..k6 on
|
|
379
374
|
// :root), reused across kinds on purpose, because thirteen hues nobody can
|
|
380
375
|
// tell apart is not thirteen answers. The glyph is what identifies, the hue
|
|
381
|
-
// only groups.
|
|
376
|
+
// only groups, and the word is the glyph's tooltip.
|
|
382
377
|
|
|
383
378
|
export type TypeMark = { glyph: string; hue: string; word: string }
|
|
384
379
|
|
|
385
380
|
export const TYPE_MARK: Record<string, TypeMark> = {
|
|
386
|
-
folder: { glyph: '\u25a3', hue: 'k5', word: 'a folder of this repo' },
|
|
387
381
|
ask: { glyph: '\u25c9', hue: 'k2', word: 'an ask: something only a human can do' },
|
|
388
382
|
secret: { glyph: '\u25c8', hue: 'k4', word: 'a secret this repo needs, present or missing' },
|
|
389
383
|
asset: { glyph: '\u25c6', hue: 'k1', word: 'an asset of the kit, with its rolls and its pick' },
|
|
@@ -394,6 +388,7 @@ export const TYPE_MARK: Record<string, TypeMark> = {
|
|
|
394
388
|
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' },
|
|
395
389
|
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' },
|
|
396
390
|
reflection: { glyph: '\u203b', hue: 'k2', word: 'a reflection: friction this repo logged against the wiki, waiting to be folded in' },
|
|
391
|
+
process: { glyph: '\u223f', hue: 'k1', word: 'a global process this repo declares: its state, its menu, its log' },
|
|
397
392
|
done: { glyph: '\u2713', hue: 'k5', word: 'a done ask: answered, filed under done, out of every count above it' },
|
|
398
393
|
}
|
|
399
394
|
|
|
@@ -401,9 +396,8 @@ export const TYPE_MARK: Record<string, TypeMark> = {
|
|
|
401
396
|
* hole, and a new resource kind must never make the tree look broken */
|
|
402
397
|
const UNKNOWN: TypeMark = { glyph: '\u25cb', hue: 'k5', word: 'a kind this page does not know yet' }
|
|
403
398
|
|
|
404
|
-
/** a
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
: res == null ? UNKNOWN : isDone(res) ? TYPE_MARK.done! : TYPE_MARK[res.type] ?? UNKNOWN
|
|
399
|
+
/** a group wears the mark of the kind it gathers, a leaf its own kind */
|
|
400
|
+
export const markOf = (res: Resource | null | undefined, kind: string | null = null): TypeMark =>
|
|
401
|
+
res == null
|
|
402
|
+
? kind == null ? UNKNOWN : TYPE_MARK[kind] ?? UNKNOWN
|
|
403
|
+
: isDone(res) ? TYPE_MARK.done! : TYPE_MARK[res.type] ?? UNKNOWN
|