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
@@ -1,136 +0,0 @@
1
- // A contract folder typed without its leading dot is INVISIBLE, in every
2
- // direction at once: the scanner that harvests it looks for the dotted name,
3
- // the gitignore policy covers the dotted name, and the repo that filed
4
- // something into it gets silence rather than an error. Files sat unread for a
5
- // day that way, and nothing anywhere was red.
6
- //
7
- // One character, no ambiguity, and nothing about it is a decision: it is a
8
- // typo, so the fix pass renames it and lands it, and no agent is told. A WARN a
9
- // machine can repair is a design bug, and an attention surface carries only
10
- // what needs taste or hands.
11
- //
12
- // The names come from the SK004 policy block, which is the one table that
13
- // already lists the `.rv-*` contract folders. The TRACKED contract dirs
14
- // (`.rv-identity`, `.rv-assets`) are not in it, and a rename that untracks a
15
- // tracked folder is not a repair, so the ignore is checked against THIS REPO
16
- // before anything moves: the venue names in that block carry "ignored until the
17
- // listing is live, override below the block", so a repo with a live listing
18
- // tracks `.rv-itch/` on purpose and a rename there would delete its files from
19
- // the index. `git check-ignore` answers for the repo instead of a regex
20
- // answering for a comment.
21
- import { readdirSync, renameSync, rmdirSync } from 'fs'
22
- import { join } from 'path'
23
- import { POLICY_LINES } from '../rules/SK004-gitignore-policy.ts'
24
- import { machineCommit } from './machine-commit.ts'
25
- import { nestedIn } from './nesting.ts'
26
-
27
- /** the always-ignored `.rv-*` contract folders, from the policy that owns them */
28
- export const contractDirNames = (): string[] =>
29
- POLICY_LINES.filter((l) => /^\.rv-[a-z-]+\/$/.test(l)).map((l) => l.slice(0, -1))
30
-
31
- /** the shape of a name, ignoring the leading dot, the separators, the case and
32
- * a trailing plural. Tight on purpose: `rv-reflections`, `rv_reflection` and
33
- * `RV-Reflections` are the same intent, `reflections` and `docs` are not */
34
- const shapeOf = (s: string): string => s.toLowerCase().replace(/^\.+/, '').replace(/[-_.]/g, '').replace(/s$/, '')
35
-
36
- export type DirRepair = { from: string; to: string }
37
- export type DirRefusal = { from: string; to: string; reason: string }
38
- export type DirRepairPlan = { repairs: DirRepair[]; refusals: DirRefusal[] }
39
-
40
- /**
41
- * PURE: what to rename, given the names sitting in a repo root.
42
- *
43
- * BOTH names present is the one case worth refusing out loud: two folders can
44
- * hold two files of the same name, and merging them is a choice about which
45
- * one survives. Nothing else here is a choice.
46
- */
47
- export function planContractDirRepairs(entries: string[], names: string[] = contractDirNames()): DirRepairPlan {
48
- const present = new Set(entries)
49
- const repairs: DirRepair[] = []
50
- const refusals: DirRefusal[] = []
51
- for (const dotted of names) {
52
- const shape = shapeOf(dotted)
53
- for (const entry of entries) {
54
- if (entry === dotted || shapeOf(entry) !== shape) continue
55
- if (present.has(dotted)) refusals.push({ from: entry, to: dotted, reason: 'both names exist, and merging them decides which file wins' })
56
- else repairs.push({ from: entry, to: dotted })
57
- }
58
- }
59
- return { repairs, refusals }
60
- }
61
-
62
- const isEmptyDir = (path: string): boolean => {
63
- try { return readdirSync(path).length === 0 } catch { return false }
64
- }
65
-
66
- const dirsIn = (root: string): string[] => {
67
- try { return readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) } catch { return [] }
68
- }
69
-
70
- export type DirRepairResult = { renamed: DirRepair[]; removed: string[]; refusals: DirRefusal[]; uncommitted: DirRepair[] }
71
-
72
- /** does THIS repo's ignore policy cover the dotted name (the index is not
73
- * consulted: the question is about the patterns, and the path does not exist yet) */
74
- const isIgnoredHere = (root: string, name: string): boolean =>
75
- Bun.spawnSync(['git', 'check-ignore', '-q', '--no-index', `${name}/`], { cwd: root }).exitCode === 0
76
-
77
- /** does git track anything under this folder today */
78
- const isTracked = (root: string, name: string): boolean => {
79
- const r = Bun.spawnSync(['git', 'ls-files', '--', name], { cwd: root })
80
- return r.exitCode === 0 && r.stdout.toString().trim() !== ''
81
- }
82
-
83
- /**
84
- * Apply the plan. An EMPTY undotted folder is removed rather than renamed:
85
- * there is nothing to carry over, git never tracked it, and the dotted name is
86
- * created by whoever files the first note.
87
- *
88
- * A rename UNTRACKS whatever the undotted folder held, and that is the intended
89
- * end state rather than a side effect: the dotted name is gitignored by
90
- * contract, so the repo was only tracking those files by accident of the
91
- * missing dot. Hence a plain rename plus a commit of the OLD path: the commit
92
- * records the files leaving the index, the files themselves stay on disk under
93
- * the name every scanner reads, and `git commit -- <old>` matches nothing when
94
- * git never tracked them, which lands no commit and is the right answer there.
95
- */
96
- export function repairContractDirs(root: string): DirRepairResult {
97
- const plan = planContractDirRepairs(dirsIn(root))
98
- const renamed: DirRepair[] = []
99
- const removed: string[] = []
100
- const uncommitted: DirRepair[] = []
101
- // a nested repo never untracks: the index and the commit are the parent's
102
- const nested = plan.repairs.length === 0 ? null : nestedIn(root)
103
- for (const r of plan.repairs) {
104
- const from = join(root, r.from)
105
- const to = join(root, r.to)
106
- try {
107
- if (isEmptyDir(from)) {
108
- rmdirSync(from)
109
- removed.push(r.from)
110
- continue
111
- }
112
- // renaming TRACKED files into an ignored path takes them out of the
113
- // index, which is the contract for a reflections folder and data
114
- // loss for a live venue folder the repo tracks on purpose. So the
115
- // untracking half only happens where the repo's own ignore policy
116
- // says those files are local. Untracked files rename freely: there
117
- // is nothing to lose and nothing to commit
118
- const tracked = isTracked(root, r.from)
119
- if (tracked && (nested != null || !isIgnoredHere(root, r.to))) continue
120
- renameSync(from, to)
121
- renamed.push(r)
122
- // a repair that half happened is not the silence that was asked
123
- // for. the hooks can refuse, SHIPKIT_NO_AUTOCOMMIT can be set, a
124
- // merge can be in progress: the rename is still the right end
125
- // state, and the dirt it leaves has to be traceable to this run
126
- // rather than surfacing as an unexplained refusal in the next bump
127
- if (tracked && !machineCommit({ anchor: root, message: `chore(machine): rename ${r.from} to ${r.to}`, paths: [from] })) {
128
- uncommitted.push(r)
129
- }
130
- } catch {
131
- // a permission error or a race is not worth a red gate: the folder
132
- // stays where it is and the next run tries again
133
- }
134
- }
135
- return { renamed, removed, refusals: plan.refusals, uncommitted }
136
- }
@@ -1,339 +0,0 @@
1
- // Task runner — dep-DAG scheduler with wire-protocol output (wire.ts).
2
- // Plain mode only by design: one `ok:`/`FAIL:` line per finished task +
3
- // final summary; greppable by humans and agents alike. Semantics:
4
- // - a dep must PASS to unblock; a filtered-out dep counts as satisfied;
5
- // a failed/skipped dep skips dependents transitively (note says why).
6
- // Deps double as serialization guards (document racy edges at the def).
7
- // - subprocess tasks: full log streamed to <logDir>/run-<pid>-<ts>/<id>.log, last non-empty
8
- // line kept for the FAIL summary; SIGTERM at timeoutMs, SIGKILL +2s.
9
- // - logic tasks run in-process against a CheckCtx: WARN never flips the
10
- // exit code, FAIL does. Their lines print under the task line.
11
- // - bail/SIGINT KILL in-flight children (SIGTERM, SIGKILL +2s); a killed
12
- // task reports 'skipped (killed …)', never a fake fail.
13
- // - sharp edge, by design: `onlyIds` on a task whose race-guard dep is
14
- // optIn/skipped-group drops that dep (filtered = satisfied) — explicit
15
- // filters win over guards.
16
- import { createWriteStream, mkdirSync, readdirSync, rmSync, type WriteStream } from 'fs'
17
- import { basename, join } from 'path'
18
- import { CheckCtx } from './check-ctx.ts'
19
- import { spawnWithTimeout } from './spawn-timeout.ts'
20
- import { formatLine, type Line } from './wire.ts'
21
-
22
- export type TaskDef = {
23
- id: string
24
- cmd?: string[]
25
- logic?: (t: CheckCtx) => unknown
26
- deps?: string[]
27
- env?: Record<string, string>
28
- cwd?: string
29
- group?: string
30
- /** excluded unless listed in RunOpts.withIds */
31
- optIn?: boolean
32
- timeoutMs?: number
33
- /** CONTENT population: the repo-relative paths this task reads. Declaring
34
- * them is what makes the task keyable (ledger/git-key.ts). Undeclared is
35
- * the DEFAULT and means never cached, so forgetting a whole task costs
36
- * speed and never correctness. A list MISSING a path the command reads is
37
- * the one that costs correctness: the key cannot move when that path
38
- * changes, so the answer replays over a tree it no longer describes. Over
39
- * declaring costs a re-run, under declaring is a false green. `['.']` is
40
- * the whole TRACKED tree, and it is NOT a catch-all: an ignored path
41
- * enters a key by name and never by content, so a task reading a
42
- * gitignored fixture, a built dir or a .env under declares with `['.']`
43
- * exactly as it would with a narrow list. A task that reads the network,
44
- * a keychain or the clock declares `ttl`, never `inputs`. A task carries no code
45
- * closure, so the script it runs belongs in here too.
46
- * Declaring both this and `ttl` is a registration failure. */
47
- inputs?: string[]
48
- /** WORLD population: this task reads the network, a keychain, $PATH or the
49
- * clock, so nothing on disk describes its answer and a clean pass is
50
- * remembered for this long (e.g. '24h'). */
51
- ttl?: string
52
- /** a DECIDED never-cacheable, with the reason. Says somebody looked and
53
- * nothing can describe this task, which takes it off the burn-down list
54
- * that undeclared units land on. */
55
- never?: string
56
- }
57
-
58
- /** every field a `TaskDef` may carry. Declared as a value so `defineRepo` can
59
- * refuse a typo inside a `defaults` slot: a slot with no `cmd` DECORATES the
60
- * resolved task, so a misspelled field is not a type error at the call site
61
- * and would land as a task that is quietly never cached. Add a field above,
62
- * add it here. */
63
- export const TASK_DEF_KEYS = ['id', 'cmd', 'logic', 'deps', 'env', 'cwd', 'group', 'optIn', 'timeoutMs', 'inputs', 'ttl', 'never'] as const
64
-
65
- export type TaskResult = {
66
- id: string
67
- status: 'pass' | 'fail' | 'skipped'
68
- exitCode: number | null
69
- durationMs: number
70
- logPath: string | null
71
- lastLine: string
72
- note: string
73
- lines: Line[]
74
- }
75
-
76
- export type RunOpts = {
77
- tasks: TaskDef[]
78
- logDir: string
79
- cwd?: string
80
- maxParallel?: number
81
- bail?: boolean
82
- skipGroups?: string[]
83
- onlyIds?: string[]
84
- withIds?: string[]
85
- /** ids `--only` may name that are NOT tasks: discovered checks and rules.
86
- * The flag addresses all three surfaces, so a check name must not come
87
- * back as `unknown task id` from the one surface that does not own it. */
88
- otherIds?: string[]
89
- /** print sink — tests capture, production defaults to console.log */
90
- print?: (line: string) => void
91
- color?: boolean
92
- /** per-task completion tap (the TUI streams results in as they land) —
93
- * fires for pass/fail/dep-skip; bail/interrupt leftovers never finish,
94
- * callers sweep their own pending state after runTasks resolves */
95
- onTaskFinish?: (r: TaskResult) => void
96
- }
97
-
98
- export type RunSummary = { ok: boolean; results: TaskResult[]; interrupted: boolean }
99
-
100
- type Live = TaskDef & TaskResult & { startedAt: number }
101
-
102
- const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '').replace(/\r/g, '')
103
-
104
- // only-mode keeps the transitive dep closure — running `--only typecheck`
105
- // with a lint race-guard dep must still run lint first
106
- function closure(tasks: TaskDef[], ids: string[]): Set<string> {
107
- const byId = new Map(tasks.map((t) => [t.id, t]))
108
- const keep = new Set<string>()
109
- const visit = (id: string): void => {
110
- if (keep.has(id)) return
111
- keep.add(id)
112
- for (const d of byId.get(id)?.deps ?? []) visit(d)
113
- }
114
- for (const id of ids) visit(id)
115
- return keep
116
- }
117
-
118
- export function selectTasks(o: RunOpts): { selected: TaskDef[]; errors: string[] } {
119
- const errors: string[] = []
120
- const ids = new Set(o.tasks.map((t) => t.id))
121
- if (ids.size !== o.tasks.length) errors.push('duplicate task ids')
122
- for (const t of o.tasks) {
123
- if ((t.cmd == null) === (t.logic == null)) errors.push(`task ${t.id}: exactly one of cmd/logic required`)
124
- for (const d of t.deps ?? []) if (!ids.has(d)) errors.push(`task ${t.id}: unknown dep '${d}'`)
125
- }
126
- const elsewhere = new Set((o.otherIds ?? []).map((i) => i.toLowerCase()))
127
- for (const id of [...(o.onlyIds ?? []), ...(o.withIds ?? [])])
128
- if (!ids.has(id) && !elsewhere.has(id.toLowerCase())) errors.push(`unknown task id '${id}'`)
129
- // cycle check — a dep cycle must die loud at select time, not hang the loop
130
- const state = new Map<string, 0 | 1>()
131
- const byId = new Map(o.tasks.map((t) => [t.id, t]))
132
- const walk = (id: string, trail: string[]): void => {
133
- if (state.get(id) === 1) return
134
- if (state.get(id) === 0) { errors.push(`dep cycle: ${[...trail, id].join(' → ')}`); return }
135
- state.set(id, 0)
136
- for (const d of byId.get(id)?.deps ?? []) walk(d, [...trail, id])
137
- state.set(id, 1)
138
- }
139
- for (const t of o.tasks) walk(t.id, [])
140
- if (errors.length > 0) return { selected: [], errors }
141
-
142
- const withSet = new Set(o.withIds ?? [])
143
- const skipGroups = new Set(o.skipGroups ?? [])
144
- let selected = o.tasks.filter((t) => !(t.optIn === true && !withSet.has(t.id)) && !(t.group != null && skipGroups.has(t.group)))
145
- if (o.onlyIds != null && o.onlyIds.length > 0) {
146
- const keep = closure(o.tasks, o.onlyIds)
147
- selected = selected.filter((t) => keep.has(t.id))
148
- }
149
- return { selected, errors }
150
- }
151
-
152
- /** THIS run's log dir. It used to be `logDir` itself, wiped with rmSync at the
153
- * top of every run: a second agent starting a gate deleted the log files the
154
- * first was still streaming into, so the surviving FAIL line pointed at a
155
- * path that no longer existed. Now each run owns `run-<pid>-<started>/` and
156
- * writes nowhere else.
157
- *
158
- * Old run dirs are swept on the way in, and only ones nothing can be using:
159
- * the pid must not be running AND the dir must be older than the grace
160
- * window, so a recycled pid cannot cost a live run its logs. */
161
- function openRunDir(base: string): string {
162
- // the pid plus the millisecond separates two PROCESSES; the counter
163
- // separates two runTasks calls inside one (the TUI can start a second
164
- // before the clock ticks), so no run ever writes into another's dir
165
- const dir = join(base, `run-${process.pid}-${Date.now()}-${runSeq++}`)
166
- mkdirSync(dir, { recursive: true })
167
- const cutoff = Date.now() - RUN_DIR_KEEP_MS
168
- try {
169
- for (const name of readdirSync(base)) {
170
- // run dirs AND the per-run release survey files, which a killed
171
- // run leaves behind: both carry the pid and the start time, so one
172
- // sweep covers both rather than one path leaking forever
173
- const m = /^(?:run|releases)-(\d+)-(\d+)(?:-\d+)?(?:\.json(?:\.lock)?)?$/.exec(name)
174
- if (m == null || name === basename(dir)) continue
175
- if (Number(m[2]) > cutoff) continue
176
- try { process.kill(Number(m[1]), 0); continue } catch (e) { if ((e as { code?: string }).code === 'EPERM') continue }
177
- rmSync(join(base, name), { recursive: true, force: true })
178
- }
179
- } catch {
180
- // an unreadable log base is a housekeeping miss, never a gate failure
181
- }
182
- return dir
183
- }
184
-
185
- /** how long a finished run's logs stay readable, and the pid-reuse guard */
186
- const RUN_DIR_KEEP_MS = 6 * 60 * 60 * 1000
187
- let runSeq = 0
188
-
189
- export async function runTasks(o: RunOpts): Promise<RunSummary> {
190
- const print = o.print ?? ((l: string) => console.log(l))
191
- const color = o.color ?? (process.stdout.isTTY && process.env.NO_COLOR == null)
192
- const sel = selectTasks(o)
193
- if (sel.errors.length > 0) {
194
- for (const e of sel.errors) print(formatLine({ kind: 'fail', text: e }, color))
195
- return { ok: false, results: [], interrupted: false }
196
- }
197
- const selectedIds = new Set(sel.selected.map((t) => t.id))
198
- const runDir = openRunDir(o.logDir)
199
-
200
- const tasks: Live[] = sel.selected.map((t) => ({
201
- ...t,
202
- status: 'skipped', exitCode: null, durationMs: 0, startedAt: 0,
203
- logPath: t.cmd != null ? join(runDir, `${t.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
204
- lastLine: '', note: '', lines: [],
205
- }))
206
- const byId = new Map(tasks.map((t) => [t.id, t]))
207
- const queued = new Set(tasks.map((t) => t.id))
208
- const done = new Map<string, Live>()
209
- const running = new Map<string, Promise<void>>()
210
- const cap = o.maxParallel ?? Math.max(2, Math.floor((navigator.hardwareConcurrency || 4) / 2))
211
- let interrupted = false
212
- let bailed = false
213
- const procs = new Map<string, ReturnType<typeof Bun.spawn>>()
214
- const killed = new Map<string, string>() // task id → reason
215
- const killAll = (reason: string): void => {
216
- for (const [id, proc] of procs) {
217
- killed.set(id, reason)
218
- try { proc.kill() } catch {}
219
- setTimeout(() => { try { proc.kill('SIGKILL') } catch {} }, 2000)
220
- }
221
- }
222
-
223
- const depState = (t: Live): 'ready' | 'blocked' | 'dead' => {
224
- for (const d of t.deps ?? []) {
225
- if (!selectedIds.has(d)) continue // filtered dep = satisfied
226
- const dep = done.get(d)
227
- if (dep == null) return 'blocked'
228
- if (dep.status !== 'pass') { t.note = `dep ${d} ${dep.status}`; return 'dead' }
229
- }
230
- return 'ready'
231
- }
232
-
233
- const finish = (t: Live, status: Live['status'], code: number | null): void => {
234
- t.status = status
235
- t.exitCode = code
236
- t.durationMs = t.startedAt > 0 ? Date.now() - t.startedAt : 0
237
- done.set(t.id, t)
238
- o.onTaskFinish?.({ id: t.id, status: t.status, exitCode: t.exitCode, durationMs: t.durationMs, logPath: t.logPath, lastLine: t.lastLine, note: t.note, lines: t.lines })
239
- const dur = ` (${(t.durationMs / 1000).toFixed(1)}s)`
240
- if (status === 'pass') {
241
- // WARNs nag and skips stay visible (exempt contracts) even on pass
242
- const surfaced = t.lines.filter((l) => l.kind === 'warn' || l.kind === 'fix-warn' || l.kind === 'skip')
243
- print(formatLine({ kind: 'ok', text: `${t.id}${dur}` }, color))
244
- for (const l of surfaced) print(formatLine(l, color))
245
- } else if (status === 'fail') {
246
- print(formatLine({ kind: 'fail', text: `${t.id}${dur}${t.lastLine !== '' ? ` — ${t.lastLine}` : ''}` }, color))
247
- for (const l of t.lines.filter((l) => l.kind !== 'ok' && l.kind !== 'note')) print(formatLine(l, color))
248
- if (t.logPath != null) print(formatLine({ kind: 'fix-fail', text: `full log: ${t.logPath}` }, color))
249
- if (o.bail && !bailed) { bailed = true; killAll('bail') }
250
- }
251
- // skipped tasks report only in the summary — dep noise stays low
252
- }
253
-
254
- const startTask = (t: Live): void => {
255
- queued.delete(t.id)
256
- t.startedAt = Date.now()
257
- const p = (async (): Promise<void> => {
258
- if (t.logic != null) {
259
- const ctx = new CheckCtx(new Map())
260
- try { await t.logic(ctx) } catch (e) { ctx.fail(`${t.id} threw: ${String(e).slice(0, 200)}`) }
261
- t.lines = ctx.lines
262
- // headline prefers the last FAIL — an exempt/skip line landing
263
- // last must not masquerade as the failure
264
- const last = ctx.lines.filter((l) => l.kind === 'fail').at(-1) ?? ctx.lines.at(-1)
265
- t.lastLine = last != null ? last.text.slice(0, 200) : ''
266
- finish(t, ctx.failed ? 'fail' : 'pass', ctx.failed ? 1 : 0)
267
- return
268
- }
269
- const cmd = t.cmd!
270
- const ms = t.timeoutMs ?? 600_000
271
- // log stream opens only once the child actually spawned — a
272
- // missing binary must not leave a dangling open on the log dir
273
- let out: WriteStream | null = null
274
- const r = await spawnWithTimeout(cmd, {
275
- timeoutMs: ms,
276
- env: { ...process.env, ...t.env },
277
- cwd: t.cwd ?? o.cwd,
278
- onSpawn: (proc) => {
279
- out = createWriteStream(t.logPath!, { flags: 'a' })
280
- procs.set(t.id, proc)
281
- },
282
- onChunk: (text) => {
283
- out?.write(text)
284
- for (const raw of text.split(/\r?\n/)) {
285
- const s = stripAnsi(raw).trim()
286
- if (s !== '') t.lastLine = s.slice(0, 200)
287
- }
288
- },
289
- })
290
- procs.delete(t.id)
291
- // TS narrows `out` to its initializer across the await (assignment
292
- // happens in the onSpawn closure) — widen explicitly
293
- ;(out as WriteStream | null)?.end()
294
- // missing binary / spawn throw surface as a wire FAIL, never a crash
295
- if (r.spawnFailed) { t.lastLine = r.out; finish(t, 'fail', -1); return }
296
- const killReason = killed.get(t.id)
297
- if (killReason != null) { t.note = `killed (${killReason})`; finish(t, 'skipped', r.code); return }
298
- if (r.timedOut) t.lastLine = `timeout after ${ms}ms — ${t.lastLine}`
299
- finish(t, r.code === 0 ? 'pass' : 'fail', r.code)
300
- })().finally(() => running.delete(t.id))
301
- running.set(t.id, p)
302
- }
303
-
304
- const onSigint = (): void => { interrupted = true; killAll('interrupt') }
305
- process.on('SIGINT', onSigint)
306
- try {
307
- while (queued.size > 0 || running.size > 0) {
308
- if (interrupted || bailed) {
309
- for (const id of [...queued]) { const t = byId.get(id)!; t.note = bailed ? 'bailed' : 'interrupted'; queued.delete(id); done.set(id, t) }
310
- await Promise.all(running.values())
311
- break
312
- }
313
- let started = false
314
- for (const id of [...queued]) {
315
- if (running.size >= cap) break
316
- const t = byId.get(id)!
317
- const st = depState(t)
318
- if (st === 'dead') { queued.delete(id); finish(t, 'skipped', null); started = true }
319
- else if (st === 'ready') { startTask(t); started = true }
320
- }
321
- if (running.size > 0) await Promise.race(running.values())
322
- else if (!started && queued.size > 0) {
323
- // nothing running, nothing startable — impossible unless a bug: die loud
324
- for (const id of [...queued]) print(formatLine({ kind: 'fail', text: `${id}: scheduler stuck (unsatisfiable deps?)` }, color))
325
- break
326
- }
327
- }
328
- } finally {
329
- process.off('SIGINT', onSigint)
330
- }
331
-
332
- const results = tasks.map((t): TaskResult => ({ id: t.id, status: t.status, exitCode: t.exitCode, durationMs: t.durationMs, logPath: t.logPath, lastLine: t.lastLine, note: t.note, lines: t.lines }))
333
- const failed = results.filter((r) => r.status === 'fail')
334
- const skipped = results.filter((r) => r.status === 'skipped')
335
- if (failed.length > 0) print(formatLine({ kind: 'fail', text: `${failed.length}/${results.length} failed: ${failed.map((r) => r.id).join(', ')}` }, color))
336
- if (skipped.length > 0) print(formatLine({ kind: 'note', text: `skip: ${skipped.map((r) => `${r.id}${r.note !== '' ? ` (${r.note})` : ''}`).join(', ')}` }, color))
337
- if (failed.length === 0 && !interrupted) print(formatLine({ kind: 'ok', text: `all ${results.filter((r) => r.status === 'pass').length} tasks green` }, color))
338
- return { ok: failed.length === 0 && !interrupted, results, interrupted }
339
- }
@@ -1,138 +0,0 @@
1
- // EVERY UNIT THE GATE RUNS, with what it declared about its own cacheability.
2
- //
3
- // Three registries feed one list, because a key layer that can only see tasks
4
- // would leave the 25 checks and the 32 rules uncached forever, and those are
5
- // most of the wall time. The declaration lives with the unit (a `TaskDef`
6
- // field, a `Rule` field, an `export const inputs` in a check file), never in a
7
- // table over here: a table beside the registry is a second source of truth
8
- // that drifts the first time somebody adds a check.
9
- import { relative } from 'path'
10
- import { allTasks, type RepoConfig } from '../repo-config.ts'
11
- import type { TaskDef } from '../core/task-runner.ts'
12
- import { discoverChecks } from '../core/discover-checks.ts'
13
- import { RULES } from '../rules/rule-registry.ts'
14
- import { ruleCacheExtra } from '../rules/rule.ts'
15
- import type { UnitDecl } from './git-key.ts'
16
-
17
- /** the env var a repo's own suite reads to widen itself. It lives HERE because
18
- * it rewrites every task's env, which is part of the key: a run that asked for
19
- * the quick tier and one that asked for the full tier are different questions,
20
- * and a surface that renders keys without applying it renders a tier nobody
21
- * ran. */
22
- export const FULL_TESTS_ENV = 'SHIPKIT_TEST_FULL'
23
-
24
- export type TestTier = 'quick' | 'full'
25
-
26
- /** the tier this process is asking for, absent an explicit choice */
27
- export function envTier(): TestTier {
28
- return process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick'
29
- }
30
-
31
- /** the repo's tasks as the gate will actually run them at `tier`. */
32
- export function tieredTasks(c: RepoConfig, root: string, tier: TestTier): TaskDef[] {
33
- const tasks = allTasks(c, root).tasks
34
- return tier === 'full' ? tasks.map((t) => ({ ...t, env: { ...t.env, [FULL_TESTS_ENV]: '1' } })) : tasks
35
- }
36
-
37
- /** every task, rule and discovered check, in the order the gate meets them.
38
- *
39
- * `tasks` overrides what the repo resolves to, and the gate MUST pass what it
40
- * is actually about to run: `--full` rewrites every task's env, and a key
41
- * built from the unmodified list would let a quick run replay into a full one
42
- * under one identity. */
43
- export function gateUnits(root: string, c: RepoConfig, tasks?: TaskDef[]): UnitDecl[] {
44
- return gateInventory(root, c, tasks).map((r) => r.decl)
45
- }
46
-
47
- /** one gate unit with everything a reader asks about it, not only what the
48
- * key layer asks. The cache half stays in `decl` so `UnitDecl` never grows
49
- * fields the key layer must then decide to ignore. */
50
- export type UnitRow = {
51
- decl: UnitDecl
52
- /** the display name an operator reads (a rule's title, else the id) */
53
- title: string
54
- /** does `shipkit ci` run it? false = PROACTIVE: `shipkit check` reports it,
55
- * the gate never does, and it may sit red for months without anything
56
- * being wrong with the change in front of you. */
57
- gate: boolean
58
- /** can a failure stop you? null = the unit never said AND the repo set no
59
- * `checkDefaults.blocking`, which the check runner treats as fatal. */
60
- blocking: boolean | null
61
- /** where the answer is owned: a rule's wiki page, a check's file, a task's
62
- * command. Null when the unit names none. */
63
- owner: string | null
64
- /** the group a task was filed under, for the few that declare one */
65
- group: string | null
66
- /** excluded from a default run unless named with `--with` */
67
- optIn: boolean
68
- }
69
-
70
- /** every unit the gate knows, with its metadata. `gateUnits` is this list
71
- * narrowed to the cache declaration, so the three registries are walked in
72
- * exactly one place and a surface can never enumerate a different set than
73
- * the one the key layer keyed. */
74
- export function gateInventory(root: string, c: RepoConfig, tasks?: TaskDef[]): UnitRow[] {
75
- const out: UnitRow[] = []
76
- for (const t of tasks ?? allTasks(c, root).tasks) {
77
- out.push({
78
- decl: {
79
- id: t.id,
80
- kind: 'task',
81
- inputs: t.inputs,
82
- ttl: t.ttl,
83
- never: t.never,
84
- argv: t.cmd,
85
- extra: t.env == null ? undefined : JSON.stringify(Object.entries(t.env).sort()),
86
- },
87
- title: t.id,
88
- // an optIn task is off the default run, which is the same shape as
89
- // a check that opted out of the gate
90
- gate: t.optIn !== true,
91
- // a task IS the build: it has no non-blocking mode to declare
92
- blocking: true,
93
- owner: t.cmd == null ? null : t.cmd.join(' '),
94
- group: t.group ?? null,
95
- optIn: t.optIn === true,
96
- })
97
- }
98
- for (const r of [...RULES, ...(c.rules ?? [])]) {
99
- out.push({
100
- decl: {
101
- id: r.id,
102
- kind: 'rule',
103
- inputs: r.inputs,
104
- code: r.code,
105
- ttl: r.ttl,
106
- never: r.never,
107
- extra: ruleCacheExtra(r, { root, config: c }),
108
- },
109
- title: r.title,
110
- gate: true,
111
- blocking: r.blocking,
112
- owner: r.owner,
113
- group: null,
114
- optIn: false,
115
- })
116
- }
117
- for (const d of discoverChecks(root)) {
118
- const rel = relative(root, d.path).replace(/\\/g, '/')
119
- out.push({
120
- decl: {
121
- id: d.name,
122
- kind: 'check',
123
- inputs: d.inputs ?? undefined,
124
- ttl: d.ttl ?? undefined,
125
- never: d.never ?? undefined,
126
- code: [rel],
127
- argv: ['bun', rel],
128
- },
129
- title: d.name,
130
- gate: d.gate,
131
- blocking: d.blocking ?? c.checkDefaults?.blocking ?? null,
132
- owner: rel,
133
- group: null,
134
- optIn: false,
135
- })
136
- }
137
- return out
138
- }