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,113 @@
1
+ // a JSONC parser that remembers WHERE every value sits, so a fixer can edit
2
+ // one value of a hand-written file (vscode's tasks.json carries comments and
3
+ // trailing commas) and leave every other byte exactly as its author wrote it.
4
+
5
+ export type Node =
6
+ | { kind: 'object'; start: number; end: number; props: { key: string; keyStart: number; value: Node }[] }
7
+ | { kind: 'array'; start: number; end: number; items: Node[] }
8
+ | { kind: 'scalar'; start: number; end: number; value: unknown }
9
+
10
+ /** the tree of `text`, or throws on a malformed document. `end` is exclusive */
11
+ export function parseSpans(text: string): Node {
12
+ let i = 0
13
+ const skip = (): void => {
14
+ for (;;) {
15
+ while (i < text.length && /\s/.test(text[i]!)) i++
16
+ if (text.startsWith('//', i)) { while (i < text.length && text[i] !== '\n') i++; continue }
17
+ if (text.startsWith('/*', i)) { const e = text.indexOf('*/', i + 2); i = e < 0 ? text.length : e + 2; continue }
18
+ return
19
+ }
20
+ }
21
+ const fail = (what: string): never => { throw new Error(`jsonc: ${what} at offset ${i}`) }
22
+ const str = (): string => {
23
+ const start = i
24
+ i++
25
+ while (i < text.length && text[i] !== '"') i += text[i] === '\\' ? 2 : 1
26
+ if (i >= text.length) fail('unterminated string')
27
+ i++
28
+ return JSON.parse(text.slice(start, i)) as string
29
+ }
30
+ const value = (): Node => {
31
+ skip()
32
+ const start = i
33
+ const c = text[i]
34
+ if (c === '{') {
35
+ i++
36
+ const props: { key: string; keyStart: number; value: Node }[] = []
37
+ for (;;) {
38
+ skip()
39
+ if (text[i] === '}') { i++; return { kind: 'object', start, end: i, props } }
40
+ if (text[i] !== '"') fail('expected a key')
41
+ const keyStart = i
42
+ const key = str()
43
+ skip()
44
+ if (text[i] !== ':') fail('expected a colon')
45
+ i++
46
+ props.push({ key, keyStart, value: value() })
47
+ skip()
48
+ if (text[i] === ',') i++
49
+ }
50
+ }
51
+ if (c === '[') {
52
+ i++
53
+ const items: Node[] = []
54
+ for (;;) {
55
+ skip()
56
+ if (text[i] === ']') { i++; return { kind: 'array', start, end: i, items } }
57
+ items.push(value())
58
+ skip()
59
+ if (text[i] === ',') i++
60
+ }
61
+ }
62
+ if (c === '"') {
63
+ const v = str()
64
+ return { kind: 'scalar', start, end: i, value: v }
65
+ }
66
+ const m = /^(?:true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(text.slice(i))
67
+ if (m == null) return fail('unexpected character')
68
+ i += m[0].length
69
+ return { kind: 'scalar', start, end: i, value: JSON.parse(m[0]) }
70
+ }
71
+ const root = value()
72
+ skip()
73
+ if (i < text.length) fail('trailing content')
74
+ return root
75
+ }
76
+
77
+ /** the plain value of a node, comments and positions dropped */
78
+ export function plain(text: string, n: Node): unknown {
79
+ if (n.kind === 'scalar') return n.value
80
+ if (n.kind === 'array') return n.items.map((x) => plain(text, x))
81
+ return Object.fromEntries(n.props.map((p) => [p.key, plain(text, p.value)]))
82
+ }
83
+
84
+ /** the whitespace a line starts with, for the line holding offset `at` */
85
+ export function indentAt(text: string, at: number): string {
86
+ const lineStart = text.lastIndexOf('\n', at - 1) + 1
87
+ return /^[ \t]*/.exec(text.slice(lineStart))![0]
88
+ }
89
+
90
+ /** the text span that removes item `idx` of an array cleanly: its own line
91
+ * when it sits alone on it, and the comma that joined it to a neighbour */
92
+ export function removalSpan(text: string, arr: Extract<Node, { kind: 'array' }>, idx: number): [number, number] {
93
+ const item = arr.items[idx]!
94
+ const next = arr.items[idx + 1]
95
+ if (next != null) {
96
+ let j = item.end
97
+ while (j < text.length && /[ \t]/.test(text[j]!)) j++
98
+ if (text[j] === ',') j++
99
+ const lineStart = text.lastIndexOf('\n', item.start - 1) + 1
100
+ const eol = text.indexOf('\n', j)
101
+ // whole lines when the item owns them, so a comment above the next
102
+ // item stays with it
103
+ if (text.slice(lineStart, item.start).trim() === '' && eol >= 0 && text.slice(j, eol).trim() === '') return [lineStart, eol + 1]
104
+ while (j < text.length && /[ \t]/.test(text[j]!)) j++
105
+ return [item.start, j]
106
+ }
107
+ const prev = arr.items[idx - 1]
108
+ if (prev != null) {
109
+ // the last one: drop the comma after the previous item, keep what closes the array
110
+ return [prev.end, item.end]
111
+ }
112
+ return [item.start, item.end]
113
+ }
@@ -13,6 +13,7 @@
13
13
  import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
14
14
  import { dirname, join } from 'path'
15
15
  import { defaultHubPort } from '../serve/hub-port.ts'
16
+ import { hubProblemMatcher } from '../problems/problem-line.ts'
16
17
 
17
18
  const FILES = join(import.meta.dir, 'files')
18
19
 
@@ -122,23 +123,21 @@ export type ErgonomicsOpts = {
122
123
  * is the opposite: with no `folderOpen` task nothing starts at all, so there
123
124
  * is no existing answer to overrule.
124
125
  *
125
- * What lands INSIDE it is DERIVED, never assumed. `build/vscode-tasks.md` owns
126
- * this artifact and its method is per-project analysis, so the only subtasks
127
- * written here are the ones true of every wired repo (the hub, whose script
128
- * `init` writes) plus a TSC watcher when this repo actually has a project for
129
- * one. A canned watcher pointed at a solution tsconfig (`files: []` plus
130
- * `references`) type checks ZERO files and reports a clean problems pane
131
- * forever, which is worse than no task. Every other watcher a stack earns
132
- * (a dev server, css, tests) stays the analysis step the owner page describes,
126
+ * What lands INSIDE it is only what is true of every wired repo: the hub,
127
+ * whose script `init` writes, carrying the problem matcher that turns every
128
+ * check's findings into the problems pane. There is no type checker task, the
129
+ * hub runs `typecheck` like any other check. Every watcher a stack earns (a
130
+ * dev server, css) stays the analysis step `build/vscode-tasks.md` describes,
133
131
  * and the caller says so out loud.
134
132
  *
135
133
  * Create-only: an adopted repo often carries a `.vscode/` from whatever it was
136
- * forked from, and that one wins byte for byte. Returns the paths it wrote. */
134
+ * forked from, and that one wins byte for byte (rule SK017 then repairs its
135
+ * hub task in place). Returns the paths it wrote. */
137
136
  export function editorErgonomics(root: string, opts: ErgonomicsOpts): string[] {
138
137
  // a symlinked .vscode is a dotfiles pattern pointing at a SHARED dir, and
139
138
  // writing through it lands files outside the repo being wired
140
139
  if (isLink(join(root, '.vscode'))) return []
141
- const body = tasksTemplate({ NAME: opts.name, PORT: String(opts.port) }, typecheckableProject(root))
140
+ const body = tasksTemplate({ NAME: opts.name, PORT: String(opts.port) })
142
141
  return put(root, '.vscode/tasks.json', body) ? ['.vscode/tasks.json'] : []
143
142
  }
144
143
 
@@ -150,35 +149,11 @@ const isLink = (path: string): boolean => {
150
149
  }
151
150
  }
152
151
 
153
- /** the tasks template with the TSC subtask REMOVED when this repo has no
154
- * project it would check. One template, one subtraction: forking the file
155
- * would put two copies of the house task shape in the tree. */
156
- export function tasksTemplate(vars: Record<string, string>, withTsc: boolean): string {
157
- const body = template('vscode-tasks.json', vars)
158
- if (withTsc) return body
159
- const parsed = JSON.parse(body.replace(/^\s*\/\/.*$/gm, '')) as { version: string; tasks: Array<Record<string, unknown>> }
160
- parsed.tasks = parsed.tasks.filter((t) => t['label'] !== TSC_LABEL)
161
- for (const t of parsed.tasks) {
162
- const deps = t['dependsOn']
163
- if (Array.isArray(deps)) t['dependsOn'] = deps.filter((d) => d !== TSC_LABEL)
164
- }
165
- return JSON.stringify(parsed, null, 3) + '\n'
166
- }
167
-
168
- const TSC_LABEL = '👁 TSC'
169
-
170
- /** whether `tsc --project tsconfig.json` here would check anything at all. A
171
- * solution config (`files: []` plus `references`) needs `--build`, which the
172
- * house watcher does not pass, and answers zero files without it. */
173
- export function typecheckableProject(root: string): boolean {
174
- try {
175
- const raw = readFileSync(join(root, 'tsconfig.json'), 'utf8')
176
- const cfg = JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '')) as { files?: unknown[]; include?: unknown[]; references?: unknown[] }
177
- if ((cfg.references ?? []).length === 0) return true
178
- return (cfg.files ?? []).length > 0 || (cfg.include ?? []).length > 0
179
- } catch {
180
- return false
181
- }
152
+ /** the tasks template, the hub's problem matcher rendered in from the one
153
+ * module the hub prints through, so the two can never disagree */
154
+ export function tasksTemplate(vars: Record<string, string>): string {
155
+ return template('vscode-tasks.json', vars).replace(/^(\s*)(.*)\{\{HUB_PROBLEM_MATCHER\}\}/m, (_all, indent: string, head: string) =>
156
+ `${indent}${head}${JSON.stringify(hubProblemMatcher(), null, 3).split('\n').join(`\n${indent}`)}`)
182
157
  }
