rvlib-shipkit 0.38.0 → 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.
Files changed (252) hide show
  1. package/CHANGELOG.md +371 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +216 -18
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,164 @@
1
+ // The words a check page says about a declaration, and the link it puts on a
2
+ // file. Pure, so the sentences are pinned by tests rather than read off a
3
+ // screenshot.
4
+ import { CACHED, RAN } from '../../core/outcome-words.ts'
5
+ import { parseDuration } from '../../core/wire.ts'
6
+ import type { WhenAt } from './hub-types.ts'
7
+ import { spanWords } from './next-run.ts'
8
+
9
+ /** the cache population a check declared, and where it runs, as the page
10
+ * receives them */
11
+ export type CachePop = { inputs?: string[]; ttl?: string; never?: string; reads?: boolean; when?: WhenAt }
12
+
13
+ /** `a`, `a and b`, `a, b and c` */
14
+ const list = (xs: string[]): string => (xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)!}`)
15
+
16
+ /** how often a check that remembers nothing runs: every time the thing
17
+ * it runs in runs */
18
+ const CADENCE: Record<WhenAt, string> = {
19
+ always: 'on every run',
20
+ release: 'on every release run',
21
+ health: 'on every daily pass',
22
+ off: 'nowhere, it is off',
23
+ }
24
+
25
+ /** the value a page shows for the `remembered` row, the paths behind it when
26
+ * it is keyed on files, and the sentence for the row's help. The value is a
27
+ * RHYTHM a reader scans, and the declaration it comes from is in the help */
28
+ export type RememberedFact = { v: string; inputs: string[]; help: string }
29
+
30
+ export function rememberedFact(u: CachePop): RememberedFact {
31
+ const inputs = u.inputs ?? []
32
+ if (inputs.includes('.'))
33
+ return { v: 'until any file changes', inputs: [], help: `keyed on the whole repo tree: the answer is ${CACHED} until a file in the repo changes, then it runs again` }
34
+ if (inputs.length > 0)
35
+ return { v: 'until a file it reads changes', inputs, help: `keyed on ${list(inputs)}: the answer is ${CACHED} until a change under ${inputs.length > 1 ? 'them' : 'it'}, then it runs again` }
36
+ if (u.reads === true && inputs.length === 0 && (u.ttl == null || u.ttl === '') && u.never == null)
37
+ return { v: 'until a file it read changes', inputs: [], help: `keyed on what its last run read: the answer is ${CACHED} until one of those files, existence probes or listings changes, then it runs again. \`shipkit keys --why\` names them` }
38
+ if (u.ttl != null && u.ttl !== '') {
39
+ let every = u.ttl
40
+ try { every = spanWords(parseDuration(u.ttl)) } catch { /* an unparsable ttl is shown as written */ }
41
+ return {
42
+ v: `for ${every}`,
43
+ inputs: [],
44
+ help: `the answer is kept for ${u.ttl} (\`ttl\`), and every run inside that window uses it. A failing answer is kept for an hour at most, so a fix shows up soon`,
45
+ }
46
+ }
47
+ const cadence = CADENCE[u.when ?? 'always']
48
+ if (u.never != null)
49
+ return { v: 'never', inputs: [], help: `never cached, so it runs ${cadence}${u.never === '' ? '' : `: ${u.never}`}` }
50
+ return { v: 'never', inputs: [], help: `nothing declared, so it runs ${cadence}. \`inputs\`, \`ttl\` or \`never\` in the check says otherwise` }
51
+ }
52
+
53
+ /** the four places a check runs, named by what they do. The config keeps its
54
+ * own words (`always`, `release`, `health`, `off`), and these are only what
55
+ * the select under `run when` shows */
56
+ export const WHEN_OPTION: Record<WhenAt, string> = {
57
+ always: 'always',
58
+ release: 'before a release',
59
+ health: 'health pass only',
60
+ off: 'never',
61
+ }
62
+
63
+ /** what a check IS: three FACTS, each a short value a reader scans, with the
64
+ * sentence that explains it in the row's own `(?)`. Where it runs is the
65
+ * switch's own row, and what it runs is the command line above the grid */
66
+ export type WhatFacts = { from: 'shipkit' | 'repo'; build: boolean; blocking: boolean | null }
67
+
68
+ export type WhatFact = { k: string; v: string; help: string; tone?: 'red' | 'warn' }
69
+
70
+ export function whatFacts(u: WhatFacts): WhatFact[] {
71
+ return [
72
+ {
73
+ k: 'from',
74
+ v: u.from === 'shipkit' ? 'shipkit' : 'this repo',
75
+ help: u.from === 'shipkit' ? 'the toolkit ships this check, and every repo that installs it gets the same one' : 'this repo declared it, in a file of its own that nothing outside the repo knows about',
76
+ },
77
+ {
78
+ k: 'kind',
79
+ v: u.build ? 'build step' : 'check',
80
+ help: u.build
81
+ ? 'it builds rather than reports, which is why the health pass leaves it out and the gate runs it'
82
+ : 'it reports on this repo or on the world rather than building anything, so both the gate and the health pass can ask it',
83
+ },
84
+ u.blocking === true
85
+ ? { k: 'on failure', v: 'blocks', tone: 'red', help: 'its failure stops the gate, and stops you' }
86
+ : u.blocking === false
87
+ ? { k: 'on failure', v: 'warns', tone: 'warn', help: 'it reports the WORLD rather than this repo, so a failure prints as a warning and never blocks' }
88
+ : { k: 'on failure', v: 'undeclared', tone: 'red', help: 'it never declared whether it blocks, which the runner treats as fatal rather than guessing' },
89
+ ]
90
+ }
91
+
92
+ /** a check row in two parts: the id as a small tag, then the title. A check
93
+ * whose title IS its id (a floor slot) has no tag, the id is its name */
94
+ export function checkName(checkId: string, label: string): { tag: string | null; title: string } {
95
+ if (label.startsWith(`${checkId} `)) {
96
+ const title = label.slice(checkId.length + 1).trim()
97
+ if (title !== '') return { tag: checkId, title }
98
+ }
99
+ return { tag: null, title: label === '' ? checkId : label }
100
+ }
101
+
102
+ /** a path as a reader wants it: short, with the full one for the tooltip */
103
+ export type ShortPath = { short: string; full: string }
104
+
105
+ const HOME = /^\/(?:Users|home)\/[^/]+\//
106
+
107
+ /** repo relative when it sits in the repo, `~/` when it sits in a home. What
108
+ * this returns still resolves from the repo root, so a command stays runnable */
109
+ export function repoPath(p: string, root: string): ShortPath {
110
+ const r = root.replace(/\/+$/, '')
111
+ if (r !== '' && p.startsWith(`${r}/`)) return { short: p.slice(r.length + 1), full: p }
112
+ return { short: p.replace(HOME, '~/'), full: p }
113
+ }
114
+
115
+ /** a docs page: repo relative when it is in the repo, else relative to the
116
+ * wiki it belongs to (`meta/rv-conventions/package-json.md`) */
117
+ export function docPath(p: string, root: string): ShortPath {
118
+ const outside = p.startsWith('/') || p.startsWith('~/')
119
+ const r = root.replace(/\/+$/, '')
120
+ if (!outside || (r !== '' && p.startsWith(`${r}/`))) return repoPath(p, root)
121
+ const at = p.indexOf('/wiki/')
122
+ return at < 0 ? repoPath(p, root) : { short: p.slice(at + '/wiki/'.length), full: p }
123
+ }
124
+
125
+ /** WHAT IT RUNS. A subprocess check is its argv, each token short and never
126
+ * split, plus the package.json script a floor slot resolved to. A check whose
127
+ * body is a function runs inside the gate, and its source file says what */
128
+ export type Command =
129
+ | { kind: 'argv'; tokens: ShortPath[]; copy: string; script: { name: string; body: string } | null }
130
+ | { kind: 'in-process'; file: string | null }
131
+
132
+ const quoted = (t: string): string => (/^[\w@%+=:,./~-]+$/.test(t) ? t : `'${t.replace(/'/g, `'\\''`)}'`)
133
+
134
+ export function commandOf(u: { cmd: string[] | null; file: string | null; script?: { name: string; body: string } | null }, root: string): Command {
135
+ if (u.cmd == null || u.cmd.length === 0) return { kind: 'in-process', file: u.file }
136
+ const tokens = u.cmd.map((t) => {
137
+ const s = t.startsWith('/') ? repoPath(t, root) : { short: t, full: t }
138
+ return { short: quoted(s.short), full: s.full }
139
+ })
140
+ return { kind: 'argv', tokens, copy: tokens.map((t) => t.short).join(' '), script: u.script ?? null }
141
+ }
142
+
143
+ /** how the last run got its answer: it RAN, with a verdict when it did not
144
+ * pass, or its answer came back from the cache. Null when nothing is on
145
+ * record in this checkout */
146
+ export type LastRun = { word: string; verdict: string | null; tone: 'red' | 'warn' | 'quiet' | 'idle' }
147
+
148
+ export function lastRun(u: { status: 'pass' | 'warn' | 'fail' | 'skip' | null; replayed: boolean; advisory: boolean }): LastRun | null {
149
+ if (u.status == null) return null
150
+ if (u.replayed) return { word: CACHED, verdict: null, tone: 'quiet' }
151
+ if (u.status === 'fail') return { word: RAN, verdict: u.advisory ? 'failed, does not block' : 'failed', tone: u.advisory ? 'warn' : 'red' }
152
+ if (u.status === 'warn') return { word: RAN, verdict: 'warned', tone: 'warn' }
153
+ if (u.status === 'skip') return { word: RAN, verdict: 'skipped', tone: 'quiet' }
154
+ return { word: RAN, verdict: null, tone: 'idle' }
155
+ }
156
+
157
+ /** his editor, on the file itself. The root comes from the server, because
158
+ * only it knows where this repo sits on disk */
159
+ export function editorHref(root: string, file: string | null | undefined): string | null {
160
+ if (file == null || file === '') return null
161
+ if (file.startsWith('/')) return `vscode://file${file}`
162
+ if (root === '') return null
163
+ return `vscode://file${root.startsWith('/') ? root : `/${root}`}/${file}`.replace(/([^:])\/\//g, '$1/')
164
+ }
@@ -0,0 +1,280 @@
1
+ // Gate checks, as the page sees them: what a RUNNING gate has already
2
+ // reported, laid over the rows the server last sent.
3
+ //
4
+ // The server is the truth (collections/checks.ts reads the inventory, the
5
+ // store and the run record), and this service holds one thing only: the
6
+ // results of a run happening RIGHT NOW, so a row recolours the moment its
7
+ // check finishes instead of on the next ten second poll. Everything here is
8
+ // dropped when the run ends and the next poll brings the same facts back
9
+ // from the one place that owns them.
10
+ import { makeAutoObservable, runInAction } from 'mobx'
11
+ import type { HubSt } from './hub-st.ts'
12
+ import { applyRunEvent, idleRun, startRun, stripText, type RunProgress } from './run-strip.ts'
13
+ import type { GateBlock, Resource, ResourceState, SlowSuite, CheckEvent, WhenAt } from './hub-types.ts'
14
+ import { WHEN_OPTION } from './check-words.ts'
15
+
16
+ /** the colour rule, the SAME one the server applies, because a row must not
17
+ * change meaning between a live result and the poll that confirms it */
18
+ const stateOf = (e: CheckEvent): ResourceState => {
19
+ if (e.status === 'fail') return e.advisory ? 'warn' : 'red'
20
+ if (e.status === 'warn') return 'warn'
21
+ if (e.status === 'skip') return 'quiet'
22
+ return e.replayed ? 'quiet' : 'ok'
23
+ }
24
+
25
+ /** what a page keeps from a streaming run: enough to read what happened,
26
+ * bounded so a runaway check cannot grow it forever */
27
+ const MAX_LIVE_LINES = 400
28
+
29
+ export class ChecksSvc {
30
+ /** check id → what it just reported, for the run in flight */
31
+ live = new Map<string, CheckEvent>()
32
+ /** checks still to report, so a row can say it is waiting rather than
33
+ * showing the verdict of a run that is being replaced */
34
+ running = new Set<string>()
35
+ /** the run that is going, and what it was narrowed to */
36
+ runningAll = false
37
+ note = ''
38
+ /** the header's run state: how far this page's run has got, and how it ended */
39
+ progress: RunProgress = idleRun()
40
+ /** the wire lines of the run in flight, so a check page streams while it
41
+ * runs rather than showing nothing until the poll after it ends. They
42
+ * stay after the run: they ARE the last output until the next one */
43
+ liveLines: string[] = []
44
+ private clearTimer: ReturnType<typeof setTimeout> | null = null
45
+ /** the managed block in repo.config.ts as the server last sent it: the
46
+ * hash the next write hands back, and whether the file is waiting for
47
+ * a commit. null until the first read */
48
+ gate: GateBlock | null = null
49
+ gateNote = ''
50
+ gateBusy = false
51
+ /** the reason box, per check id: a check is only turned off WITH a reason */
52
+ reasons = new Map<string, string>()
53
+ /** checks whose select says never while the reason is still being typed:
54
+ * off is written only once it has one */
55
+ pendingOff = new Set<string>()
56
+ /** the slow suite form under the test check */
57
+ suite: SlowSuite = { file: '', catches: '', whyNotFast: '' }
58
+
59
+ constructor(private st: HubSt) {
60
+ makeAutoObservable<this, 'st' | 'clearTimer'>(this, { st: false, clearTimer: false })
61
+ }
62
+
63
+ get busy(): boolean {
64
+ return this.progress.phase === 'running'
65
+ }
66
+
67
+ /** the one line the header shows, empty when this page runs nothing */
68
+ get strip(): string {
69
+ return stripText(this.progress)
70
+ }
71
+
72
+ /** this check is expected to report in the run that is going */
73
+ isRunning(checkId: string): boolean {
74
+ return this.running.has(checkId)
75
+ }
76
+
77
+ reasonFor(id: string): string {
78
+ return this.reasons.get(id) ?? ''
79
+ }
80
+ setReason(id: string, v: string): void {
81
+ this.reasons.set(id, v)
82
+ }
83
+ setSuite(f: keyof SlowSuite, v: string): void {
84
+ this.suite = { ...this.suite, [f]: v }
85
+ }
86
+
87
+ /** the block, read before anything is written: every write hands its hash
88
+ * back, which is what makes a concurrent edit a refusal rather than a
89
+ * silent overwrite */
90
+ async loadGate(): Promise<void> {
91
+ try {
92
+ const r = await fetch('/api/checks/when')
93
+ if (!r.ok) throw new Error(await r.text())
94
+ const b = (await r.json()) as GateBlock
95
+ runInAction(() => { this.gate = b })
96
+ } catch (e) {
97
+ runInAction(() => { this.gateNote = `the repo config could not be read: ${(e as Error).message}` })
98
+ }
99
+ }
100
+
101
+ /** ONE write path for the switch, the slow suite form and the prose word
102
+ * lists alike: it edits repo.config.ts in place and leaves it dirty to be
103
+ * reviewed. Resolves true when the server took the edit */
104
+ async writeGate(edit: Record<string, unknown>, said: string): Promise<boolean> {
105
+ if (this.gateBusy) return false
106
+ let took = false
107
+ runInAction(() => { this.gateBusy = true; this.gateNote = 'writing repo.config.ts…' })
108
+ try {
109
+ // the hash of the file the page last read: without it a write
110
+ // could land on top of an edit this page never saw
111
+ if (this.gate == null) await this.loadGate()
112
+ const r = await fetch('/api/checks/when', { method: 'POST', body: JSON.stringify({ ...edit, expect: this.gate?.hash }) })
113
+ if (!r.ok) throw new Error(await r.text())
114
+ const b = (await r.json()) as GateBlock
115
+ runInAction(() => { this.gate = b; this.gateNote = said })
116
+ took = true
117
+ // the rows carry `when`, so they are right only after the server
118
+ // has read the config it just wrote
119
+ await this.st.cols.poll()
120
+ } catch (e) {
121
+ runInAction(() => { this.gateNote = `refused: ${(e as Error).message}` })
122
+ // whatever the file really says now, so the next try is not
123
+ // refused for a hash the page has already outgrown
124
+ await this.loadGate()
125
+ }
126
+ runInAction(() => { this.gateBusy = false })
127
+ return took
128
+ }
129
+
130
+ /** what the select shows: never while a reason is being typed, else
131
+ * where the check runs today */
132
+ whenShown(u: { checkId: string; when: WhenAt }): WhenAt {
133
+ return this.pendingOff.has(u.checkId) ? 'off' : u.when
134
+ }
135
+
136
+ /** the select moved. Every place but never writes at once, and never
137
+ * waits for its reason */
138
+ chooseWhen(checkId: string, at: WhenAt): void {
139
+ if (at === 'off') {
140
+ this.pendingOff.add(checkId)
141
+ this.gateNote = 'never needs a reason: type it, then turn it off'
142
+ return
143
+ }
144
+ this.pendingOff.delete(checkId)
145
+ void this.setWhen(checkId, at)
146
+ }
147
+
148
+ async setWhen(checkId: string, at: WhenAt): Promise<void> {
149
+ const reason = this.reasonFor(checkId).trim()
150
+ if (at === 'off' && reason === '') {
151
+ runInAction(() => { this.gateNote = 'never needs a reason: a check nobody can explain is a silenced gate' })
152
+ return
153
+ }
154
+ await this.writeGate({ when: { [checkId]: at === 'off' ? `off:${reason}` : at } }, `${checkId} is set to ${WHEN_OPTION[at]} — repo.config.ts is edited and waiting for your commit`)
155
+ runInAction(() => { this.pendingOff.delete(checkId) })
156
+ }
157
+
158
+ /** back to whatever the check itself declares, by dropping the entry */
159
+ async clearWhen(checkId: string): Promise<void> {
160
+ await this.writeGate({ when: { [checkId]: null } }, `${checkId} is back to what it declares itself — repo.config.ts is edited and waiting for your commit`)
161
+ }
162
+
163
+ async addSlow(): Promise<void> {
164
+ const s = this.suite
165
+ await this.writeGate({ slow: [{ ...s }] }, `${s.file} is a slow suite now — the quick tier leaves it out, the full tier keeps it`)
166
+ runInAction(() => { this.suite = { file: '', catches: '', whyNotFast: '' } })
167
+ }
168
+ async removeSlow(file: string): Promise<void> {
169
+ await this.writeGate({ slowRemove: [file] }, `${file} is back in the quick tier`)
170
+ }
171
+
172
+ private pushLine(text: string): void {
173
+ this.liveLines.push(text)
174
+ // one runaway check must not grow the page without a bound
175
+ if (this.liveLines.length > MAX_LIVE_LINES) this.liveLines.shift()
176
+ }
177
+
178
+ apply(e: CheckEvent): void {
179
+ this.live.set(e.id, e)
180
+ // the wait set is keyed by the id `--only` takes, which is the id the
181
+ // stream sends back: a row stops pulsing the moment ITS check lands
182
+ this.running.delete(e.id)
183
+ }
184
+
185
+ /** the resource list with the live results over it */
186
+ overlay(rs: Resource[]): Resource[] {
187
+ if (this.live.size === 0) return rs
188
+ return rs.map((r) => {
189
+ const u = r.check
190
+ if (u == null) return r
191
+ const e = this.live.get(u.checkId)
192
+ if (e == null) return r
193
+ return {
194
+ ...r,
195
+ state: stateOf(e),
196
+ severity: e.status === 'fail' ? (e.advisory ? 'yellow' : 'red') : e.status === 'warn' ? 'yellow' : undefined,
197
+ check: { ...u, status: e.status, replayed: e.replayed, lastMs: e.replayed ? null : Math.round(e.ms), lastAt: Date.now(), advisory: e.advisory },
198
+ md: e.lines.length === 0 ? r.md : `\`\`\`\n${e.lines.map((l) => (l.kind === 'fix-fail' || l.kind === 'fix-warn' ? ` fix: ${l.text}` : l.kind === 'fail' ? `FAIL: ${l.text}` : l.kind === 'warn' ? `WARN ${l.text}` : l.kind === 'skip' ? `skip: ${l.text}` : `ok: ${l.text}`)).join('\n')}\n\`\`\``,
199
+ }
200
+ })
201
+ }
202
+
203
+ /** every check this run will hear from, so a row can pulse from the first
204
+ * second and the header can say "12 of 70" rather than "12" */
205
+ private expected(only: string[] | undefined, gate: boolean): string[] {
206
+ if (only != null) return only
207
+ const checks = this.st.cols.fetched.map((r) => r.check).filter((u) => u != null)
208
+ return checks.filter((u) => gate || !u.build).map((u) => u.checkId)
209
+ }
210
+
211
+ /** run the whole health pass, the whole gate, or a named set of checks.
212
+ * The stream is NDJSON, one event per line: wire lines feed the raw
213
+ * card, check events colour the rows and move the header state. The page never
214
+ * shells anything, and there is no second run mechanism. */
215
+ async run(only?: string[], o: { gate?: boolean; label?: string } = {}): Promise<void> {
216
+ if (this.busy) return
217
+ const gate = o.gate === true
218
+ const waiting = this.expected(only, gate)
219
+ const label = o.label ?? (only == null ? (gate ? 'the gate' : 'the health pass') : only.length === 1 ? only[0]! : `${only.length} checks`)
220
+ if (this.clearTimer != null) clearTimeout(this.clearTimer)
221
+ runInAction(() => {
222
+ this.live.clear()
223
+ this.liveLines = []
224
+ this.running = new Set(waiting)
225
+ this.runningAll = only == null
226
+ this.progress = startRun(label, waiting.length)
227
+ this.note = `running ${label}…`
228
+ })
229
+ try {
230
+ const r = await fetch('/api/check/run', { method: 'POST', body: JSON.stringify({ ...(only == null ? {} : { only }), ...(gate ? { gate } : {}) }) })
231
+ const rd = r.body!.getReader()
232
+ const dec = new TextDecoder()
233
+ let buf = ''
234
+ for (;;) {
235
+ const { done, value } = await rd.read()
236
+ if (done) break
237
+ buf += dec.decode(value, { stream: true })
238
+ const lines = buf.split('\n')
239
+ // the tail is whatever arrived half written; it completes on
240
+ // the next chunk
241
+ buf = lines.pop() ?? ''
242
+ for (const l of lines) {
243
+ if (l === '') continue
244
+ let e: { t?: string; text?: string; code?: number } & Partial<CheckEvent>
245
+ try { e = JSON.parse(l) as typeof e } catch { continue }
246
+ runInAction(() => { this.progress = applyRunEvent(this.progress, e as Record<string, unknown>) })
247
+ if (e.t === 'line' && e.text != null) {
248
+ runInAction(() => this.pushLine(e.text!))
249
+ this.st.health.appendLine(e.text)
250
+ } else if (e.t === 'check') {
251
+ runInAction(() => this.apply(e as unknown as CheckEvent))
252
+ } else if (e.t === 'done') {
253
+ runInAction(() => { this.note = e.code === 0 ? 'done, green' : `done, exit ${e.code ?? '?'}` })
254
+ }
255
+ }
256
+ }
257
+ } catch (e) {
258
+ runInAction(() => { this.note = `stream broke: ${(e as Error).message}` })
259
+ this.st.health.appendLine(`FAIL: stream broke: ${(e as Error).message}`)
260
+ }
261
+ runInAction(() => {
262
+ this.running.clear()
263
+ this.runningAll = false
264
+ // a stream that died mid way still ends the run: the header must
265
+ // never sit there claiming something is running
266
+ if (this.progress.phase === 'running') this.progress = { ...this.progress, phase: 'done' }
267
+ })
268
+ // the header state clears itself, because the rows ARE the answer once the
269
+ // run is over and a banner that stays is a banner nobody reads
270
+ this.clearTimer = setTimeout(() => runInAction(() => { this.progress = idleRun() }), 5_000)
271
+ // the numbers a run just produced live on the server, in the log it
272
+ // wrote: one poll and the page is reading them rather than a memory
273
+ await this.st.cols.poll()
274
+ void this.st.health.load()
275
+ void this.st.timeline.load()
276
+ // a test page on screen reads the record the run just wrote
277
+ const sel = this.st.sel
278
+ if (sel?.t === 'tests') void this.st.tests.load(sel.check)
279
+ }
280
+ }
@@ -0,0 +1,33 @@
1
+ // THE clock: one observable second, for every age on the page.
2
+ //
3
+ // An age rendered once is frozen text, and a component that owns its own
4
+ // timer is one timer per age. This is one interval on the root store, and
5
+ // only the small components that READ `now` repaint when it moves. Nothing
6
+ // else on the page observes it.
7
+ import { makeAutoObservable, runInAction } from 'mobx'
8
+
9
+ export class ClockSvc {
10
+ now = Date.now()
11
+ /** the live interval, or null. A second start is a no-op rather than a
12
+ * second timer, because boot runs under StrictMode twice in dev */
13
+ running: ReturnType<typeof setInterval> | null = null
14
+
15
+ constructor() {
16
+ makeAutoObservable(this)
17
+ }
18
+
19
+ tick(at = Date.now()): void {
20
+ this.now = at
21
+ }
22
+
23
+ start(everyMs = 1_000): void {
24
+ if (this.running != null) return
25
+ this.running = setInterval(() => runInAction(() => this.tick()), everyMs)
26
+ }
27
+
28
+ stop(): void {
29
+ if (this.running == null) return
30
+ clearInterval(this.running)
31
+ this.running = null
32
+ }
33
+ }
@@ -1,25 +1,25 @@
1
1
  // Collection views: the per-collection card grid (ColGrid), the item card
2
- // (asks · creds · posts), the graph item view (GraphView — embeds the
3
- // /graph/* page itself), and the health/status card with the streaming
2
+ // (asks · creds · posts), and the health/status card with the streaming
4
3
  // check runner. Every verb posts to /api/act/* and lands in a git-tracked
5
4
  // truth file; the truth chip names the file the item IS (on disagreement
6
5
  // the file wins).
7
6
  import { runInAction } from 'mobx'
8
7
  import { observer } from 'mobx-react-lite'
9
8
  import type { HubSt } from './hub-st.ts'
10
- import type { ColInstance } from './hub-types.ts'
9
+ import type { Resource } from './hub-types.ts'
11
10
  import { renderMd } from './md.ts'
12
11
 
13
- // creds carry no truth file — name/service/state parse out of the instance
14
- const credOf = (i: ColInstance): { name: string; service: string | null; missing: boolean } => ({
15
- name: i.id.replace(/^cred:/, ''),
16
- service: i.text.includes(' — ') ? i.text.split(' — ').slice(1).join(' — ') : null,
17
- missing: i.severity != null,
18
- })
12
+ // creds carry no truth file: the name is the id, the words after it are the
13
+ // service and what its probe last said, and the slot says present or missing
14
+ const credOf = (i: Resource): { name: string; service: string | null; missing: boolean; state: Resource['state'] } => {
15
+ const name = i.id.replace(/^cred:/, '')
16
+ const rest = i.text.startsWith(name) ? i.text.slice(name.length).trim() : i.text
17
+ return { name, service: rest !== '' ? rest : null, missing: i.slot === 'missing', state: i.state }
18
+ }
19
19
 
20
20
  const CredHead = ({ cred }: { cred: ReturnType<typeof credOf> }) => (
21
21
  <>
22
- <span className="credname">{cred.name}</span>
22
+ <span className={`credname tl ${cred.state}`}>{cred.name}</span>
23
23
  <span className={`stchip ${cred.missing ? 'miss' : 'ok'}`}>{cred.missing ? 'missing' : 'present'}</span>
24
24
  </>
25
25
  )
@@ -37,9 +37,8 @@ export const ColCard = observer(({ app, col, id }: { app: HubSt; col: string; id
37
37
  const cred = credOf(i)
38
38
  return (
39
39
  <div className="colcard">
40
- <div className="back" onClick={() => app.showCol(col)}>← {c.colTitle(col)}</div>
41
40
  <h3>
42
- <span className={`cdot ${i.severity ?? ''}`} /> <CredHead cred={cred} />
41
+ <CredHead cred={cred} />
43
42
  {i.artifact?.url != null ? <a className="mint" href={i.artifact.url} target="_blank" rel="noreferrer">mint ↗</a> : null}
44
43
  </h3>
45
44
  {cred.service != null ? <div className="creddesc">{cred.service}</div> : null}
@@ -58,9 +57,8 @@ export const ColCard = observer(({ app, col, id }: { app: HubSt; col: string; id
58
57
  }
59
58
  return (
60
59
  <div className="colcard">
61
- <div className="back" onClick={() => app.showCol(col)}>← {c.colTitle(col)}</div>
62
60
  <h3>
63
- <span className={`cdot ${i.severity ?? ''}`} /> {i.text}
61
+ <span className={`tl ${i.state}`}>{i.text}</span>
64
62
  {i.file != null ? <span className="truth">{i.file}</span> : null}
65
63
  </h3>
66
64
  {i.md != null ? <div className="md" dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
@@ -120,7 +118,7 @@ export const ColGrid = observer(({ app, id }: { app: HubSt; id: string }) => {
120
118
  const cred = credOf(i)
121
119
  return (
122
120
  <div key={i.id} className="cgcard" onClick={() => c.select(col.id, i.id)}>
123
- <div className="cgtop"><span className={`cdot ${i.severity ?? ''}`} /><CredHead cred={cred} /></div>
121
+ <div className="cgtop"><CredHead cred={cred} /></div>
124
122
  {cred.service != null ? <div className="creddesc">{cred.service}</div> : null}
125
123
  {i.md != null ? <div className="md clamp" dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
126
124
  {i.artifact?.url != null ? (
@@ -131,7 +129,7 @@ export const ColGrid = observer(({ app, id }: { app: HubSt; id: string }) => {
131
129
  }
132
130
  return (
133
131
  <div key={i.id} className="cgcard" onClick={() => c.select(col.id, i.id)}>
134
- <div className="cgtop"><span className={`cdot ${i.severity ?? ''}`} /><b>{i.text}</b></div>
132
+ <div className="cgtop"><b className={`tl ${i.state}`}>{i.text}</b></div>
135
133
  {/* a link click opens its tab — it must not ALSO yank into the item view */}
136
134
  {i.md != null ? <div className="md clamp" onClick={(e) => { if ((e.target as HTMLElement).tagName === 'A') e.stopPropagation() }} dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
137
135
  {i.file != null ? <span className="truth">{i.file}</span> : null}
@@ -146,23 +144,6 @@ export const ColGrid = observer(({ app, id }: { app: HubSt; id: string }) => {
146
144
  })
147
145
 
148
146
  // graph item view — the graph ITSELF fills the stage, lint line as a slim header
149
- export const GraphView = observer(({ app, id }: { app: HubSt; id: string }) => {
150
- const c = app.cols
151
- const i = c.find('graphs', id)
152
- if (i == null) return <div className="stagewrap"><div className="ph">graph vanished — the truth moved on</div></div>
153
- if (i.href == null) return <div className="stagewrap"><div className="ph">this graph carries no page to embed</div></div>
154
- return (
155
- <div className="graphview">
156
- <div className="gvhead">
157
- <span className="back" onClick={() => app.showCol('graphs')}>← {c.colTitle('graphs')}</span>
158
- <span className={`cdot ${i.severity ?? ''}`} />
159
- <span className="gvtext">{i.text}</span>
160
- </div>
161
- <iframe src={i.href} title={i.text} />
162
- </div>
163
- )
164
- })
165
-
166
147
  const wireClass = (l: string): string => {
167
148
  const t = l.trimStart()
168
149
  if (t.startsWith('FAIL') || t.startsWith('✗')) return 'fail'
@@ -172,19 +153,22 @@ const wireClass = (l: string): string => {
172
153
  return ''
173
154
  }
174
155
 
156
+ // the RAW view of a run: every check has its own page, and this is the one
157
+ // node that keeps the whole text reachable
175
158
  export const StatusCard = observer(({ app }: { app: HubSt }) => {
176
159
  const h = app.health
160
+ const body = h.live.length > 0
161
+ ? h.live.join('\n')
162
+ : h.statusMd ?? 'no STATUS.md yet — hit "run the health pass" (or run: shipkit check)'
177
163
  return (
178
164
  <div className="wrap">
179
- <h1>{app.name}<small>STATUS.md</small></h1>
165
+ <h1>{app.name}<small>{h.live.length > 0 ? 'this run' : 'STATUS.md'}</small></h1>
180
166
  <div className="hbar">
181
- <button disabled={h.running} onClick={() => void h.runCheck()}>run check</button>
182
- <span className="note">{h.note !== '' ? h.note : <>shells <code>shipkit check</code>, wire lines stream in below</>}</span>
167
+ <button disabled={h.running} onClick={() => void h.runHealthPass()}>run the health pass</button>
168
+ <span className="note">{h.note !== '' ? h.note : <>runs every check but the build steps, in the hub's own process, and each row colours as its check finishes</>}</span>
183
169
  </div>
184
170
  <pre>
185
- {(h.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)')
186
- .split('\n')
187
- .map((l, i) => <span key={i} className={wireClass(l)}>{l}{'\n'}</span>)}
171
+ {body.split('\n').map((l, i) => <span key={i} className={wireClass(l)}>{l}{'\n'}</span>)}
188
172
  </pre>
189
173
  </div>
190
174
  )