rvlib-shipkit 0.38.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +371 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +216 -18
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,292 @@
1
+ // the comfy section's backend: the repo's .shipkit/comfy/ workflow modules, the
2
+ // comfy-ts serve panel the hub embeds to tune and test one, and the box those
3
+ // modules generate on. The contract this reads is owned by build/comfyui.md.
4
+ //
5
+ // The panel is a CHILD PROCESS rather than a route: comfy-ts owns that ui, and
6
+ // a second implementation of a var form inside the hub is the redundant
7
+ // mechanism that doctrine bans.
8
+ import { existsSync, readdirSync, readFileSync } from 'fs'
9
+ import { join, resolve } from 'path'
10
+ import { defaultHubPort } from '../serve/hub-port.ts'
11
+
12
+ export type ComfyWorkflow = { id: string; file: string }
13
+ export type ComfyPanelUp = { url: string; port: number }
14
+ export type ComfyBox = { url: string; err: string | null }
15
+
16
+ const SUFFIX = '.cflow.ts'
17
+ /** the wm facts (build/comfyui.md) — an absent key defaults to them */
18
+ const DEFAULT_BOX_PORT = 8085
19
+
20
+ export const comfyWorkflowsDir = (root: string): string => join(root, '.shipkit/comfy', 'workflows')
21
+
22
+ /** every workflow module, sorted. `_host.ts` carries no `.cflow` suffix on
23
+ * purpose, so this scan and the comfy-ts one see the same list */
24
+ export function scanComfyWorkflows(root: string): ComfyWorkflow[] {
25
+ const dir = comfyWorkflowsDir(root)
26
+ if (!existsSync(dir)) return []
27
+ return readdirSync(dir)
28
+ .filter((f) => f.endsWith(SUFFIX))
29
+ .sort()
30
+ .map((f) => ({ id: f.slice(0, -SUFFIX.length), file: join(dir, f) }))
31
+ }
32
+
33
+ /** the box url .shipkit/comfy/config.json points at. An unreadable config reports
34
+ * its own message: probing a default the repo never declared would answer a
35
+ * question nobody asked */
36
+ export function comfyBox(root: string): ComfyBox {
37
+ const p = join(root, '.shipkit/comfy', 'config.json')
38
+ const url = (port: number): string => `http://127.0.0.1:${port}`
39
+ if (!existsSync(p)) return { url: url(DEFAULT_BOX_PORT), err: null }
40
+ try {
41
+ const cfg = JSON.parse(readFileSync(p, 'utf8')) as { port?: number }
42
+ return { url: url(typeof cfg.port === 'number' ? cfg.port : DEFAULT_BOX_PORT), err: null }
43
+ } catch (e) {
44
+ return { url: url(DEFAULT_BOX_PORT), err: `.shipkit/comfy/config.json is unreadable (${(e as Error).message}), falling back to port ${DEFAULT_BOX_PORT}` }
45
+ }
46
+ }
47
+
48
+ /** is the box answering? read-only, and the ONE raw fetch this file makes */
49
+ export async function comfyBoxUp(url: string): Promise<boolean> {
50
+ return await fetch(`${url}/system_stats`, { signal: AbortSignal.timeout(2500) })
51
+ .then((r) => r.ok)
52
+ .catch(() => false)
53
+ }
54
+
55
+ /** one house range above the hub's, from the same hash, so a repo whose hub
56
+ * never collides never collides here either */
57
+ export const defaultComfyPanelPort = (name: string): number => defaultHubPort(name) + 1000
58
+
59
+ /** what `GET /` on a comfy-ts serve answers, narrowed to the identity fields */
60
+ export type PanelIndex = { server?: string; workflows?: { module?: string; file?: string }[] }
61
+
62
+ /** why a server already on the panel port cannot be adopted. `stale` is a panel
63
+ * of OURS that booted before a module was written, which a restart fixes;
64
+ * `foreign` is another repo's panel or another program, which only another
65
+ * port fixes. Telling the two apart is the whole point: the two cures have
66
+ * nothing in common */
67
+ export type PanelVerdict = { ok: true } | { ok: false; kind: 'foreign' } | { ok: false; kind: 'stale'; missing: string[] }
68
+
69
+ /** does the server on that port serve OUR modules? The FILE paths are the
70
+ * identity: two repos both serving a 'txt2img-cutout' is the normal case, and
71
+ * adopting the wrong one would tune another repo's graph */
72
+ export function panelIdentity(index: PanelIndex, want: ComfyWorkflow[]): PanelVerdict {
73
+ if (index.server !== 'comfy-ts serve' || want.length === 0) return { ok: false, kind: 'foreign' }
74
+ const served = new Set((index.workflows ?? []).map((w) => resolve(w.file ?? '')))
75
+ const missing = want.filter((w) => !served.has(resolve(w.file)))
76
+ if (missing.length === 0) return { ok: true }
77
+ // not ONE file of ours: another repo serving the same module names
78
+ if (missing.length === want.length) return { ok: false, kind: 'foreign' }
79
+ return { ok: false, kind: 'stale', missing: missing.map((w) => w.id) }
80
+ }
81
+
82
+ export const panelServesOurs = (index: PanelIndex, want: ComfyWorkflow[]): boolean => panelIdentity(index, want).ok
83
+
84
+ /** the refusal a reader can act on: the stale one names the modules the live
85
+ * panel never imported, since changing the port there fixes nothing */
86
+ export function panelRefusal(port: number, v: Extract<PanelVerdict, { ok: false }>): string {
87
+ if (v.kind === 'foreign') return `port ${port} is taken by another server — set serve: { comfyPort } in repo.config.ts to a free one`
88
+ return `the panel on port ${port} booted before ${v.missing.join(', ')} existed, and comfy-ts serve imports every module at boot. Use restart panel in the header to pick it up`
89
+ }
90
+
91
+ async function probeIndex(url: string, ms = 1500): Promise<PanelIndex | null> {
92
+ return await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(ms) })
93
+ .then((r) => (r.ok ? (r.json() as Promise<PanelIndex>) : null))
94
+ .catch(() => null)
95
+ }
96
+
97
+ /** does `GET /` answer a BROWSER with a ui? comfy-ts serves its web bundle from
98
+ * 2.5 on, and an older one answers the json index to every request, this one
99
+ * included. The content type is the whole answer: the index probe above is
100
+ * green on both, so a panel adopted on identity alone can be a wall of json in
101
+ * the frame */
102
+ async function probeWebShell(url: string, ms = 1500): Promise<boolean> {
103
+ return await fetch(url, { headers: { accept: 'text/html' }, signal: AbortSignal.timeout(ms) })
104
+ .then((r) => r.ok && (r.headers.get('content-type') ?? '').toLowerCase().includes('text/html'))
105
+ .catch(() => false)
106
+ }
107
+
108
+ /** the comfy-ts the repo would actually run, for the refusal to name */
109
+ export function installedComfyTsVersion(root: string): string {
110
+ try {
111
+ const raw = readFileSync(join(root, 'node_modules', 'comfy-ts', 'package.json'), 'utf8')
112
+ return (JSON.parse(raw) as { version?: string }).version ?? 'unknown'
113
+ } catch {
114
+ return 'unknown'
115
+ }
116
+ }
117
+
118
+ export const noWebPanelRefusal = (version: string): string => `comfy-ts ${version} serves no web panel (needs 2.5 or later), run: bun add comfy-ts@latest`
119
+
120
+ const LOG_LINES = 40
121
+ // short steps, same ceilings (30s to boot, 2s to die, 3s to go quiet): a
122
+ // panel that is up in 80ms is not left waiting for the rest of a long step
123
+ const BOOT_TRIES = 600
124
+ const BOOT_STEP_MS = 50
125
+ const KILL_TRIES = 80
126
+ const KILL_STEP_MS = 25
127
+ const QUIET_TRIES = 60
128
+ const QUIET_STEP_MS = 50
129
+
130
+ /** the pids LISTENING on a tcp port, on darwin and linux. `-sTCP:LISTEN`
131
+ * carries the whole answer: `-i tcp:<port>` alone also matches every client
132
+ * connected to it, this process included, and those must never be killed.
133
+ * lsof sits in /usr/sbin on darwin, which a gui-launched shell can miss */
134
+ function portHolders(port: number): number[] {
135
+ const lsof = Bun.which('lsof') ?? ['/usr/sbin/lsof', '/usr/bin/lsof'].find((p) => existsSync(p))
136
+ if (lsof == null) return []
137
+ const r = Bun.spawnSync([lsof, '-ti', `tcp:${port}`, '-sTCP:LISTEN'], { stdout: 'pipe', stderr: 'ignore' })
138
+ const pids = r.stdout
139
+ .toString()
140
+ .split('\n')
141
+ .map((l) => Number(l.trim()))
142
+ .filter((n) => Number.isInteger(n) && n > 0)
143
+ return [...new Set(pids)]
144
+ }
145
+
146
+ /** a pid that died between the scan and the signal is the outcome we wanted */
147
+ function kill(pid: number, signal: 'SIGTERM' | 'SIGKILL'): void {
148
+ try {
149
+ process.kill(pid, signal)
150
+ } catch {
151
+ return
152
+ }
153
+ }
154
+
155
+ /** the panel process, started on the first ask and shared by every later one.
156
+ * Adopting a live one matters more than owning it: `bun --watch` restarts the
157
+ * hub on every lib edit, and the child it spawned outlives that restart */
158
+ export class ComfyPanel {
159
+ private proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'> | null = null
160
+ private starting: Promise<ComfyPanelUp> | null = null
161
+ private log: string[] = []
162
+
163
+ constructor(private o: { root: string; port: number }) {}
164
+
165
+ get url(): string {
166
+ return `http://127.0.0.1:${this.o.port}`
167
+ }
168
+
169
+ async ensure(): Promise<ComfyPanelUp> {
170
+ const want = scanComfyWorkflows(this.o.root)
171
+ if (want.length === 0) throw new Error(`no ${SUFFIX} module under .shipkit/comfy/workflows`)
172
+ const live = await probeIndex(this.url)
173
+ if (live != null) {
174
+ const v = panelIdentity(live, want)
175
+ if (!v.ok) throw new Error(panelRefusal(this.o.port, v))
176
+ await this.requireWebShell()
177
+ return { url: this.url, port: this.o.port }
178
+ }
179
+ if (this.proc != null && this.proc.exitCode != null) this.proc = null
180
+ this.starting ??= this.spawn().finally(() => {
181
+ this.starting = null
182
+ })
183
+ return await this.starting
184
+ }
185
+
186
+ stop(): void {
187
+ this.proc?.kill()
188
+ this.proc = null
189
+ }
190
+
191
+ /** comfy-ts serve imports the workflow modules at BOOT, so an edited module
192
+ * reaches the panel only through a new process. An ADOPTED panel is the
193
+ * common case and there is no child to kill, so the port holder is killed
194
+ * by pid: without that, ensure() re-adopts the same process and the
195
+ * restart is the no-op the wait below exists to prevent. The wait matters:
196
+ * a killed server still answers for a beat */
197
+ async restart(): Promise<ComfyPanelUp> {
198
+ const dying = this.proc
199
+ this.stop()
200
+ if (dying != null) await Promise.race([dying.exited, Bun.sleep(3000)])
201
+ else await this.killAdopted()
202
+ for (let i = 0; i < QUIET_TRIES && (await probeIndex(this.url, 400)) != null; i++) await Bun.sleep(QUIET_STEP_MS)
203
+ return await this.ensure()
204
+ }
205
+
206
+ /** the live panel nobody here spawned. Identity is verified AGAIN before
207
+ * anything dies, because a foreign server on that port is another
208
+ * program's process, and a port nothing listens on is said out loud
209
+ * rather than restarted into the same old panel */
210
+ private async killAdopted(): Promise<void> {
211
+ const live = await probeIndex(this.url)
212
+ if (live == null) return
213
+ const v = panelIdentity(live, scanComfyWorkflows(this.o.root))
214
+ if (!v.ok && v.kind === 'foreign') throw new Error(panelRefusal(this.o.port, v))
215
+ const pids = portHolders(this.o.port).filter((pid) => pid !== process.pid)
216
+ if (pids.length === 0)
217
+ throw new Error(
218
+ `the panel on port ${this.o.port} cannot be restarted: no other process is listening there, so nothing to kill. Kill it by hand (lsof -ti tcp:${this.o.port} | xargs kill) and open the panel again`,
219
+ )
220
+ for (const pid of pids) kill(pid, 'SIGTERM')
221
+ for (let i = 0; i < KILL_TRIES && portHolders(this.o.port).some((p) => pids.includes(p)); i++) await Bun.sleep(KILL_STEP_MS)
222
+ for (const pid of portHolders(this.o.port).filter((p) => pids.includes(p))) kill(pid, 'SIGKILL')
223
+ }
224
+
225
+ /** nothing is mounted on a panel that cannot answer a browser: the frame
226
+ * would show the json index and the chip would sit on starting forever */
227
+ private async requireWebShell(): Promise<void> {
228
+ if (await probeWebShell(this.url)) return
229
+ throw new Error(noWebPanelRefusal(installedComfyTsVersion(this.o.root)))
230
+ }
231
+
232
+ private tail(): string {
233
+ return this.log.length === 0 ? '(it printed nothing)' : this.log.join('\n')
234
+ }
235
+
236
+ private async spawn(): Promise<ComfyPanelUp> {
237
+ // the REPO's own copy: a workflow module imports the bare 'comfy-ts'
238
+ // specifier, so the engine running it must be the one it resolves
239
+ const bin = join(this.o.root, 'node_modules', '.bin', 'comfy-ts')
240
+ if (!existsSync(bin)) throw new Error(`comfy-ts is not installed in this repo — run: bun add comfy-ts`)
241
+ this.log = []
242
+ // process.execPath is bun: a .cflow.ts module is TypeScript and _host.ts
243
+ // reads import.meta.dir, so node loads neither
244
+ const proc = Bun.spawn([process.execPath, bin, 'serve', comfyWorkflowsDir(this.o.root), '--port', String(this.o.port)], {
245
+ cwd: this.o.root,
246
+ stdin: 'ignore',
247
+ stdout: 'pipe',
248
+ stderr: 'pipe',
249
+ })
250
+ this.proc = proc
251
+ void this.drain(proc.stdout)
252
+ void this.drain(proc.stderr)
253
+ for (let i = 0; i < BOOT_TRIES; i++) {
254
+ if (proc.exitCode != null) throw new Error(`comfy-ts serve exited (${proc.exitCode}):\n${this.tail()}`)
255
+ const idx = await probeIndex(this.url, 800)
256
+ if (idx != null) {
257
+ // a panel too old to serve a ui is killed rather than left
258
+ // holding the port for the next ensure() to adopt, and the
259
+ // refusal waits for the exit: a killed server still answers
260
+ // for a beat
261
+ await this.requireWebShell().catch(async (e: Error) => {
262
+ proc.kill()
263
+ this.proc = null
264
+ await Promise.race([proc.exited, Bun.sleep(3000)])
265
+ throw e
266
+ })
267
+ return { url: this.url, port: this.o.port }
268
+ }
269
+ await Bun.sleep(BOOT_STEP_MS)
270
+ }
271
+ proc.kill()
272
+ this.proc = null
273
+ throw new Error(`comfy-ts serve never answered on ${this.url}:\n${this.tail()}`)
274
+ }
275
+
276
+ /** the child's output is a startup wall of routes on success and the only
277
+ * account of the failure otherwise, so the tail rides the error message
278
+ * instead of the hub's terminal */
279
+ private async drain(s: ReadableStream<Uint8Array>): Promise<void> {
280
+ const dec = new TextDecoder()
281
+ let buf = ''
282
+ for await (const chunk of s as unknown as AsyncIterable<Uint8Array>) {
283
+ buf += dec.decode(chunk, { stream: true })
284
+ const lines = buf.split('\n')
285
+ buf = lines.pop() ?? ''
286
+ for (const l of lines) {
287
+ this.log.push(l)
288
+ if (this.log.length > LOG_LINES) this.log.shift()
289
+ }
290
+ }
291
+ }
292
+ }
@@ -1,19 +1,18 @@
1
1
  // The ONE bridge from the contracts engine into wire output — every surface