183
158
 
184
159
  export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
@@ -200,8 +175,6 @@ export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
200
175
 
201
176
  write('src/index.ts', 'export {}\n')
202
177
  write('tsconfig.json', template('tsconfig.json', vars))
203
- // tasks AFTER the tsconfig, so the TSC subtask is derived from the project
204
- // this run just wrote rather than from the empty dir it found
205
178
  ;(editorErgonomics(root, { name: opts.name, port }).length > 0 ? written : skipped).push('.vscode/tasks.json')
206
179
  // settings.json only here: it turns eslint off and hands formatting to an
207
180
  // oxc extension, which is a decision only the repo's creator gets to make
@@ -223,7 +196,7 @@ export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
223
196
  write('agent/changelog.md', template('agent-changelog.md', vars))
224
197
  // gitignored by policy, so it never ships: it exists so an agent hitting
225
198
  // friction with the toolchain finds the place to write it down
226
- write('.rv-reflections/README.md', REFLECTIONS_README)
199
+ write('.shipkit/reflections/README.md', REFLECTIONS_README)
227
200
 
228
201
  addScripts(root, written, p)
229
202
  addIgnoreLines(root, written)
@@ -247,7 +220,7 @@ function grantSummary(): string {
247
220
  }
248
221
  }
249
222
 
