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.
Files changed (252) hide show
  1. package/CHANGELOG.md +367 -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 +209 -15
  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,197 @@
1
+ // SK017 vscode hub task (build/vscode-tasks.md): the folder-open hub task
2
+ // carries the problem matcher the hub prints for, it opens with the folder
3
+ // itself when nothing else does, and no `👁 TSC` watcher runs beside a gate
4
+ // that already runs `typecheck`.
5
+ //
6
+ // the file is hand-written jsonc, so the fixer edits spans: it removes the TSC
7
+ // task, a wrapper that only starts the hub, and their dependsOn entries, and
8
+ // writes the hub's matcher and folder-open run option. Every other byte stays
9
+ // as its author left it.
10
+ import { basename } from 'path'
11
+ import { check } from '../check.ts'
12
+ import type { CheckCtx } from '../core/check-ctx.ts'
13
+ import { checkFloorReader, resolveFloor } from '../core/floor-resolve.ts'
14
+ import { whenOfTask } from '../core/when.ts'
15
+ import { hubProblemMatcher } from '../problems/problem-line.ts'
16
+ import { indentAt, parseSpans, plain, removalSpan, type Node } from '../scaffold/jsonc-spans.ts'
17
+
18
+ export const TSC_LABEL = '👁 TSC'
19
+ const TASKS = '.vscode/tasks.json'
20
+
21
+ type Obj = Extract<Node, { kind: 'object' }>
22
+ type Arr = Extract<Node, { kind: 'array' }>
23
+
24
+ const prop = (o: Obj, key: string): Node | undefined => o.props.find((p) => p.key === key)?.value
25
+
26
+ /** the house type checker task and nothing that only looks like it: the exact
27
+ * label, a tsc binary, in watch mode */
28
+ function isTscWatcher(t: Record<string, unknown>): boolean {
29
+ const cmd = typeof t['command'] === 'string' ? t['command'] : ''
30
+ const args = Array.isArray(t['args']) ? t['args'] : []
31
+ return t['label'] === TSC_LABEL && basename(cmd) === 'tsc' && args.includes('--watch')
32
+ }
33
+
34
+ /** the hub task: `bun run hub`, or the house label */
35
+ function isHub(t: Record<string, unknown>): boolean {
36
+ const args = Array.isArray(t['args']) ? t['args'] : []
37
+ return (t['command'] === 'bun' && args[0] === 'run' && args[1] === 'hub') || (typeof t['label'] === 'string' && /^🚀\s+shipkit\b/.test(t['label']))
38
+ }
39
+
40
+ /** a task that only starts other tasks: no command of its own */
41
+ const isWrapper = (t: Record<string, unknown>): boolean => t['command'] == null && t['type'] == null && t['dependsOn'] != null
42
+
43
+ export type RepairOpts = {
44
+ /** the gate runs an enabled `typecheck` check. Without one the TSC task is
45
+ * the only type checker this repo has, so it stays */
46
+ typecheckOn: boolean
47
+ }
48
+
49
+ /** what this file needs, applied: the text to write, one line per change, and
50
+ * one line per thing it deliberately left. No change means the text comes
51
+ * back byte for byte */
52
+ export function repairTasks(text: string, o: RepairOpts): { text: string; changes: string[]; kept: string[] } {
53
+ const root = parseSpans(text)
54
+ if (root.kind !== 'object') throw new Error('tasks.json is not an object')
55
+ const tasks = prop(root, 'tasks')
56
+ if (tasks == null || tasks.kind !== 'array') return { text, changes: [], kept: [] }
57
+ const edits: { start: number; end: number; body: string }[] = []
58
+ const changes: string[] = []
59
+ const kept: string[] = []
60
+ const record = (t: Node): Record<string, unknown> => plain(text, t) as Record<string, unknown>
61
+ const labelOf = (t: Node): string => String(record(t)['label'] ?? '?')
62
+ const items = tasks.items.filter((n): n is Obj => n.kind === 'object')
63
+ const hubs = items.filter((t) => isHub(record(t)))
64
+
65
+ const tscAt = tasks.items.flatMap((n, i) => (n.kind === 'object' && isTscWatcher(record(n)) ? [i] : []))
66
+ const dropTsc = tscAt.length > 0 && o.typecheckOn
67
+ if (tscAt.length > 0 && !o.typecheckOn) kept.push(`kept the '${TSC_LABEL}' task: this repo's gate has no enabled typecheck check, so it is the only type checker here`)
68
+ if (dropTsc) for (const i of tscAt) {
69
+ const [start, end] = removalSpan(text, tasks, i)
70
+ edits.push({ start, end, body: '' })
71
+ changes.push(`removed the '${TSC_LABEL}' task`)
72
+ }
73
+
74
+ // a folder-open wrapper whose only job is starting the hub is a second
75
+ // task on screen for nothing: the hub opens with the folder itself
76
+ const hub = hubs.length === 1 ? hubs[0]! : null
77
+ const hubLabel = hub == null ? null : labelOf(hub)
78
+ const depsOf = (t: Node): string[] => {
79
+ const d = record(t)['dependsOn']
80
+ const list = Array.isArray(d) ? d.map(String) : [String(d)]
81
+ return dropTsc ? list.filter((x) => x !== TSC_LABEL) : list
82
+ }
83
+ const wrapperAt = hub == null ? -1 : tasks.items.findIndex((n) => n.kind === 'object' && isWrapper(record(n)) && depsOf(n).length === 1 && depsOf(n)[0] === hubLabel)
84
+ const wrapper = wrapperAt < 0 ? null : (tasks.items[wrapperAt] as Obj)
85
+ if (wrapper != null) {
86
+ const [start, end] = removalSpan(text, tasks, wrapperAt)
87
+ edits.push({ start, end, body: '' })
88
+ changes.push(`folded the '${labelOf(wrapper)}' wrapper into '${hubLabel}', which now opens with the folder`)
89
+ if (/\/\/.*(master task|subtask)/i.test(text)) changes.push('a comment still describes a master task with subtasks: rewrite it by hand')
90
+ }
91
+
92
+ if (dropTsc) {
93
+ for (const t of items) {
94
+ if (t === wrapper) continue
95
+ const d = prop(t, 'dependsOn')
96
+ if (d?.kind === 'array') {
97
+ const idx = d.items.findIndex((x) => x.kind === 'scalar' && x.value === TSC_LABEL)
98
+ if (idx < 0) continue
99
+ const [start, end] = removalSpan(text, d as Arr, idx)
100
+ edits.push({ start, end, body: '' })
101
+ changes.push(`dropped '${TSC_LABEL}' from the dependsOn of '${labelOf(t)}'`)
102
+ } else if (d?.kind === 'scalar' && d.value === TSC_LABEL) {
103
+ edits.push({ start: d.start, end: d.end, body: '[]' })
104
+ changes.push(`dropped '${TSC_LABEL}' from a dependsOn`)
105
+ }
106
+ }
107
+ }
108
+
109
+ const want = hubProblemMatcher()
110
+ for (const h of hubs) {
111
+ const first = h.props[0]
112
+ const hubIndent = indentAt(text, h.start)
113
+ const propIndent = first != null ? indentAt(text, first.keyStart) : `${hubIndent} `
114
+ const unit = propIndent.length > hubIndent.length ? propIndent.slice(hubIndent.length) : ' '
115
+ const render = (v: unknown): string => JSON.stringify(v, null, unit).split('\n').join(`\n${propIndent}`)
116
+ const added: string[] = []
117
+ const current = prop(h, 'problemMatcher')
118
+ if (current == null || JSON.stringify(plain(text, current)) !== JSON.stringify(want)) {
119
+ if (current != null) edits.push({ start: current.start, end: current.end, body: render(want) })
120
+ else added.push(`"problemMatcher": ${render(want)}`)
121
+ changes.push(`wrote the shipkit problem matcher on '${labelOf(h)}'`)
122
+ }
123
+ if (h === hub && wrapper != null) {
124
+ const ro = prop(h, 'runOptions')
125
+ const was = ro != null && ro.kind === 'object' ? record(ro) : {}
126
+ const inline = `{ ${Object.entries(Object.assign({ runOn: 'folderOpen', instanceLimit: 1 }, was, { runOn: 'folderOpen' })).map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(', ')} }`
127
+ if (ro != null) edits.push({ start: ro.start, end: ro.end, body: inline })
128
+ else added.push(`"runOptions": ${inline}`)
129
+ const wg = prop(wrapper, 'group')
130
+ if (wg != null) {
131
+ const body = text.slice(wg.start, wg.end)
132
+ const g = prop(h, 'group')
133
+ if (g != null) edits.push({ start: g.start, end: g.end, body })
134
+ else added.push(`"group": ${body}`)
135
+ }
136
+ }
137
+ if (added.length === 0) continue
138
+ const last = h.props.at(-1)
139
+ const joined = added.join(`,\n${propIndent}`)
140
+ if (last == null) edits.push({ start: h.start + 1, end: h.start + 1, body: `\n${propIndent}${joined}\n${hubIndent}` })
141
+ else edits.push({ start: last.value.end, end: last.value.end, body: `,\n${propIndent}${joined}` })
142
+ }
143
+
144
+ let out = text
145
+ for (const e of edits.sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.body + out.slice(e.end)
146
+ return { text: out, changes, kept }
147
+ }
148
+
149
+ /** whether this repo's gate runs a typecheck: a floor slot or a task that
150
+ * resolves, and no `when` turning it off */
151
+ export function typecheckOn(c: CheckCtx): boolean {
152
+ const config = c.config
153
+ let floor: ReturnType<typeof resolveFloor>
154
+ try { floor = resolveFloor(config, c.root, checkFloorReader(c)) } catch { return false }
155
+ const t = [...floor.tasks, ...(config.tasks ?? [])].find((x) => x.id === 'typecheck')
156
+ return t != null && whenOfTask(config, t).at !== 'off'
157
+ }
158
+
159
+ /** the tasks file this check may touch: none, or left alone because .vscode/
160
+ * is a link to a dir shared with other repos */
161
+ function ownTasks(c: CheckCtx): { text: string } | 'none' | 'linked' {
162
+ if (!c.exists(TASKS)) return 'none'
163
+ if (c.isLink('.vscode')) return 'linked'
164
+ const text = c.read(TASKS)
165
+ return text == null ? 'none' : { text }
166
+ }
167
+
168
+ export const vscodeHubTask = check({
169
+ id: 'SK017',
170
+ // a stale editor file is behind, not broken: it warns, and the fix pass
171
+ // repairs it
172
+ blocking: false,
173
+ title: 'vscode hub task',
174
+ description: 'the vscode hub task carries the problem matcher and no extra tsc watcher runs',
175
+ owner: 'build/vscode-tasks.md',
176
+ run: (c) => {
177
+ const own = ownTasks(c)
178
+ if (own === 'none') return c.ok(`no ${TASKS}, so there is no hub task to wire`)
179
+ if (own === 'linked') return c.ok(`.vscode/ is a link to a shared dir, left alone`)
180
+ let r: { changes: string[]; kept: string[] }
181
+ try { r = repairTasks(own.text, { typecheckOn: typecheckOn(c) }) }
182
+ catch (e) { return c.warn(`${TASKS} does not parse (${(e as Error).message}), so the hub task was not checked`) }
183
+ for (const k of r.kept) c.note(`${TASKS}: ${k}`)
184
+ if (r.changes.length === 0) return c.ok(`${TASKS}: the hub task feeds the problems pane`)
185
+ for (const ch of r.changes) c.fail(`${TASKS}: ${ch.replace(/^removed/, 'should remove').replace(/^dropped/, 'should drop').replace(/^wrote/, 'should write').replace(/^folded/, 'should fold')}`)
186
+ },
187
+ fix: (c) => {
188
+ const own = ownTasks(c)
189
+ if (typeof own === 'string') return { changed: false, summary: `no ${TASKS} to repair` }
190
+ const r = repairTasks(own.text, { typecheckOn: typecheckOn(c) })
191
+ if (r.changes.length === 0) return { changed: false, summary: `${TASKS} already current` }
192
+ c.write(TASKS, r.text)
193
+ return { changed: true, summary: `${TASKS}: ${r.changes.join(', ')}` }
194
+ },
195
+ })
196
+
197
+ export default vscodeHubTask
@@ -0,0 +1,28 @@
1
+ // SK018 — every test file runs in a check (build/shipkit.md, the test checks).
2
+ // A `*.test.ts` that no check runs is a green nobody measured: the file says
3
+ // something is proved, and nothing proves it. Each file belongs to the release
4
+ // unit whose dir holds it (the root takes the rest), and the unit's own
5
+ // `bun test` must reach it. A file kept out on purpose goes under a bunfig
6
+ // `pathIgnorePatterns` entry, which is where the reason is written.
7
+ import { check } from '../check.ts'
8
+ import { isTestFile, TEST_GLOB, testPlanOf } from '../suite/test-units.ts'
9
+
10
+ export const testFilesRun = check({
11
+ id: 'SK018',
12
+ blocking: true,
13
+ title: 'test files run',
14
+ description: 'every test file in the repo runs in a test check or is ignored on purpose',
15
+ owner: 'build/shipkit.md',
16
+ run: (c) => {
17
+ const files = c.list(TEST_GLOB).filter(isTestFile)
18
+ if (files.length === 0) return c.ok('no test file in this repo')
19
+ const plan = testPlanOf({ config: c.config, files, read: (rel) => c.read(rel) })
20
+ for (const o of plan.orphans) c.fail(`${o.file} runs in no check: ${o.why}`, 'move it under a test root, fix the `bun test` arguments, or add it to bunfig.toml [test] pathIgnorePatterns with the reason in a comment')
21
+ for (const id of plan.opaque) c.note(`${id}: its script is not a plain \`bun test\`, so which files it runs cannot be read`)
22
+ const per = [...plan.runs].filter(([id, f]) => f.length > 0 || id === 'test').map(([id, f]) => `${id} ${f.length}`)
23
+ const ignored = plan.ignored.length > 0 ? `, ${plan.ignored.length} ignored on purpose` : ''
24
+ if (plan.orphans.length === 0) c.ok(`${files.length} test file(s) all run: ${per.join(', ')}${ignored}`)
25
+ },
26
+ })
27
+
28
+ export default testFilesRun
@@ -0,0 +1,76 @@
1
+ // SK019 — tests run through the generated test checks only (build/shipkit.md,
2
+ // the test checks). The floor `test` slot and one `test:<unit>` per declared
3
+ // unit are the ONE way tests run: in the unit's dir with its bunfig and
4
+ // preload, parallel, timed against the budget, and planned file by file by
5
+ // SK018. A hand written check that runs `bun test` itself is a second runner
6
+ // none of that reaches. The allowed shapes are never scanned: the generated
7
+ // checks are not files, and `defaults.test` IS the floor slot.
8
+ //
9
+ // Its own id rather than a line of SK018: SK018 answers "does every test file
10
+ // run", this one "does anything else run them", and each is read from
11
+ // different files (test files and bunfigs there, check sources and the config
12
+ // here), so each keeps its own key, row and `when` switch.
13
+ import { dirname, posix, relative } from 'path'
14
+ import { check } from '../check.ts'
15
+ import { repoCheckSources } from '../core/legacy-shapes.ts'
16
+ import { argvRunsTests, commandRunsTests, sourceRunsTests, type ScriptReader } from '../suite/runner-shapes.ts'
17
+ import type { CheckCtx } from '../core/check-ctx.ts'
18
+
19
+ /** the scripts of the package.json in `dir` (repo relative), then the root's */
20
+ const scriptsIn = (c: CheckCtx, dir: string): ScriptReader => {
21
+ const read = (d: string): Record<string, string> => {
22
+ try { return (JSON.parse(c.read(d === '' || d === '.' ? 'package.json' : posix.join(d, 'package.json')) ?? '{}') as { scripts?: Record<string, string> }).scripts ?? {} } catch { return {} }
23
+ }
24
+ const own = read(dir)
25
+ const root = read('')
26
+ return (name) => own[name] ?? root[name] ?? null
27
+ }
28
+
29
+ /** the floor slots besides `test`, whose package.json body must not run tests either */
30
+ const OTHER_SLOTS = ['lint', 'typecheck', 'imports:check', 'cycles']
31
+
32
+ const FIX = 'declare a unit instead (units in repo.config.ts): each unit gets its own generated `test:<unit>` check, run in its dir'
33
+
34
+ export const testsRunInUnits = check({
35
+ id: 'SK019',
36
+ blocking: true,
37
+ title: 'tests run in unit checks',
38
+ description: 'no check runs the test runner itself: tests run only in the generated test checks',
39
+ owner: 'build/shipkit.md',
40
+ run: (c) => {
41
+ // the toolkit's own checks (this file's dir) live inside the repo that
42
+ // hosts it, and a built-in that WRITES a test script (SK001) is not a
43
+ // runner. Read off import.meta, since importing discovery pulls in fs
44
+ const builtins = relative(c.root, import.meta.dir).replace(/\\/g, '/')
45
+ const files = repoCheckSources(c).filter((f) => !f.rel.startsWith(`${builtins}/`))
46
+ let hits = 0
47
+ const say = (where: string, how: string): void => { hits++; c.fail(`${where} runs ${how} itself`, FIX) }
48
+ const root = scriptsIn(c, '')
49
+ const cwdOf = (cwd: string | undefined): string => (cwd == null ? '' : relative(c.root, posix.resolve(c.root, cwd)))
50
+ for (const f of files) for (const how of sourceRunsTests(f.src ?? '', scriptsIn(c, dirname(f.rel)))) say(f.rel, how)
51
+ for (const t of c.config.tasks ?? []) {
52
+ const scripts = scriptsIn(c, cwdOf(t.cwd))
53
+ const how = t.cmd != null ? argvRunsTests(t.cmd, scripts) : null
54
+ if (how != null) say(`repo.config.ts tasks '${t.id}'`, how)
55
+ else if (t.logic != null) for (const h of sourceRunsTests(String(t.logic), scripts)) say(`repo.config.ts tasks '${t.id}'`, h)
56
+ }
57
+ for (const r of c.config.rules ?? []) for (const h of sourceRunsTests(String(r.lint), root)) say(`repo.config.ts rules '${r.id}'`, h)
58
+ const defaults = c.config.defaults ?? {}
59
+ for (const [id, d] of Object.entries(defaults)) {
60
+ if (id === 'test' || d === false || d.cmd == null) continue
61
+ const how = argvRunsTests(d.cmd, scriptsIn(c, cwdOf(d.cwd)))
62
+ if (how != null) say(`repo.config.ts defaults '${id}'`, how)
63
+ }
64
+ // a slot the package.json fills: its raw body, else its own script
65
+ for (const id of OTHER_SLOTS) {
66
+ if (defaults[id] === false || (defaults[id] as { cmd?: unknown } | undefined)?.cmd != null) continue
67
+ const name = root(`${id}:raw`) != null ? `${id}:raw` : id
68
+ const body = root(name)
69
+ const how = body == null ? null : commandRunsTests(body, (n) => (n === name ? null : root(n)))
70
+ if (how != null) say(`package.json script '${name}' (the ${id} slot)`, how)
71
+ }
72
+ if (hits === 0) c.ok(`${files.length} check file(s) read, none runs the test runner: tests run only in the generated test checks`)
73
+ },
74
+ })
75
+
76
+ export default testsRunInUnits
@@ -0,0 +1,147 @@
1
+ // SK020: a check file never spawns itself (build/shipkit/writing-a-check.md
2
+ // owns the one body shape, `run: (c) => …`). A `cmd` that names the file's
3
+ // own path runs the file a second time, in its own process, behind an
4
+ // `import.meta.main` guard: a second way to write a body, with none of what
5
+ // `c` gives (recorded reads, one pass, one runner).
6
+ //
7
+ // It reads each check file's SOURCE through `c`, never imports it, so it is
8
+ // cached on the listing and the sources. A legacy check SCRIPT (no check
9
+ // object) is SK016's, read through its window, and never counted here.
10
+ import { check } from '../check.ts'
11
+ import { isLegacyCheckSource } from '../core/check-file-names.ts'
12
+ import { repoCheckSources } from '../core/legacy-shapes.ts'
13
+
14
+ const OWNER = 'build/shipkit/writing-a-check.md'
15
+
16
+ /** the source with every comment blanked, so a file that MENTIONS the
17
+ * shape in a comment is not a file that has it, and a `/*` inside a glob
18
+ * string opens no comment. `strings: false` blanks the strings too, for a
19
+ * question about code rather than about an argv */
20
+ export function codeOf(src: string, o: { strings: boolean } = { strings: true }): string {
21
+ let out = ''
22
+ let quote: string | null = null
23
+ for (let i = 0; i < src.length; i++) {
24
+ const ch = src[i]!
25
+ if (quote != null) {
26
+ const keep = o.strings || ch === quote
27
+ if (ch === '\\') {
28
+ i++
29
+ if (o.strings) out += ch + (src[i] ?? '')
30
+ continue
31
+ }
32
+ if (ch === quote) quote = null
33
+ if (keep) out += ch
34
+ continue
35
+ }
36
+ if (ch === '/' && src[i + 1] === '/') {
37
+ while (i < src.length && src[i] !== '\n') i++
38
+ out += '\n'
39
+ continue
40
+ }
41
+ if (ch === '/' && src[i + 1] === '*') {
42
+ const end = src.indexOf('*/', i + 2)
43
+ i = end === -1 ? src.length : end + 1
44
+ out += ' '
45
+ continue
46
+ }
47
+ // a regex literal starts where an expression may: its quotes open no
48
+ // string, and it is code, so it is blanked like a string
49
+ if (ch === '/' && /[(,=:[!&|?{};]$|^$/.test(out.trimEnd().slice(-1))) {
50
+ let j = i + 1
51
+ let cls = false
52
+ for (; j < src.length && src[j] !== '\n'; j++) {
53
+ if (src[j] === '\\') j++
54
+ else if (src[j] === '[') cls = true
55
+ else if (src[j] === ']') cls = false
56
+ else if (src[j] === '/' && !cls) break
57
+ }
58
+ if (src[j] === '/') {
59
+ out += o.strings ? src.slice(i, j + 1) : '//'
60
+ i = j
61
+ continue
62
+ }
63
+ }
64
+ if (ch === "'" || ch === '"' || ch === '`') quote = ch
65
+ out += ch
66
+ }
67
+ return out
68
+ }
69
+
70
+ /** the elements of every `cmd: [...]` array literal, split at the commas of
71
+ * its own level */
72
+ function cmdElements(code: string): string[][] {
73
+ const out: string[][] = []
74
+ for (const m of code.matchAll(/\bcmd\s*:\s*\[/g)) {
75
+ const items: string[] = []
76
+ let depth = 0
77
+ let quote: string | null = null
78
+ let cur = ''
79
+ for (let i = m.index! + m[0].length; i < code.length; i++) {
80
+ const ch = code[i]!
81
+ if (quote != null) {
82
+ cur += ch
83
+ if (ch === '\\') cur += code[++i] ?? ''
84
+ else if (ch === quote) quote = null
85
+ continue
86
+ }
87
+ if (ch === "'" || ch === '"' || ch === '`') quote = ch
88
+ else if (ch === '(' || ch === '[' || ch === '{') depth++
89
+ else if ((ch === ')' || ch === ']' || ch === '}') && depth > 0) depth--
90
+ else if (ch === ']') break
91
+ else if (ch === ',' && depth === 0) {
92
+ items.push(cur.trim())
93
+ cur = ''
94
+ continue
95
+ }
96
+ cur += ch
97
+ }
98
+ if (cur.trim() !== '') items.push(cur.trim())
99
+ out.push(items)
100
+ }
101
+ return out
102
+ }
103
+
104
+ /** an argv element that IS the module's own file */
105
+ const SELF = [
106
+ /^import\.meta\.(path|filename)$/,
107
+ /^fileURLToPath\(\s*import\.meta\.url\s*\)$/,
108
+ /^new\s+URL\(\s*import\.meta\.url\s*\)\.pathname$/,
109
+ /^__filename$/,
110
+ /^Bun\.main$/,
111
+ ]
112
+
113
+ /** why this check file runs a body of its own in a second process, null when
114
+ * it does not: a `cmd` element that is the file itself, or the
115
+ * `import.meta.main` guard such a body hides behind, which survives any
116
+ * aliasing of the path */
117
+ export function selfSpawn(rel: string, src: string): string | null {
118
+ const code = codeOf(src)
119
+ for (const argv of cmdElements(code))
120
+ for (const el of argv) {
121
+ if (SELF.some((re) => re.test(el))) return `its cmd runs the file itself (${el})`
122
+ const lit = /^(['"`])(.*)\1$/.exec(el)?.[2]
123
+ if (lit != null && (lit.replace(/^\.\//, '') === rel || lit.endsWith(`/${rel}`))) return `its cmd names its own path (${lit})`
124
+ }
125
+ if (/\bimport\.meta\.main\b/.test(codeOf(src, { strings: false }))) return 'it guards a body of its own behind import.meta.main'
126
+ return null
127
+ }
128
+
129
+ export const selfSpawningCheck = check({
130
+ id: 'SK020',
131
+ title: 'self spawning check',
132
+ description: 'no check file runs its own body by spawning itself',
133
+ owner: OWNER,
134
+ blocking: true,
135
+ run: (c) => {
136
+ const found = repoCheckSources(c).flatMap((f) => {
137
+ if (f.src == null || isLegacyCheckSource(f.src)) return []
138
+ const why = selfSpawn(f.rel, f.src)
139
+ return why == null ? [] : [`${f.rel}: ${why}`]
140
+ })
141
+ if (found.length === 0) return c.ok('no check file spawns itself: every body runs through `c`')
142
+ c.fail(`${found.length} check file(s) run a body of their own in a second process: ${found.join(', ')}`)
143
+ c.lines.push({ kind: 'fix-fail', text: `move each body into \`run: (c) => …\` and drop the \`cmd\` and the \`import.meta.main\` guard: a probe reaches the world through c.fetch, c.exec and c.tool (${OWNER})` })
144
+ },
145
+ })
146
+
147
+ export default selfSpawningCheck
@@ -0,0 +1,20 @@
1
+ // the contracts check — `.shipkit/*` release-contract fulfillness. It shares ONE
2
+ // evaluation with check SK002 (the `contractsResult` scan), and it tags with
3
+ // that id, because the invariant has one owner and two surfaces report it. A
4
+ // venue deploy step deps on it BY NAME, which needs nothing from this end.
5
+ import { applyContractsResult, contractsResult } from '../contracts/apply-contracts.ts'
6
+ import { check } from '../check.ts'
7
+ import { contractsFulfillness } from './SK002-contracts-fulfillness.check.ts'
8
+
9
+ export const contractsCheck = check({
10
+ id: 'contracts',
11
+ title: 'contracts',
12
+ description: 'every declared venue has the `.shipkit/*` release contract it demands',
13
+ owner: 'meta/contracts.md',
14
+ blocking: true,
15
+ run: async (c) => {
16
+ applyContractsResult(c, await c.use(contractsResult), contractsFulfillness)
17
+ },
18
+ })
19
+
20
+ export default contractsCheck
@@ -5,8 +5,9 @@ import { join } from 'path'
5
5
  import { Command, Option } from 'clipanion'
6
6
  import { checkAsks, createAsk, renderAsksTable } from '../asks/asks-queue.ts'
7
7
  import { loadRepoConfig } from '../repo-config.ts'
8
+ import { LAYOUT } from '../core/layout.ts'
8
9
 
9
- const asksDir = async (root: string): Promise<string> => join(root, (await loadRepoConfig(root)).asks?.dir ?? 'asks')
10
+ const asksDir = async (root: string): Promise<string> => join(root, (await loadRepoConfig(root)).asks?.dir ?? LAYOUT.asks)
10
11
 
11
12
  export class AsksCommand extends Command {
12
13
  static override paths = [['asks']]
@@ -21,7 +22,7 @@ export class AsksCommand extends Command {
21
22
  try {
22
23
  const root = process.cwd()
23
24
  const c = await loadRepoConfig(root)
24
- renderAsksTable(join(root, c.asks?.dir ?? 'asks'), undefined, { emptyText: c.asks?.emptyText })
25
+ renderAsksTable(join(root, c.asks?.dir ?? LAYOUT.asks), undefined, { emptyText: c.asks?.emptyText })
25
26
  return 0
26
27
  } catch (e) {
27
28
  this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
@@ -1,4 +1,4 @@
1
- // shipkit assets — asset atelier: declare kinds in .rv-assets/kit.ts, roll
1
+ // shipkit assets — asset atelier: declare kinds in .shipkit/assets/kit.ts, roll
2
2
  // (ComfyUI or procedural canvas), curate in the studio, ship with metadata
3
3
  // write-through. Thin clipanion surfaces; logic in assets/run-assets.ts.
4
4
  import { Command, Option } from 'clipanion'
@@ -85,7 +85,7 @@ export class AssetsIdentityCommand extends Command {
85
85
 
86
86
  static override usage = Command.Usage({
87
87
  category: 'Assets',
88
- description: 'render favicon/logo/OG from .rv-identity/brand.ts into public/',
88
+ description: 'render favicon/logo/OG from .shipkit/identity/brand.ts into public/',
89
89
  examples: [['regenerate the identity outputs', 'shipkit assets identity']],
90
90
  })
91
91
 
@@ -9,7 +9,7 @@ export class BumpCommand extends Command {
9
9
 
10
10
  static override usage = Command.Usage({
11
11
  category: 'Gate',
12
- description: 'ship a version in one word: bumps a release unit (default the repo root), run from a feature branch it merges into the release branch first, then promotes the CHANGELOG Unreleased section, bumps package.json, runs the gate and every prepublish script, commits, tags, pushes, and cuts the release. Any failure before the version commit rolls the merge back and returns you to your branch.',
12
+ description: 'ship a version in one word: bumps a unit (default the repo root), run from a feature branch it merges into the release branch first, then promotes the CHANGELOG Unreleased section, bumps package.json, runs the gate and every prepublish script, commits, tags, pushes, and cuts the release. Any failure before the version commit rolls the merge back and returns you to your branch.',
13
13
  examples: [
14
14
  ['a bugfix release of the repo itself', 'shipkit bump patch'],
15
15
  ['a feature release', 'shipkit bump minor'],
@@ -1,17 +1,17 @@
1
1
  // shipkit check — periodic health, TTL-cached; `--fix` applies rule
2
2
  // autofixes then re-lints. Thin clipanion surface; logic in
3
- // core/run-check.ts and rules/run-fix.ts. ci NEVER fixes.
3
+ // core/run-check.ts and core/run-fix.ts. ci NEVER fixes.
4
4
  import { Command, Option } from 'clipanion'
5
5
  import { runCheck } from '../core/run-check.ts'
6
6
  import { maybeSelfUpdateAndRestart } from '../core/self-update.ts'
7
- import { runFix } from '../rules/run-fix.ts'
7
+ import { runFix } from '../core/run-fix.ts'
8
8
 
9
9
  export class CheckCommand extends Command {
10
10
  static override paths = [['check'], ['doctor']]
11
11
 
12
12
  static override usage = Command.Usage({
13
13
  category: 'Health',
14
- description: 'the checks alone, without the build tasks. `shipkit ci` runs these too and is what you normally want. --fix applies rule autofixes',
14
+ description: 'every check except the build steps. `shipkit ci` runs these too and is what you normally want. --fix applies check autofixes',
15
15
  examples: [
16
16
  ['run (cached where valid)', 'shipkit check'],
17
17
  ['bust every cache layer', 'shipkit check --fresh'],
@@ -24,8 +24,8 @@ export class CheckCommand extends Command {
24
24
 
25
25
  json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
26
26
  fresh = Option.Boolean('--fresh', false, { description: 'bust every TTL cache layer (travels to child checks)' })
27
- fix = Option.Boolean('--fix', false, { description: 'apply rule autofixes (all fixable rules, or only the listed SK ids), then re-lint' })
28
- frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining rules (SK005 never touches package.json)' })
27
+ fix = Option.Boolean('--fix', false, { description: 'apply check autofixes (every fixable check, or only the listed ids), then re-run them' })
28
+ frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for the self-maintaining checks (SK005 never touches package.json)' })
29
29
  fixIds = Option.Rest()
30
30
 
31
31
  async execute(): Promise<number> {
@@ -34,7 +34,7 @@ export class CheckCommand extends Command {
34
34
  await maybeSelfUpdateAndRestart(process.cwd())
35
35
  try {
36
36
  if (this.fixIds.length > 0 && !this.fix) {
37
- this.context.stderr.write(`FAIL: rule ids need --fix (shipkit check --fix ${this.fixIds.join(' ')})\n`)
37
+ this.context.stderr.write(`FAIL: check ids need --fix (shipkit check --fix ${this.fixIds.join(' ')})\n`)
38
38
  return 2
39
39
  }
40
40
  if (this.fix && this.json) {
@@ -0,0 +1,95 @@
1
+ // shipkit checks — THE inventory: every check this repo can run on one table,
2
+ // with what it declared about itself. `shipkit keys` answers a different
3
+ // question (what is a check keyed on, right now), and nothing else says
4
+ // whether a check blocks you or only reports the world. That answer had no
5
+ // surface, so it was reconstructed by reading source files, which is how a
6
+ // repo ends up with checks nobody can account for.
7
+ //
8
+ // `--fixable` is the narrower catalog: what repairs itself, where the
9
+ // invariant is owned, and how to apply one.
10
+ import { writeFileSync } from 'fs'
11
+ import { resolve } from 'path'
12
+ import { Command, Option, type Usage } from 'clipanion'
13
+ import { loadRepoConfig } from '../repo-config.ts'
14
+ import { envTier, gateInventory } from '../ledger/gate-checks.ts'
15
+ import { renderFixableLines, renderCheckLines, renderCheckTable } from '../ledger/check-table.ts'
16
+ import { classify } from '../ledger/git-key.ts'
17
+
18
+ export class ChecksCommand extends Command {
19
+ static override paths = [['checks']]
20
+
21
+ // widened so the hidden alias below can drop it: no usage, no help entry
22
+ static override usage: Usage | undefined = Command.Usage({
23
+ category: 'Health',
24
+ description: 'every gate check on one table: where it comes from, whether it blocks or only warns, whether the gate runs it or only `shipkit check`, and how it caches. Reports, never gates',
25
+ examples: [
26
+ ['the whole inventory', 'shipkit checks'],
27
+ ['the big markdown table, written to a file', 'shipkit checks --out STATUS.full.md'],
28
+ ['only the ones the gate never runs', 'shipkit checks --proactive'],
29
+ ['everything that repairs itself, with its owner', 'shipkit checks --fixable'],
30
+ ['machine-readable', 'shipkit checks --json'],
31
+ ],
32
+ })
33
+
34
+ json = Option.Boolean('--json', false, { description: 'machine-readable CheckRow[] with the resolved cache population' })
35
+ md = Option.Boolean('--md', false, { description: 'the full markdown table on stdout, the same bytes --out writes' })
36
+ out = Option.String('--out', { required: false, description: 'write the markdown table to this file instead of stdout' })
37
+ blocking = Option.Boolean('--blocking', false, { description: 'only checks whose failure can stop you' })
38
+ proactive = Option.Boolean('--proactive', false, { description: 'only checks the gate never runs (`shipkit check` reports them)' })
39
+ fixable = Option.Boolean('--fixable', false, { description: 'only checks that carry an autofix, with the page that owns the invariant — `shipkit check --fix <id>` applies one' })
40
+ full = Option.Boolean('--full', false, { description: 'resolve every check the way a FULL-tier run resolves it' })
41
+
42
+ async execute(): Promise<number> {
43
+ const root = process.cwd()
44
+ const c = await loadRepoConfig(root)
45
+ const tier = this.full ? 'full' : envTier()
46
+ let rows = await gateInventory(root, c, { tier })
47
+ if (this.blocking) rows = rows.filter((r) => r.blocking !== false)
48
+ if (this.proactive) rows = rows.filter((r) => r.when.at !== 'always')
49
+ if (this.fixable) rows = rows.filter((r) => r.fixable)
50
+
51
+ if (this.json) {
52
+ this.context.stdout.write(`${JSON.stringify(rows.map((r) => ({ ...r, population: classify(r.decl).population })))}\n`)
53
+ return 0
54
+ }
55
+
56
+ if (this.md || this.out != null) {
57
+ const md = renderCheckTable({ name: c.name, rows })
58
+ if (this.out != null) {
59
+ const target = resolve(root, this.out)
60
+ writeFileSync(target, `${md.trimEnd()}\n`)
61
+ this.context.stdout.write(`ok: ${rows.length} check(s) written to ${this.out}\n`)
62
+ return 0
63
+ }
64
+ this.context.stdout.write(`${md}\n`)
65
+ return 0
66
+ }
67
+
68
+ if (this.fixable) {
69
+ for (const l of renderFixableLines(rows)) this.context.stdout.write(`${l}\n`)
70
+ return 0
71
+ }
72
+
73
+ // the SAME cells the markdown table is built from, so the terminal
74
+ // and the file can never disagree about a column
75
+ for (const l of renderCheckLines(rows)) this.context.stdout.write(`${l}\n`)
76
+ const undeclared = rows.filter((r) => { const cl = classify(r.decl); return cl.population === 'none' && cl.decided !== true }).length
77
+ if (undeclared > 0) this.context.stdout.write(` why each one: shipkit keys --burn-down\n`)
78
+ this.context.stdout.write(` the full table, refreshed by every gate run: STATUS.full.md\n`)
79
+ return 0
80
+ }
81
+ }
82
+
83
+ /** `shipkit units`, the name 0.38 shipped for this inventory. Hidden from the
84
+ * help and the command index, it answers the same table with one WARN on
85
+ * stderr, so a `--json` consumer still parses stdout. It dies with the rest
86
+ * of the compatibility window at 0.40. */
87
+ export class UnitsAliasCommand extends ChecksCommand {
88
+ static override paths = [['units']]
89
+ static override usage = undefined
90
+
91
+ override async execute(): Promise<number> {
92
+ this.context.stderr.write('WARN shipkit units is now shipkit checks, the old name goes at 0.40\n')
93
+ return await super.execute()
94
+ }
95
+ }