2
2
  // that reports contract fulfillness (`shipkit check` via rule SK002, the ci
3
- // `contracts` task, `shipkit contracts`) goes through applyContractsResult,
3
+ // `contracts` check, `shipkit contracts`) goes through applyContractsResult,
4
4
  // so tags, the empty-repo sentence, and severity plumbing exist exactly
5
5
  // once. Kinds pass through raw so fix pointers and visible exempts survive
6
6
  // every printer.
7
- import { existsSync } from 'fs'
8
- import { join } from 'path'
9
- import type { CheckCtx } from '../core/check-ctx.ts'
7
+ import type { CheckCtx, ScanCtx } from '../core/check-ctx.ts'
10
8
  import type { RepoConfig } from '../repo-config.ts'
11
- import { tagRuleLines, type Rule } from '../rules/rule.ts'
9
+ import { tagCheckLines } from '../check.ts'
10
+ import { defineScan } from '../scan.ts'
12
11
  import { allContracts } from './contract-manifests.ts'
13
12
  import type { ContractManifest } from './contract-types.ts'
14
- import type { ContractsConfig, ContractsResult } from './contracts-engine.ts'
13
+ import { evaluateContracts, type ContractsConfig, type ContractsResult } from './contracts-engine.ts'
15
14
 
16
- export function contractsConfigFrom(c: RepoConfig, root: string): ContractsConfig {
15
+ export function contractsConfigFrom(c: RepoConfig, d: Pick<ScanCtx, 'root' | 'exists'>): ContractsConfig {
17
16
  const venues = c.venues ?? []
18
17
  const social = c.social ?? []
19
18
  const declared = [
@@ -22,10 +21,10 @@ export function contractsConfigFrom(c: RepoConfig, root: string): ContractsConfi
22
21
  ...(social.includes('discord') ? ['discord'] : []),
23
22
  ]
24
23
  return {
25
- root,
24
+ root: d.root,
26
25
  declared,
27
26
  social,
28
- publicSurface: venues.length > 0 || existsSync(join(root, 'deploy.sh')),
27
+ publicSurface: venues.length > 0 || d.exists('deploy.sh'),
29
28
  exempt: c.contracts?.exempt,
30
29
  docsBase: c.docsBase,
31
30
  }
@@ -35,13 +34,19 @@ export function activeContracts(c: RepoConfig): ContractManifest[] {
35
34
  return [...allContracts, ...(c.contracts?.extra ?? [])]
36
35
  }
37
36
 
38
- export function applyContractsResult(t: CheckCtx, r: ContractsResult, tag?: Rule): void {
39
- const lines = tag != null ? tagRuleLines(tag, r.lines) : r.lines
37
+ /** ONE evaluation per run, shared by every check that reports fulfillness
38
+ * (SK002 and `contracts`), each of them cached on what it read */
39
+ export const contractsResult = defineScan<ContractsResult>('contracts', (c) =>
40
+ evaluateContracts(activeContracts(c.config), contractsConfigFrom(c.config, c), c),
41
+ )
42
+
43
+ export function applyContractsResult(t: CheckCtx, r: ContractsResult, tag?: { id?: string; fix?: unknown }): void {
44
+ const lines = tag?.id != null ? tagCheckLines({ id: tag.id, fix: tag.fix }, r.lines) : r.lines
40
45
  for (const l of lines) t.lines.push(l)
41
46
  if (r.exitCode === 1) t.failed = true
42
47
  if (r.lines.some((l) => l.kind === 'warn')) t.warned = true
43
48
  if (r.lines.length === 0) {
44
49
  const text = 'no contracts apply (nothing declared, nothing detected)'
45
- t.ok(tag != null ? `[${tag.id}] ${text}` : text)
50
+ t.ok(tag?.id != null ? `[${tag.id}] ${text}` : text)
46
51
  }
47
52
  }
@@ -31,15 +31,27 @@ export type ContractEnv = {
31
31
  venues: string[]
32
32
  social: string[]
33
33
  exists: (relPath: string) => boolean
34
+ /** the text of a repo-relative file, null when none. A probe reads the
35
+ * repo through this and `exists`, so a check reporting it is cached on
36
+ * what the probe looked at: reaching the disk around them is what keeps
37
+ * that check uncached (core/raw-access.ts) */
38
+ read: (relPath: string) => string | null
34
39
  }
35
40
 
36
41
  export type ContractManifest = {
37
- venue: string // kebab id; the contract folder is `.rv-<venue>/`
42
+ /** kebab id of the venue, and the contract folder is `.shipkit/<venue>/` */
43
+ venue: string
44
+ /** the contract folder, `.shipkit/<venue>`, checked against the venue id */
38
45
  dir: string
39
- owner: string // docs path suffix, rendered after the consumer-configured docsBase in fix: lines
46
+ /** docs path suffix, rendered after the consumer-configured docsBase in fix: lines */
47
+ owner: string
48
+ /** `declared` = required once the repo names this venue. `always` = every repo, behind `gate` */
40
49
  appliesTo: 'declared' | 'always'
41
- gate?: 'public-surface' // 'always' contracts: FAIL once a public surface exists, WARN before
50
+ /** `always` contracts only: FAIL once a public surface exists, WARN before */
51
+ gate?: 'public-surface'
52
+ /** signals that this venue is in play although the repo never declared it, so config cannot lie */
42
53
  detect: Detector[]
54
+ /** the files the folder must carry. A contract with an empty list is prose */
43
55
  artifacts: ArtifactRule[]
44
56
  }
45
57
 
@@ -57,7 +69,7 @@ export const dirExists = (path: string): Detector => ({ kind: 'dirExists', path
57
69
  export function validateManifest(m: ContractManifest): string[] {
58
70
  const errs: string[] = []
59
71
  if (!/^[a-z0-9-]+$/.test(m.venue)) errs.push(`venue '${m.venue}' must be kebab-case [a-z0-9-]`)
60
- if (m.dir !== `.rv-${m.venue}`) errs.push(`dir '${m.dir}' must be '.rv-${m.venue}' (folder naming rule)`)
72
+ if (m.dir !== `.shipkit/${m.venue}`) errs.push(`dir '${m.dir}' must be '.shipkit/${m.venue}' (folder naming rule)`)
61
73
  if (m.artifacts.length === 0) errs.push('artifacts must not be empty — a contract with nothing to check is prose, not a contract')
62
74
  if (m.appliesTo === 'declared' && m.detect.length === 0) errs.push(`appliesTo 'declared' needs >=1 detector — otherwise an undeclared venue can never be caught`)
63
75
  if (m.gate != null && m.appliesTo !== 'always') errs.push(`gate '${m.gate}' only applies to 'always' contracts`)
@@ -7,11 +7,16 @@
7
7
  // - 'always' contracts with gate 'public-surface': full severity once the
8
8
  // repo has a public surface, everything demoted to WARN before.
9
9
  // Every FAIL carries a fix: pointer = docsBase + manifest.owner.
10
- import { existsSync, readFileSync, statSync } from 'fs'
11
- import { join } from 'path'
10
+ //
11
+ // It reads ONLY through the disk it is handed (a check context), so what a
12
+ // verdict rests on is recorded, and a check reporting it is cached on that.
13
+ import type { ScanCtx } from '../core/check-ctx.ts'
12
14
  import type { ArtifactRule, ContractEnv, ContractManifest } from './contract-types.ts'
13
15
  import type { Line } from '../core/wire.ts'
14
16
 
17
+ /** what the engine reads through: a check context, or a shared scan's */
18
+ export type ContractDisk = Pick<ScanCtx, 'read' | 'bytes' | 'exists' | 'entries'>
19
+
15
20
  export type ContractsConfig = {
16
21
  root: string
17
22
  /** venue ids the repo declares it ships on (config side of both-ways drift) */
@@ -31,35 +36,67 @@ export type ContractsResult = { lines: Line[]; exitCode: 0 | 1; outcomes: Contra
31
36
 
32
37
  // PNG dims from the IHDR header — bytes 16..23 big-endian, after the
33
38
  // 8-byte signature + IHDR length/type. No image dependency.
34
- export function pngDims(path: string): { width: number; height: number } | null {
35
- try {
36
- const fd = readFileSync(path)
37
- if (fd.length < 24) return null
38
- const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
39
- for (let i = 0; i < sig.length; i++) if (fd[i] !== sig[i]) return null
40
- return { width: fd.readUInt32BE(16), height: fd.readUInt32BE(20) }
41
- } catch {
42
- return null
39
+ export function pngDims(bytes: Uint8Array | null): { width: number; height: number } | null {
40
+ if (bytes == null || bytes.length < 24) return null
41
+ const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
42
+ for (let i = 0; i < sig.length; i++) if (bytes[i] !== sig[i]) return null
43
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
44
+ return { width: view.getUint32(16), height: view.getUint32(20) }
45
+ }
46
+
47
+ const WILD = /[*?[{]/
48
+
49
+ /** the files matching a repo-relative glob, walked one dir at a time through
50
+ * `entries`, so a folder git ignores is still seen and each dir read is
51
+ * recorded. A hidden name matches only a segment that starts with a dot, and
52
+ * `**` never walks into a hidden dir */
53
+ export function globFiles(pattern: string, d: ContractDisk): string[] {
54
+ const segs = pattern.split('/').filter((x) => x !== '' && x !== '.')
55
+ const out = new Set<string>()
56
+ const at = (dir: string, name: string): string => (dir === '' ? name : `${dir}/${name}`)
57
+ const inside = (dir: string) => d.entries(dir === '' ? '.' : dir)
58
+ const walk = (dir: string, i: number): void => {
59
+ const seg = segs[i]
60
+ if (seg == null) return
61
+ const last = i === segs.length - 1
62
+ if (seg === '**') {
63
+ walk(dir, i + 1)
64
+ for (const e of inside(dir)) if (e.kind === 'dir' && !e.name.startsWith('.')) walk(at(dir, e.name), i)
65
+ return
66
+ }
67
+ if (!WILD.test(seg)) {
68
+ if (!last) walk(at(dir, seg), i + 1)
69
+ else if (d.exists(at(dir, seg), 'file')) out.add(at(dir, seg))
70
+ return
71
+ }
72
+ const g = new Bun.Glob(seg)
73
+ for (const e of inside(dir)) {
74
+ if (e.name.startsWith('.') && !seg.startsWith('.')) continue
75
+ if (!g.match(e.name)) continue
76
+ if (!last) { if (e.kind === 'dir') walk(at(dir, e.name), i + 1) }
77
+ else if (e.kind === 'file') out.add(at(dir, e.name))
78
+ }
43
79
  }
80
+ walk('', 0)
81
+ return [...out].sort()
44
82
  }
45
83
 
46
- const abs = (root: string, rel: string): string => join(root, rel)
84
+ /** the text of a file, null when no FILE stands there */
85
+ const textOf = (d: ContractDisk, rel: string): string | null => (d.exists(rel, 'file') ? d.read(rel) : null)
47
86
 
48
- async function checkRule(rule: ArtifactRule, env: ContractEnv): Promise<string[]> {
87
+ async function checkRule(rule: ArtifactRule, env: ContractEnv, d: ContractDisk): Promise<string[]> {
49
88
  const problems: string[] = []
50
89
  switch (rule.kind) {
51
90
  case 'file': {
52
- const p = abs(env.root, rule.path)
53
- if (!existsSync(p)) problems.push(`${rule.path} missing`)
54
- else if (!statSync(p).isFile()) problems.push(`${rule.path} is not a file`)
55
- else if (rule.nonEmpty && statSync(p).size === 0) problems.push(`${rule.path} is empty`)
91
+ if (!d.exists(rule.path)) problems.push(`${rule.path} missing`)
92
+ else if (!d.exists(rule.path, 'file')) problems.push(`${rule.path} is not a file`)
93
+ else if (rule.nonEmpty && (d.bytes(rule.path)?.length ?? 0) === 0) problems.push(`${rule.path} is empty`)
56
94
  break
57
95
  }
58
96
  case 'png': {
59
- const p = abs(env.root, rule.path)
60
97
  const need = rule.atLeast === true ? `>= ${rule.width}x${rule.height} at ${rule.width}:${rule.height}` : `${rule.width}x${rule.height}`
61
- if (!existsSync(p)) { problems.push(`${rule.path} missing — need png ${need}`); break }
62
- const dims = pngDims(p)
98
+ if (!d.exists(rule.path)) { problems.push(`${rule.path} missing — need png ${need}`); break }
99
+ const dims = pngDims(d.exists(rule.path, 'file') ? d.bytes(rule.path) : null)
63
100
  if (dims == null) problems.push(`${rule.path} is not a readable png`)
64
101
  else if (rule.atLeast !== true) {
65
102
  if (dims.width !== rule.width || dims.height !== rule.height) problems.push(`${rule.path} is ${dims.width}x${dims.height} — need ${need}`)
@@ -75,27 +112,25 @@ async function checkRule(rule: ArtifactRule, env: ContractEnv): Promise<string[]
75
112
  break
76
113
  }
77
114
  case 'glob': {
78
- const matches = [...new Bun.Glob(rule.pattern).scanSync({ cwd: env.root, onlyFiles: true })]
115
+ const matches = globFiles(rule.pattern, d)
79
116
  if (matches.length < rule.min) { problems.push(`${rule.pattern}: ${matches.length} file(s) — need >= ${rule.min}`); break }
80
117
  if (rule.width != null && rule.height != null)
81
118
  for (const m of matches) {
82
- const dims = pngDims(abs(env.root, m))
119
+ const dims = pngDims(d.bytes(m))
83
120
  if (dims == null || dims.width !== rule.width || dims.height !== rule.height)
84
121
  problems.push(`${m} is ${dims == null ? 'not a readable png' : `${dims.width}x${dims.height}`} — need ${rule.width}x${rule.height}`)
85
122
  }
86
123
  break
87
124
  }
88
125
  case 'md': {
89
- const p = abs(env.root, rule.path)
90
- if (!existsSync(p)) { problems.push(`${rule.path} missing`); break }
91
- const text = readFileSync(p, 'utf8')
126
+ const text = textOf(d, rule.path)
127
+ if (text == null) { problems.push(`${rule.path} missing`); break }
92
128
  if (text.trim() === '') { problems.push(`${rule.path} is empty`); break }
93
129
  for (const needle of rule.mustContain) if (!text.includes(needle)) problems.push(`${rule.path} lacks required '${needle}'`)
94
130
  break
95
131
  }
96
132
  case 'dir': {
97
- const p = abs(env.root, rule.path)
98
- if (!existsSync(p) || !statSync(p).isDirectory()) problems.push(`${rule.path}/ missing`)
133
+ if (!d.exists(rule.path, 'dir')) problems.push(`${rule.path}/ missing`)
99
134
  break
100
135
  }
101
136
  case 'custom': {
@@ -106,19 +141,19 @@ async function checkRule(rule: ArtifactRule, env: ContractEnv): Promise<string[]
106
141
  return problems
107
142
  }
108
143
 
109
- function detectorFires(m: ContractManifest, cfg: ContractsConfig): string | null {
110
- for (const d of m.detect) {
111
- if (d.kind === 'dirExists') {
112
- if (existsSync(abs(cfg.root, d.path))) return `${d.path}/ exists`
113
- } else if (d.kind === 'fileContains') {
114
- const p = abs(cfg.root, d.path)
115
- if (existsSync(p) && new RegExp(d.pattern).test(readFileSync(p, 'utf8'))) return `${d.path} matches /${d.pattern}/`
144
+ function detectorFires(m: ContractManifest, d: ContractDisk): string | null {
145
+ for (const det of m.detect) {
146
+ if (det.kind === 'dirExists') {
147
+ if (d.exists(det.path)) return `${det.path}/ exists`
148
+ } else if (det.kind === 'fileContains') {
149
+ const text = textOf(d, det.path)
150
+ if (text != null && new RegExp(det.pattern).test(text)) return `${det.path} matches /${det.pattern}/`
116
151
  } else {
117
- const pkgPath = abs(cfg.root, 'package.json')
118
- if (!existsSync(pkgPath)) continue
152
+ const pkg = textOf(d, 'package.json')
153
+ if (pkg == null) continue
119
154
  try {
120
- const scripts = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {}
121
- const re = new RegExp(d.pattern)
155
+ const scripts = (JSON.parse(pkg) as { scripts?: Record<string, string> }).scripts ?? {}
156
+ const re = new RegExp(det.pattern)
122
157
  const hit = Object.keys(scripts).find((k) => re.test(k))
123
158
  if (hit != null) return `package.json script '${hit}'`
124
159
  } catch { /* unparseable package.json is another tool's FAIL */ }
@@ -127,7 +162,7 @@ function detectorFires(m: ContractManifest, cfg: ContractsConfig): string | null
127
162
  return null
128
163
  }
129
164
 
130
- export async function evaluateContracts(manifests: ContractManifest[], cfg: ContractsConfig): Promise<ContractsResult> {
165
+ export async function evaluateContracts(manifests: ContractManifest[], cfg: ContractsConfig, d: ContractDisk): Promise<ContractsResult> {
131
166
  const lines: Line[] = []
132
167
  const outcomes: ContractOutcome[] = []
133
168
  let anyFail = false
@@ -135,7 +170,8 @@ export async function evaluateContracts(manifests: ContractManifest[], cfg: Cont
135
170
  root: cfg.root,
136
171
  venues: cfg.declared,
137
172
  social: cfg.social,
138
- exists: (rel) => existsSync(abs(cfg.root, rel)),
173
+ exists: (rel) => d.exists(rel),
174
+ read: (rel) => textOf(d, rel),
139
175
  }
140
176
  const fix = (m: ContractManifest, text: string): Line[] => [
141
177
  { kind: 'fix-fail', text: `${text} — ${(cfg.docsBase ?? '') + m.owner}` },
@@ -150,7 +186,7 @@ export async function evaluateContracts(manifests: ContractManifest[], cfg: Cont
150
186
  }
151
187
  const declared = cfg.declared.includes(m.venue)
152
188
  if (m.appliesTo === 'declared' && !declared) {
153
- const signal = detectorFires(m, cfg)
189
+ const signal = detectorFires(m, d)
154
190
  if (signal != null) {
155
191
  anyFail = true
156
192
  lines.push({ kind: 'fail', text: `${m.venue} ships (${signal}) but venue is not declared — declare it or remove the signal` })
@@ -166,7 +202,7 @@ export async function evaluateContracts(manifests: ContractManifest[], cfg: Cont
166
202
  let problems = 0
167
203
  let warned = 0
168
204
  for (const rule of m.artifacts) {
169
- const found = await checkRule(rule, env)
205
+ const found = await checkRule(rule, env, d)
170
206
  const asWarn = warnAll || rule.warnOnly === true
171
207
  for (const p of found) {
172
208
  if (asWarn) {
@@ -1,15 +1,15 @@
1
- // normative WHAT for .rv-discord/ — a project's public Discord server
2
- // contract. Posts stay in .rv-social/.
1
+ // normative WHAT for .shipkit/discord/ — a project's public Discord server
2
+ // contract. Posts stay in .shipkit/social/.
3
3
  import { defineContract, dirExists, md, png } from 'rvlib-shipkit/src/contracts/contract-types.ts'
4
4
 
5
5
  export default defineContract({
6
6
  venue: 'discord',
7
- dir: '.rv-discord',
7
+ dir: '.shipkit/discord',
8
8
  owner: 'distribute/post-publicly.md',
9
9
  appliesTo: 'declared',
10
- detect: [dirExists('.rv-discord')],
10
+ detect: [dirExists('.shipkit/discord')],
11
11
  artifacts: [
12
- md('.rv-discord/server.md', { mustContain: ['invite'] }),
13
- png('.rv-discord/icon.png', 512, 512, { atLeast: true }),
12
+ md('.shipkit/discord/server.md', { mustContain: ['invite'] }),
13
+ png('.shipkit/discord/icon.png', 512, 512, { atLeast: true }),
14
14
  ],
15
15
  })
@@ -1,17 +1,17 @@
1
- // normative WHAT for .rv-identity/ — every repo with a public surface owns
1
+ // normative WHAT for .shipkit/identity/ — every repo with a public surface owns
2
2
  // its brand source + committed favicon/logo/OG outputs. gate: FAIL once a
3
3
  // public surface exists (deploy.sh or any declared venue), WARN before.
4
4
  import { defineContract, file, glob, png } from 'rvlib-shipkit/src/contracts/contract-types.ts'
5
5
 
6
6
  export default defineContract({
7
7
  venue: 'identity',
8
- dir: '.rv-identity',
8
+ dir: '.shipkit/identity',
9
9
  owner: 'build/generate-assets.md',
10
10
  appliesTo: 'always',
11
11
  gate: 'public-surface',
12
12
  detect: [],
13
13
  artifacts: [
14
- file('.rv-identity/brand.ts'),
14
+ file('.shipkit/identity/brand.ts'),
15
15
  png('public/favicon.png', 512, 512),
16
16
  png('public/og.png', 1200, 630, { atLeast: true }),
17
17
  glob('public/logo.*', { min: 1 }),