250
- const REFLECTIONS_README = `# .rv-reflections
223
+ const REFLECTIONS_README = `# .shipkit/reflections
251
224
 
252
225
  One file per friction with the shared toolchain: \`YYYYMMDD-<slug>.md\`.
253
226
  What broke, which page or lib, what you expected. Gitignored on purpose,
package/src/scan.ts ADDED
@@ -0,0 +1,98 @@
1
+ // A SHARED SCAN: work several checks need (every wiki page parsed, the root
2
+ // package.json), done once per runner pass and handed to each check that asks
3
+ // through `c.use(scan)`. The scan reads through its own context, and every
4
+ // read it makes is recorded into EVERY check that used it, so a cached answer
5
+ // built on a scan busts when anything the scan read moves.
6
+ //
7
+ // The NAME is the scan's identity inside a pass: two scans with one name share
8
+ // one value. A factory that takes an argument (mdPages below) puts the argument
9
+ // in the name and hands back one scan object per argument.
10
+ import type { ScanCtx } from './core/check-ctx.ts'
11
+
12
+ export type Scan<T> = {
13
+ /** unique per pass: the memo key */
14
+ readonly name: string
15
+ readonly run: (c: ScanCtx) => T | Promise<T>
16
+ }
17
+
18
+ /** THE way to declare a scan. It reads through `c` (read, json, exists,
19
+ * list, use) and reports nothing: a finding belongs to the check that asked. */
20
+ export function defineScan<T>(name: string, run: (c: ScanCtx) => T | Promise<T>): Scan<T> {
21
+ if (name.trim() === '') throw new Error('defineScan: a scan needs a name, it is the memo key of the pass')
22
+ return { name, run }
23
+ }
24
+
25
+ // ── built-in scans ───────────────────────────────────────────────────────
26
+
27
+ /** one markdown page, parsed */
28
+ export type MdPage = {
29
+ /** repo-relative */
30
+ path: string
31
+ /** the leading `---` block as flat `key: value` pairs. Nested yaml is out
32
+ * of scope: a value keeps the text after the first colon, trimmed */
33
+ frontmatter: Record<string, string>
34
+ /** the text after the frontmatter */
35
+ body: string
36
+ /** every markdown link target in the body, as written, anchors dropped.
37
+ * External (scheme or `//`), mailto and same-page anchors are left out */
38
+ links: string[]
39
+ }
40
+
41
+ const FRONT = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
42
+ const LINK = /\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g
43
+
44
+ export function parseMdPage(path: string, text: string): MdPage {
45
+ const m = FRONT.exec(text)
46
+ const frontmatter: Record<string, string> = {}
47
+ if (m != null)
48
+ for (const line of m[1]!.split(/\r?\n/)) {
49
+ const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
50
+ if (kv != null) frontmatter[kv[1]!] = kv[2]!.trim()
51
+ }
52
+ const body = m == null ? text : text.slice(m[0].length)
53
+ const links: string[] = []
54
+ for (const l of body.matchAll(LINK)) {
55
+ const target = l[1]!.split('#')[0]!
56
+ if (target === '' || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//')) continue
57
+ links.push(target)
58
+ }
59
+ return { path, frontmatter, body, links }
60
+ }
61
+
62
+ const pagesByDir = new Map<string, Scan<MdPage[]>>()
63
+
64
+ /** every `*.md` git sees under `dir` (repo-relative, `.` for the whole repo),
65
+ * parsed, sorted by path */
66
+ export function mdPages(dir: string): Scan<MdPage[]> {
67
+ const d = dir.replace(/\/+$/, '').replace(/^\.\//, '')
68
+ let s = pagesByDir.get(d)
69
+ if (s == null) {
70
+ const glob = d === '' || d === '.' ? '**/*.md' : `${d}/**/*.md`
71
+ s = defineScan(`mdPages:${d === '' ? '.' : d}`, (c) => c.list(glob).map((p) => parseMdPage(p, c.read(p) ?? '')))
72
+ pagesByDir.set(d, s)
73
+ }
74
+ return s
75
+ }
76
+
77
+ /** the fields of a package.json the built-in checks read */
78
+ export type PackageJson = {
79
+ name?: string
80
+ version?: string
81
+ private?: boolean
82
+ license?: string
83
+ scripts?: Record<string, string>
84
+ dependencies?: Record<string, string>
85
+ devDependencies?: Record<string, string>
86
+ peerDependencies?: Record<string, string>
87
+ optionalDependencies?: Record<string, string>
88
+ publishConfig?: unknown
89
+ [k: string]: unknown
90
+ }
91
+
92
+ const rootPackageJson = defineScan<PackageJson | null>('packageJson', (c) => c.json<PackageJson>('package.json'))
93
+
94
+ /** the repo root package.json, parsed. null when there is none, and an
95
+ * unparseable one throws naming the file */
96
+ export function packageJson(): Scan<PackageJson | null> {
97
+ return rootPackageJson
98
+ }
@@ -0,0 +1,148 @@
1
+ // when the hub runs the quick gate on its own: once at start, then after the
2
+ // tracked files have been quiet for QUIET_MS. The trigger only STARTS ordinary
3
+ // gate runs. Every answer comes out of the one runner, measured or replayed
4
+ // from the ledger like any other run, so nothing here owns a verdict: a missed
5
+ // file event costs a stale problems pane until the next save, never a green.
6
+ //
7
+ // never two runs at once: a request during a run queues exactly one more, run
8
+ // after it ends, however many saves landed meanwhile.
9
+ import { watch, type FSWatcher } from 'fs'
10
+ import { GENERATED_IGNORE_LINES } from '../rules/untrack-generated.ts'
11
+ import { gitBin } from '../core/git-bin.ts'
12
+ import { LAYOUT } from '../core/layout.ts'
13
+
14
+ export const QUIET_MS = 2000
15
+
16
+ export type Timers = {
17
+ set: (fn: () => void, ms: number) => unknown
18
+ clear: (handle: unknown) => void
19
+ }
20
+
21
+ const realTimers: Timers = {
22
+ set: (fn, ms) => setTimeout(fn, ms),
23
+ clear: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
24
+ }
25
+
26
+ export class GateTrigger {
27
+ private timer: unknown = null
28
+ private running: Promise<void> | null = null
29
+ private pending = false
30
+ private readonly timers: Timers
31
+ private readonly quietMs: number
32
+
33
+ constructor(private readonly o: { run: () => Promise<unknown>; quietMs?: number; timers?: Timers; onError?: (e: Error) => void }) {
34
+ this.timers = o.timers ?? realTimers
35
+ this.quietMs = o.quietMs ?? QUIET_MS
36
+ }
37
+
38
+ /** a tracked file moved: the run starts once nothing has moved for the
39
+ * quiet window, so a save burst is one run */
40
+ touched(): void {
41
+ if (this.timer != null) this.timers.clear(this.timer)
42
+ this.timer = this.timers.set(() => {
43
+ this.timer = null
44
+ this.request()
45
+ }, this.quietMs)
46
+ }
47
+
48
+ /** run now, or once more after the run in flight */
49
+ request(): void {
50
+ if (this.running != null) { this.pending = true; return }
51
+ this.running = (async () => {
52
+ do {
53
+ this.pending = false
54
+ try { await this.o.run() } catch (e) { this.o.onError?.(e instanceof Error ? e : new Error(String(e))) }
55
+ } while (this.pending)
56
+ })().finally(() => { this.running = null })
57
+ }
58
+
59
+ /** resolves when no run is in flight or queued */
60
+ async idle(): Promise<void> {
61
+ while (this.running != null) await this.running
62
+ }
63
+
64
+ stop(): void {
65
+ if (this.timer != null) this.timers.clear(this.timer)
66
+ this.timer = null
67
+ }
68
+ }
69
+
70
+ /** every path a run of the gate or the hub itself writes, whatever the repo
71
+ * ignores: the status files, the per run logs under the scratch dir, an atomic write's
72
+ * temp file, the comfy state the hub writes at boot. A save there is the
73
+ * machine's own, and triggering on it would schedule the next run forever */
74
+ export const GATE_WRITES: readonly string[] = [
75
+ ...GENERATED_IGNORE_LINES.filter((l) => !l.startsWith('#')),
76
+ `${LAYOUT.tmp}/`,
77
+ '.*.tmp-*',
78
+ '.comfy-ts/',
79
+ ]
80
+
81
+ const globRe = (glob: string): RegExp => new RegExp(`^${glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')}$`)
82
+ const WRITE_RULES = GATE_WRITES.map((g) => (g.endsWith('/') ? { dir: g, re: null } : { dir: null, re: globRe(g) }))
83
+
84
+ /** true for a path the gate writes (GATE_WRITES), matched on its basename for
85
+ * a file pattern and on its prefix for a directory. A folder the gate creates
86
+ * on the way (`.shipkit` for `.shipkit/tmp/`) is one of its writes too: an
87
+ * owner file inside it fires its own event */
88
+ export function isGateWrite(rel: string): boolean {
89
+ const base = rel.split('/').at(-1) ?? rel
90
+ return WRITE_RULES.some((r) => (r.dir != null ? r.dir.startsWith(`${rel}/`) || rel.startsWith(r.dir) : r.re!.test(base)))
91
+ }
92
+
93
+ /** which of these repo-relative paths git ignores. A tracked file is never
94
+ * reported, whatever the ignore rules say */
95
+ export async function gitIgnored(root: string, paths: string[]): Promise<Set<string>> {
96
+ if (paths.length === 0) return new Set()
97
+ try {
98
+ const p = Bun.spawn([gitBin(), 'check-ignore', '--stdin', '-z'], { cwd: root, stdin: 'pipe', stdout: 'pipe', stderr: 'ignore' })
99
+ p.stdin.write(paths.join('\0') + '\0')
100
+ await p.stdin.end()
101
+ const out = await new Response(p.stdout).text()
102
+ await p.exited
103
+ return new Set(out.split('\0').filter((s) => s !== ''))
104
+ } catch {
105
+ return new Set()
106
+ }
107
+ }
108
+
109
+ /** calls onChange whenever a file the owner edits moves under root: tracked,
110
+ * or untracked and not ignored. What the gate itself writes (GATE_WRITES,
111
+ * the ledger in .git) never counts, ignored or not, so a run never triggers
112
+ * the next one */
113
+ export function watchTracked(root: string, onChange: () => void, o: { ignored?: (paths: string[]) => Promise<Set<string>>; onError?: (e: Error) => void } = {}): { close: () => void; error: string | null } {
114
+ const ignored = o.ignored ?? ((paths: string[]) => gitIgnored(root, paths))
115
+ const pending = new Set<string>()
116
+ let flushing = false
117
+ const flush = async (): Promise<void> => {
118
+ if (flushing) return
119
+ flushing = true
120
+ try {
121
+ while (pending.size > 0) {
122
+ const batch = [...pending]
123
+ pending.clear()
124
+ const skip = await ignored(batch)
125
+ if (batch.some((p) => !skip.has(p))) onChange()
126
+ }
127
+ } finally {
128
+ flushing = false
129
+ }
130
+ }
131
+ let w: FSWatcher | null = null
132
+ let error: string | null = null
133
+ try {
134
+ w = watch(root, { recursive: true }, (_event, name) => {
135
+ if (name == null) return onChange()
136
+ const rel = String(name).replace(/\\/g, '/')
137
+ if (rel === '.git' || rel.startsWith('.git/') || rel.split('/').includes('node_modules') || isGateWrite(rel)) return
138
+ pending.add(rel)
139
+ void flush()
140
+ })
141
+ // a watcher that dies mid session (the tree was removed, a limit hit)
142
+ // says so, rather than crashing the hub or going quiet
143
+ w.on('error', (e) => o.onError?.(e instanceof Error ? e : new Error(String(e))))
144
+ } catch (e) {
145
+ error = e instanceof Error ? e.message : String(e)
146
+ }
147
+ return { close: () => w?.close(), error }
148
+ }
@@ -0,0 +1,102 @@
1
+ // THE HEALTH PASS TIMER. While the hub serves, the whole health pass runs once
2
+ // a day, and once at startup when the last whole pass on record is older than
3
+ // that. One timer for the repo, no per check schedule: a check's own `ttl` is
4
+ // its cadence, and this pass is only the heartbeat that replays an answer
5
+ // still fresh and re-measures one that expired.
6
+ //
7
+ // The run is the hub's own in process runner, the same path as the button, so
8
+ // its rows land in the same run record. "When was the last pass" is read off
9
+ // that record, which is what makes a `shipkit check` in a terminal count too.
10
+
11
+ /** how often the health pass runs while the hub serves */
12
+ export const HEALTH_EVERY_MS = 24 * 60 * 60 * 1000
13
+
14
+ /** how often the timer looks. The pass itself is daily, this only bounds how
15
+ * late after its due time it starts */
16
+ export const SCHEDULE_LOOK_MS = 5 * 60 * 1000
17
+
18
+ export type ScheduleState = {
19
+ everyMs: number
20
+ /** when the last whole health pass on record started, null = none */
21
+ lastPassAt: number | null
22
+ /** when the next one starts: now when it is overdue */
23
+ nextAt: number
24
+ /** the timer's own pass is running right now */
25
+ running: boolean
26
+ }
27
+
28
+ export type ScheduleDeps = {
29
+ now: () => number
30
+ /** the last whole health pass on record, whoever ran it */
31
+ lastPassAt: () => number | null
32
+ /** another run in this process is going: the timer never stacks on it */
33
+ busy: () => boolean
34
+ /** the pass itself */
35
+ run: () => Promise<void>
36
+ everyMs?: number
37
+ }
38
+
39
+ export type TickResult = 'ran' | 'fresh' | 'busy'
40
+
41
+ export class HealthSchedule {
42
+ readonly everyMs: number
43
+ running = false
44
+ /** when this timer last STARTED a pass. A pass that dies before it writes
45
+ * a record would otherwise be due again at the very next look */
46
+ private attemptedAt: number | null = null
47
+ private timer: ReturnType<typeof setInterval> | null = null
48
+
49
+ constructor(private readonly d: ScheduleDeps) {
50
+ this.everyMs = d.everyMs ?? HEALTH_EVERY_MS
51
+ }
52
+
53
+ /** the newest of the record and this timer's own last attempt */
54
+ private lastAt(): number | null {
55
+ const rec = this.d.lastPassAt()
56
+ if (rec == null) return this.attemptedAt
57
+ return this.attemptedAt == null ? rec : Math.max(rec, this.attemptedAt)
58
+ }
59
+
60
+ due(): boolean {
61
+ const last = this.lastAt()
62
+ return last == null || this.d.now() - last >= this.everyMs
63
+ }
64
+
65
+ state(): ScheduleState {
66
+ const now = this.d.now()
67
+ const last = this.lastAt()
68
+ return {
69
+ everyMs: this.everyMs,
70
+ lastPassAt: this.d.lastPassAt(),
71
+ nextAt: last == null ? now : Math.max(now, last + this.everyMs),
72
+ running: this.running,
73
+ }
74
+ }
75
+
76
+ /** one look: run the pass when it is due and nothing else is running */
77
+ async tick(): Promise<TickResult> {
78
+ if (this.running || this.d.busy()) return 'busy'
79
+ if (!this.due()) return 'fresh'
80
+ this.running = true
81
+ this.attemptedAt = this.d.now()
82
+ try {
83
+ await this.d.run()
84
+ } finally {
85
+ this.running = false
86
+ }
87
+ return 'ran'
88
+ }
89
+
90
+ /** look now, then every SCHEDULE_LOOK_MS. A second start is a no-op */
91
+ start(onError: (e: Error) => void, lookMs = SCHEDULE_LOOK_MS): void {
92
+ if (this.timer != null) return
93
+ const look = (): void => void this.tick().catch((e: Error) => onError(e))
94
+ look()
95
+ this.timer = setInterval(look, lookMs)
96
+ }
97
+
98
+ stop(): void {
99
+ if (this.timer != null) clearInterval(this.timer)
100
+ this.timer = null
101
+ }
102
+ }