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
@@ -1,30 +1,27 @@
1
- // normative WHAT for .rv-itch/ — the WHY + workflow live in the sibling page.
1
+ // normative WHAT for .shipkit/itch/ — the WHY + workflow live in the sibling page.
2
2
  import { custom, defineContract, dirExists, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
3
- import { existsSync, readFileSync } from 'fs'
4
- import { join } from 'path'
5
3
 
6
4
  export default defineContract({
7
5
  venue: 'itch',
8
- dir: '.rv-itch',
6
+ dir: '.shipkit/itch',
9
7
  owner: 'distribute/itch.md',
10
8
  appliesTo: 'declared',
11
- detect: [script('^(up|push):itch$'), fileContains('deploy.sh', 'butler push'), dirExists('.rv-itch')],
9
+ detect: [script('^(up|push):itch$'), fileContains('deploy.sh', 'butler push'), dirExists('.shipkit/itch')],
12
10
  artifacts: [
13
11
  // one AI-disclosure wording everywhere — page.md is its canonical home
14
- md('.rv-itch/page.md', { mustContain: ['## AI disclosure'] }),
12
+ md('.shipkit/itch/page.md', { mustContain: ['## AI disclosure'] }),
15
13
  // every field of itch's create-project form, answered and ready to
16
14
  // paste — the listing is a browser-only act, so the artifact IS the
17
15
  // handoff (references/listing.template.md is the fill-in copy)
18
- md('.rv-itch/listing.md', { mustContain: ['## Title', '## Short description or tagline', '## Classification', '## Genre', '## Tags', '## Visibility'] }),
16
+ md('.shipkit/itch/listing.md', { mustContain: ['## Title', '## Short description or tagline', '## Classification', '## Genre', '## Tags', '## Visibility'] }),
19
17
  // headings alone would pass the TEMPLATE copied in verbatim, and this
20
18
  // file exists to be pasted with no decision left in it
21
- custom('.rv-itch/listing.md answered', (env) => {
22
- const p = join(env.root, '.rv-itch/listing.md')
23
- if (!existsSync(p)) return []
24
- const text = readFileSync(p, 'utf8')
19
+ custom('.shipkit/itch/listing.md answered', (env) => {
20
+ const text = env.read('.shipkit/itch/listing.md')
21
+ if (text == null) return []
25
22
  const out: string[] = []
26
23
  const left = [...text.matchAll(/<[a-z][^<>\n]{2,60}>/g)].map((m) => m[0])
27
- if (left.length > 0) out.push(`.rv-itch/listing.md still holds ${left.length} unanswered placeholder(s): ${[...new Set(left)].slice(0, 3).join(' ')} — it is pasted into the store form as-is`)
24
+ if (left.length > 0) out.push(`.shipkit/itch/listing.md still holds ${left.length} unanswered placeholder(s): ${[...new Set(left)].slice(0, 3).join(' ')} — it is pasted into the store form as-is`)
28
25
  // a heading with an EMPTY body is an unanswered field wearing no
29
26
  // placeholder (prose-level open decisions stay a human read).
30
27
  // fenced blocks stripped first (a ## inside one is not a field),
@@ -37,11 +34,11 @@ export default defineContract({
37
34
  return !inline && body.every((l) => l.trim() === '')
38
35
  })
39
36
  .map((s) => s.split('\n')[0]!.trim())
40
- if (empty.length > 0) out.push(`.rv-itch/listing.md field(s) with no answer under the heading: ${empty.slice(0, 3).join(', ')}${empty.length > 3 ? ' …' : ''}`)
37
+ if (empty.length > 0) out.push(`.shipkit/itch/listing.md field(s) with no answer under the heading: ${empty.slice(0, 3).join(', ')}${empty.length > 3 ? ' …' : ''}`)
41
38
  return out
42
39
  }),
43
40
  // itch DISPLAYS 630x500 and accepts larger — atLeast, never exact-size
44
- png('.rv-itch/cover.png', 630, 500, { atLeast: true }),
45
- glob('.rv-itch/screenshots/*.png', { min: 3 }),
41
+ png('.shipkit/itch/cover.png', 630, 500, { atLeast: true }),
42
+ glob('.shipkit/itch/screenshots/*.png', { min: 3 }),
46
43
  ],
47
44
  })
@@ -1,18 +1,18 @@
1
- // normative WHAT for .rv-social/ (posts ONLY — the Discord server contract is
1
+ // normative WHAT for .shipkit/social/ (posts ONLY — the Discord server contract is
2
2
  // discord.contract.ts). Declared = the repo config lists social channels.
3
3
  import { custom, defineContract, dirExists } from 'rvlib-shipkit/src/contracts/contract-types.ts'
4
4
 
5
5
  export default defineContract({
6
6
  venue: 'social',
7
- dir: '.rv-social',
7
+ dir: '.shipkit/social',
8
8
  owner: 'distribute/post-publicly.md',
9
9
  appliesTo: 'declared',
10
- detect: [dirExists('.rv-social')],
10
+ detect: [dirExists('.shipkit/social')],
11
11
  artifacts: [
12
12
  custom('declared-channel-dirs', (env) =>
13
13
  env.social
14
- .filter((ch) => !env.exists(`.rv-social/${ch}`))
15
- .map((ch) => `.rv-social/${ch}/ missing — declared social channel '${ch}' has no post folder`),
14
+ .filter((ch) => !env.exists(`.shipkit/social/${ch}`))
15
+ .map((ch) => `.shipkit/social/${ch}/ missing — declared social channel '${ch}' has no post folder`),
16
16
  ),
17
17
  ],
18
18
  })
@@ -1,28 +1,28 @@
1
- // normative WHAT for .rv-steam/ — the WHY + workflow live in the sibling page.
1
+ // normative WHAT for .shipkit/steam/ — the WHY + workflow live in the sibling page.
2
2
  // Capsule sizes as of 2026-07 — Valve churns them: re-verify at use, bump here.
3
3
  import { defineContract, dir, dirExists, file, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
4
4
 
5
5
  export default defineContract({
6
6
  venue: 'steam',
7
- dir: '.rv-steam',
7
+ dir: '.shipkit/steam',
8
8
  owner: 'distribute/steam.md',
9
9
  appliesTo: 'declared',
10
- detect: [script('^(up|push):steam$'), fileContains('deploy.sh', 'run_app_build'), dirExists('.rv-steam')],
10
+ detect: [script('^(up|push):steam$'), fileContains('deploy.sh', 'run_app_build'), dirExists('.shipkit/steam')],
11
11
  artifacts: [
12
- // store.md carries the same AI-disclosure wording as .rv-itch/page.md
13
- md('.rv-steam/store.md', { mustContain: ['## AI disclosure'] }),
14
- png('.rv-steam/capsules/main.png', 1232, 706),
15
- png('.rv-steam/capsules/header.png', 920, 430),
16
- png('.rv-steam/capsules/small.png', 462, 174),
17
- png('.rv-steam/capsules/vertical.png', 748, 896),
18
- png('.rv-steam/capsules/library.png', 600, 900),
19
- png('.rv-steam/capsules/library-hero.png', 3840, 1240),
20
- png('.rv-steam/capsules/library-logo.png', 1280, 720),
21
- png('.rv-steam/capsules/community-icon.png', 184, 184),
12
+ // store.md carries the same AI-disclosure wording as .shipkit/itch/page.md
13
+ md('.shipkit/steam/store.md', { mustContain: ['## AI disclosure'] }),
14
+ png('.shipkit/steam/capsules/main.png', 1232, 706),
15
+ png('.shipkit/steam/capsules/header.png', 920, 430),
16
+ png('.shipkit/steam/capsules/small.png', 462, 174),
17
+ png('.shipkit/steam/capsules/vertical.png', 748, 896),
18
+ png('.shipkit/steam/capsules/library.png', 600, 900),
19
+ png('.shipkit/steam/capsules/library-hero.png', 3840, 1240),
20
+ png('.shipkit/steam/capsules/library-logo.png', 1280, 720),
21
+ png('.shipkit/steam/capsules/community-icon.png', 184, 184),
22
22
  // .ico, dims not png-parseable — presence-checked only
23
- file('.rv-steam/capsules/client-icon.ico'),
24
- glob('.rv-steam/screenshots/*.png', { min: 5, width: 1920, height: 1080 }),
23
+ file('.shipkit/steam/capsules/client-icon.ico'),
24
+ glob('.shipkit/steam/screenshots/*.png', { min: 5, width: 1920, height: 1080 }),
25
25
  // vdf scripts only exist once the appid does — nag, don't block
26
- dir('.rv-steam/vdf', { warnOnly: true }),
26
+ dir('.shipkit/steam/vdf', { warnOnly: true }),
27
27
  ],
28
28
  })
@@ -2,7 +2,7 @@
2
2
  // behind cmd_Contracts; venue deploy scripts run it before any upload.
3
3
  import { loadRepoConfig } from '../repo-config.ts'
4
4
  import { CheckCtx } from '../core/check-ctx.ts'
5
- import { contractsFulfillness } from '../rules/SK002-contracts-fulfillness.ts'
5
+ import { contractsFulfillness } from '../checks/SK002-contracts-fulfillness.check.ts'
6
6
  import { activeContracts, applyContractsResult, contractsConfigFrom } from './apply-contracts.ts'
7
7
  import { evaluateContracts } from './contracts-engine.ts'
8
8
  import { formatLine } from '../core/wire.ts'
@@ -24,14 +24,15 @@ export async function runContracts(root: string, o: ContractsOpts = {}): Promise
24
24
  manifests = manifests.filter((m) => m.venue === o.venue)
25
25
  if (manifests.length === 0) { printErr(`FAIL: unknown venue '${o.venue}'`); return 1 }
26
26
  }
27
- const r = await evaluateContracts(manifests, contractsConfigFrom(c, root))
27
+ const disk = CheckCtx.open({ root, config: c })
28
+ const r = await evaluateContracts(manifests, contractsConfigFrom(c, disk), disk)
28
29
  if (o.json === true) {
29
30
  print(JSON.stringify({ ok: r.exitCode === 0, outcomes: r.outcomes }))
30
31
  return r.exitCode
31
32
  }
32
33
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
33
34
  // same SK002-tagged rendering as `shipkit check` and the ci task
34
- const t = new CheckCtx(new Map())
35
+ const t = new CheckCtx()
35
36
  applyContractsResult(t, r, contractsFulfillness)
36
37
  for (const l of t.lines) print(formatLine(l, color))
37
38
  return r.exitCode
@@ -0,0 +1,96 @@
1
+ // JOB ATTACHMENT: two processes asking ONE question (same check, same cache
2
+ // key) get ONE measurement. The first takes `<slot>.lock` and measures, every
3
+ // later asker waits on that lock and adopts the answer it left in
4
+ // `<slot>.json`. The lock is the mutate lease (locked-write.ts), so a holder
5
+ // that died is stolen and a wedged one is bounded, with no second mechanism.
6
+ //
7
+ // The one constraint a reader breaks first: an answer is adopted only when it
8
+ // FINISHED after the asker asked, i.e. it was in flight, and never by an asker
9
+ // that stole the lock: it just called that run gone, so it measures itself
10
+ // rather than print a steal from a pid and then an attach to the same pid. The file is never a
11
+ // cache: a red it holds must not replay for a later asker (the ledger replays
12
+ // greens only), and a green reaches later askers through the ledger, after
13
+ // its post-run re-hash.
14
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
15
+ import { dirname } from 'path'
16
+ import { thisInstance } from './instance.ts'
17
+ import { atomicWrite, withMutateLease } from './locked-write.ts'
18
+
19
+ type Shared<T> = { pid: number; nonce?: string; finishedAt: number; value: T }
20
+
21
+ export type ShareOutcome<T> =
22
+ | { kind: 'measured'; value: T }
23
+ | { kind: 'attached'; value: T; pid: number }
24
+ /** the wait was stopped (an interrupt, a bail): nothing ran */
25
+ | { kind: 'aborted' }
26
+ /** the holder outlived the wait bound: this asker measured anyway */
27
+ | { kind: 'overlapped'; value: T; warn: string }
28
+
29
+ export type ShareOpts = {
30
+ /** when this asker asked: only an answer finished after it is adopted */
31
+ askedAt: number
32
+ label?: string
33
+ print?: (line: string) => void
34
+ /** the wait bound. Past the holder's own ceiling, so a live holder always
35
+ * finishes first. */
36
+ maxWaitMs?: number
37
+ pollMs?: number
38
+ onWait?: (pid: number) => void
39
+ abort?: () => boolean
40
+ }
41
+
42
+ /** how long an answer file stays on disk. Only an in flight asker can adopt
43
+ * one, so this is housekeeping, not a validity window. */
44
+ const KEEP_MS = 60 * 60 * 1000
45
+
46
+ const readShared = <T>(path: string): Shared<T> | null => {
47
+ try {
48
+ const s = JSON.parse(readFileSync(path, 'utf8')) as Partial<Shared<T>>
49
+ return typeof s.pid === 'number' && typeof s.finishedAt === 'number' && 'value' in s ? (s as Shared<T>) : null
50
+ } catch { return null }
51
+ }
52
+
53
+ const sweep = (dir: string, now: number): void => {
54
+ try {
55
+ for (const f of readdirSync(dir)) {
56
+ if (f.endsWith('.lock')) continue
57
+ const p = `${dir}/${f}`
58
+ try { if (statSync(p).mtimeMs < now - KEEP_MS) rmSync(p, { force: true }) } catch { /* raced: gone either way */ }
59
+ }
60
+ } catch { /* housekeeping never fails a run */ }
61
+ }
62
+
63
+ /** run `body` once per in flight key. `share: false` from the body (it was
64
+ * killed, it never reached its subject) leaves no answer, so a waiter
65
+ * measures for itself rather than adopting a non answer. */
66
+ export async function shareRun<T>(slot: string, o: ShareOpts, body: () => Promise<{ value: T; share: boolean }>): Promise<ShareOutcome<T>> {
67
+ const answer = `${slot}.json`
68
+ mkdirSync(dirname(slot), { recursive: true })
69
+ let stole = false
70
+ const leased = await withMutateLease(slot, {
71
+ label: o.label ?? 'shared run',
72
+ print: o.print,
73
+ maxWaitMs: o.maxWaitMs,
74
+ pollMs: o.pollMs,
75
+ announceMs: Number.POSITIVE_INFINITY,
76
+ onWait: (h) => o.onWait?.(h.pid),
77
+ onSteal: () => { stole = true },
78
+ abort: o.abort,
79
+ }, async (): Promise<ShareOutcome<T>> => {
80
+ const prior = readShared<T>(answer)
81
+ if (!stole && prior != null && prior.finishedAt >= o.askedAt) return { kind: 'attached', value: prior.value, pid: prior.pid }
82
+ const r = await body()
83
+ const now = Date.now()
84
+ if (r.share) {
85
+ try { atomicWrite(answer, JSON.stringify({ ...thisInstance(), finishedAt: now, value: r.value } satisfies Shared<T>)) } catch {
86
+ // an answer nobody can adopt costs the waiters a measurement, never a verdict
87
+ }
88
+ }
89
+ sweep(dirname(slot), now)
90
+ return { kind: 'measured', value: r.value }
91
+ })
92
+ if (leased.ok) return leased.value
93
+ if (leased.aborted === true) return { kind: 'aborted' }
94
+ const r = await body()
95
+ return { kind: 'overlapped', value: r.value, warn: leased.warn }
96
+ }
@@ -74,3 +74,28 @@ export function inferBin(scripts: Record<string, string>): string | undefined {
74
74
  }
75
75
  return undefined
76
76
  }
77
+
78
+ /** what fills one floor slot, as far as package.json and `defaults` say: the
79
+ * decision core/floor.ts turns into a task, and rule SK018 reads to know what
80
+ * the root's tests run. `none` leaves the slot to a built-in, if it has one */
81
+ export type SlotBody =
82
+ | { kind: 'off' }
83
+ | { kind: 'override' }
84
+ | { kind: 'script'; name: string; body: string }
85
+ | { kind: 'routed-empty' }
86
+ | { kind: 'none' }
87
+
88
+ export function slotBody(id: string, override: { cmd?: unknown; logic?: unknown } | false | undefined, scripts: Record<string, string>): SlotBody {
89
+ if (override === false) return { kind: 'off' }
90
+ // a FULL override brings its own body and replaces the slot. One that
91
+ // brings none DECORATES whatever fills it
92
+ if (override != null && (override.cmd != null || override.logic != null)) return { kind: 'override' }
93
+ // ONE WAY IN: `<id>` routes to `shipkit ci --only <id>`, so the TOOL body
94
+ // lives at `<id>:raw`. Running the routed script would spawn the gate from
95
+ // inside itself, forever
96
+ const raw = scripts[rawName(id)]
97
+ if (raw != null) return { kind: 'script', name: rawName(id), body: raw }
98
+ const own = scripts[id]
99
+ if (own != null && !routesToShipkit(own)) return { kind: 'script', name: id, body: own }
100
+ return own != null ? { kind: 'routed-empty' } : { kind: 'none' }
101
+ }
@@ -1,13 +1,43 @@
1
- // CheckCtx — the per-check handle a probe's logic receives: outcome lines
2
- // (wire.ts kinds), guarded fetch/exec helpers, resolved-tool lookup.
1
+ // CheckCtx — the ONE parameter a check body receives, and its only door to the
2
+ // repo. Four parts:
3
+ //
4
+ // report c.ok / note / warn / fail / unreachable, the wire lines (wire.ts)
5
+ // where c.root, c.config, c.frozen
6
+ // reads c.read / json / exists / isLink / list / entries, each recorded with what it saw, and
7
+ // c.use(scan) for a scan every check of the pass shares (scan.ts)
8
+ // world c.exec / fetch / tool, which no read set can describe
9
+ // writes c.write / remove, for a `fix`
10
+ //
11
+ // What a body read is therefore KNOWN rather than declared: a `run` check that
12
+ // declares no cache population is cached on its recorded reads
13
+ // (ledger/ledger.ts), and one that reaches the world through exec, fetch or
14
+ // tool marks its answer untracked, so it is never stored on a read set that
15
+ // cannot describe it. A body whose code reaches the disk around `c` is never
16
+ // keyed on its reads at all (core/raw-access.ts, checked at discovery).
17
+ //
18
+ // A context with no pass reports and reaches the world, and a repo read there
19
+ // throws rather than guess a root.
20
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
21
+ import { dirname, join } from 'path'
22
+ import { atomicWrite } from './locked-write.ts'
3
23
  import { spawnWithTimeout } from './spawn-timeout.ts'
24
+ import { blobOid, dirEntries, entriesHash, isLinkAt, kindOf, listingHash, Reads, type DirEntry } from './read-set.ts'
25
+ import { CheckPass } from './check-pass.ts'
26
+ import { normalizeInput, WHOLE_TREE } from '../ledger/git-scan.ts'
27
+ import type { RepoConfig } from '../repo-config.ts'
28
+ import type { Scan } from '../scan.ts'
4
29
  import type { Line } from './wire.ts'
5
30
 
6
- // thrown by t.fetch on 429 — the suite converts it to WARN, never FAIL
7
- export class RateLimited extends Error {}
8
- // thrown by t.tool when its defineRequiredTool check failed — that check
9
- // already FAILed the run; the dependent check notes it skipped, no double FAIL
10
- export class ToolUnresolved extends Error {}
31
+ // thrown by t.fetch on 429: no answer, so the body runner (check.ts
32
+ // runCheckBody) reports it as unreachable, never as a FAIL. It knows it by
33
+ // name, so the narrow check module never loads this one
34
+ export class RateLimited extends Error {
35
+ override name = 'RateLimited'
36
+ }
37
+
38
+ /** what a shared scan may do: know where it is and read. It reports nothing
39
+ * and reaches no world, which is what lets its reads stand for its value. */
40
+ export type ScanCtx = Pick<CheckCtx, 'root' | 'config' | 'frozen' | 'read' | 'bytes' | 'json' | 'exists' | 'isLink' | 'list' | 'entries' | 'use'>
11
41
 
12
42
  export class CheckCtx {
13
43
  lines: Line[] = []
@@ -16,12 +46,28 @@ export class CheckCtx {
16
46
  /** THIS RUN PRODUCED NO ANSWER: the probe could not run at all (network
17
47
  * down, no cred, rate limited, tool missing). Distinct from a probe that
18
48
  * ran and came back bad, which IS an answer. The cache replays answers
19
- * and never replays their absence (check-suite.ts, the measured-negative
49
+ * and never replays their absence (ledger/world-cache.ts, the measured-negative
20
50
  * rule). Set it whenever the reason a check is not green is that it never
21
51
  * reached the thing it was asking about. */
22
52
  unmeasured = false
23
- constructor(private toolPaths: Map<string, string>) {}
53
+ /** every repo read this context made, with what it saw */
54
+ readonly reads = new Reads()
55
+ /** why the reads cannot describe this answer (it reached the world), null
56
+ * while they can */
57
+ untracked: string | null = null
24
58
 
59
+ constructor(
60
+ /** the runner pass this body runs in. Absent for a context that only
61
+ * reports and reaches the world */
62
+ readonly pass: CheckPass | null = null,
63
+ ) {}
64
+
65
+ /** a context over one repo, outside any runner: a fixer, a test, a tool */
66
+ static open(where: { root: string; config: RepoConfig }): CheckCtx {
67
+ return new CheckCtx(new CheckPass(where.root, where.config))
68
+ }
69
+
70
+ // ── report ────────────────────────────────────────────────────────────
25
71
  ok(msg: string): void { this.lines.push({ kind: 'ok', text: msg }) }
26
72
  note(msg: string): void { this.lines.push({ kind: 'note', text: msg }) }
27
73
  /** a WARN that came back from something we could not reach — no answer, so
@@ -41,15 +87,155 @@ export class CheckCtx {
41
87
  if (fix != null) this.lines.push({ kind: 'fix-fail', text: fix })
42
88
  }
43
89
 
44
- // path resolved by an earlier defineRequiredTool check (declaration order)
45
- tool(name: string): string {
46
- const p = this.toolPaths.get(name)
47
- if (p == null) throw new ToolUnresolved(`tool '${name}' unresolved — its defineRequiredTool check failed or is declared after this check`)
90
+ // ── where ─────────────────────────────────────────────────────────────
91
+ private repo(what: string): CheckPass {
92
+ if (this.pass == null) throw new Error(`c.${what} needs a repo, and this context has no pass: it only reports and reaches the world`)
93
+ return this.pass
94
+ }
95
+ get root(): string { return this.repo('root').root }
96
+ /** the repo's config. Reading it records repo.config.ts and every repo
97
+ * module it imports, so a config edit re-asks every check that consulted
98
+ * it and none that did not */
99
+ get config(): RepoConfig {
100
+ const p = this.repo('config')
101
+ const deps = p.configReads()
102
+ for (const [f, oid] of deps.files) this.reads.file(f, oid)
103
+ if (deps.untracked != null && this.untracked == null) {
104
+ this.untrack(deps.untracked)
105
+ this.note(`not cached: ${deps.untracked}`)
106
+ }
107
+ return p.config
108
+ }
109
+ /** a report only run: a check that rewrites the repo must not */
110
+ get frozen(): boolean { return this.repo('frozen').frozen }
111
+ /** the fix pointer a failing body with none of its own gets: an owner
112
+ * that is a file of this repo (a repo check's own path) as it stands, a
113
+ * page of the docs wiki after `docsBase`. The runner's line, never the
114
+ * body's, so nothing here is recorded as a read */
115
+ ownerPointer(owner: string): string {
116
+ const inRepo = this.pass != null && owner !== '' && existsSync(join(this.pass.root, owner))
117
+ return inRepo ? owner : `${this.pass?.config.docsBase ?? ''}${owner}`
118
+ }
119
+
120
+ // ── reads, recorded ───────────────────────────────────────────────────
121
+ private rel(path: string): string {
122
+ const p = normalizeInput(path)
123
+ if (p === WHOLE_TREE) throw new Error(`'${path}' is the repo root, not a file`)
48
124
  return p
49
125
  }
126
+ /** the text of a repo-relative file, null when there is none */
127
+ read(path: string): string | null {
128
+ return this.bytes(path)?.toString('utf8') ?? null
129
+ }
130
+ /** the raw bytes of a repo-relative file (an image, a binary), null when
131
+ * there is none. Recorded exactly like `read` */
132
+ bytes(path: string): Buffer | null {
133
+ const p = this.rel(path)
134
+ let bytes: Buffer
135
+ try {
136
+ bytes = readFileSync(join(this.repo('read').root, p))
137
+ } catch (e) {
138
+ const code = (e as { code?: string }).code
139
+ if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'EISDIR') throw e
140
+ this.reads.file(p, null)
141
+ return null
142
+ }
143
+ this.reads.file(p, blobOid(bytes))
144
+ return bytes
145
+ }
146
+ /** a repo-relative json file, parsed. null when absent, and a parse error
147
+ * throws naming the file */
148
+ json<T = unknown>(path: string): T | null {
149
+ const text = this.read(path)
150
+ if (text == null) return null
151
+ try {
152
+ return JSON.parse(text) as T
153
+ } catch (e) {
154
+ throw new Error(`${path} is not valid json (${String(e).slice(0, 120)})`)
155
+ }
156
+ }
157
+ /** whether something stands at this repo-relative path, or only a file,
158
+ * or only a dir, when `kind` says which */
159
+ exists(path: string, kind?: 'file' | 'dir'): boolean {
160
+ const p = this.rel(path)
161
+ const found = kindOf(join(this.repo('exists').root, p))
162
+ this.reads.exist(p, found)
163
+ return kind == null ? found != null : found === kind
164
+ }
165
+ /** whether a symlink stands at this repo-relative path itself, which
166
+ * `exists` cannot say: it follows the link to what it points at */
167
+ isLink(path: string): boolean {
168
+ const p = this.rel(path)
169
+ const is = isLinkAt(join(this.repo('isLink').root, p))
170
+ this.reads.link(p, is)
171
+ return is
172
+ }
173
+ /** every file matching `glob` that git sees (tracked, or untracked and not
174
+ * ignored), repo-relative and sorted */
175
+ list(glob: string): string[] {
176
+ const view = this.repo('list').view()
177
+ const files = view.list(glob)
178
+ if (view.fallback != null) this.untrack(`listed without a git scan: ${view.fallback}`)
179
+ this.reads.list(glob, listingHash(files))
180
+ return files
181
+ }
182
+ /** the entries directly inside a repo-relative dir, read from the DISK,
183
+ * sorted by name: ignored files included, which `list` leaves out (a
184
+ * gitignored contract folder is still a folder a check must see). Empty
185
+ * when no dir stands there. The listing is recorded, so an entry added,
186
+ * removed or turned from a file into a dir runs the check again */
187
+ entries(dir: string): DirEntry[] {
188
+ const d = normalizeInput(dir)
189
+ const found = dirEntries(d === WHOLE_TREE ? this.repo('entries').root : join(this.repo('entries').root, d))
190
+ this.reads.dir(d, entriesHash(found))
191
+ return found ?? []
192
+ }
193
+ /** a scan's value, computed once per pass for every check that asks, with
194
+ * its reads recorded into this check as if it had made them */
195
+ async use<T>(scan: Scan<T>): Promise<T> {
196
+ const pass = this.repo('use')
197
+ const s = pass.scan(scan, () => new CheckCtx(pass))
198
+ const value = await s.value
199
+ this.reads.merge(s.ctx.reads)
200
+ if (s.ctx.untracked != null) this.untrack(s.ctx.untracked)
201
+ return value
202
+ }
203
+
204
+ // ── writes, for a fixer ───────────────────────────────────────────────
205
+ /** write a repo-relative file whole, its dirs created, never torn. For a
206
+ * `fix`: a body that writes is not a question, and nothing records it */
207
+ write(path: string, text: string): void {
208
+ const abs = join(this.repo('write').root, this.rel(path))
209
+ mkdirSync(dirname(abs), { recursive: true })
210
+ atomicWrite(abs, text)
211
+ }
212
+ /** delete a repo-relative file or dir, and nothing when it is absent */
213
+ remove(path: string): void {
214
+ rmSync(join(this.repo('remove').root, this.rel(path)), { recursive: true, force: true })
215
+ }
216
+
217
+ // ── world ─────────────────────────────────────────────────────────────
218
+ /** this answer depends on something no read here recorded (a path
219
+ * outside the repo, git history), so it must never be stored on its
220
+ * reads. exec, fetch and tool say it for themselves */
221
+ untrack(reason: string): void {
222
+ this.untracked ??= reason
223
+ }
224
+ private world(what: string): void {
225
+ this.untrack(`it reached the world through ${what}`)
226
+ }
227
+
228
+ /** where a binary lives on this machine, null when it is not installed.
229
+ * An explicit candidate path that exists wins over PATH, so a stale
230
+ * global never shadows the one a repo pins */
231
+ tool(name: string, candidates: string[] = []): string | null {
232
+ this.world('tool')
233
+ return candidates.find((p) => existsSync(p)) ?? Bun.which(name)
234
+ }
50
235
 
51
236
  // fetch with a default timeout; 429 → RateLimited → WARN upstream
52
237
  async fetch(url: string, init?: RequestInit & { timeoutMs?: number }): Promise<Response> {
238
+ this.world('fetch')
53
239
  const res = await fetch(url, { ...init, signal: init?.signal ?? AbortSignal.timeout(init?.timeoutMs ?? 10_000) })
54
240
  if (res.status === 429) throw new RateLimited(`${new URL(url).host} → 429 rate-limited`)
55
241
  return res
@@ -58,6 +244,10 @@ export class CheckCtx {
58
244
  // spawn without the Bun.spawn throw-on-missing-binary gotcha; SIGTERM at
59
245
  // timeoutMs, SIGKILL 2s later (one code path: core/spawn-timeout.ts)
60
246
  async exec(cmd: string[], opts?: { env?: Record<string, string | undefined>; cwd?: string; timeoutMs?: number }): Promise<{ code: number; out: string; timedOut: boolean }> {
61
- return spawnWithTimeout(cmd, { timeoutMs: opts?.timeoutMs ?? 15_000, env: opts?.env, cwd: opts?.cwd })
247
+ this.world('exec')
248
+ // the environment as it stands NOW: left out, Bun hands the child the
249
+ // one it started with, and a variable set since (a test seam, a flag
250
+ // the gate set) never reaches it
251
+ return spawnWithTimeout(cmd, { timeoutMs: opts?.timeoutMs ?? 15_000, env: opts?.env ?? { ...process.env }, cwd: opts?.cwd })
62
252
  }
63
253
  }
@@ -0,0 +1,112 @@
1
+ // WHICH files are check files, and what they are called, from paths and
2
+ // source text alone. No disk access here, so a check that must know the
3
+ // repo's check files (SK016) reads them through its context and stays cached
4
+ // on what it read. core/discover-checks.ts walks the disk with the same rules.
5
+ import { basename, relative } from 'path'
6
+
7
+ export const CHECK_SUFFIX = '.check.ts'
8
+
9
+ /** dirs discovery never walks into, beside every dot dir: installs, build
10
+ * output, scratch, and templates meant for other repos */
11
+ export const SKIPPED_DIRS: ReadonlySet<string> = new Set(['node_modules', 'dist', 'tmp', 'references'])
12
+
13
+ /** is this repo-relative path a file discovery would find: the suffix, and no
14
+ * dir on the way that the walk skips */
15
+ export function isCheckFilePath(rel: string): boolean {
16
+ if (!rel.endsWith(CHECK_SUFFIX)) return false
17
+ const dirs = rel.split('/').slice(0, -1)
18
+ return dirs.every((d) => !SKIPPED_DIRS.has(d) && !d.startsWith('.'))
19
+ }
20
+
21
+ // a declaration only the legacy script shape writes, at the top level. A
22
+ // comment or an indented line never matches, so a check file that MENTIONS
23
+ // the old shape stays a check file
24
+ const LEGACY_DECL_RE = /^[ \t]*export\s+const\s+(blocking|inputs|ttl|never|gate)\b/m
25
+ // a default export that can be the check object: a call, a literal or a
26
+ // name. A function or a class is a script's own business, never a check
27
+ const DEFAULT_OBJECT_RE = /^[ \t]*export\s+default\s+(?!(async\s+)?function\b|class\b)\S/m
28
+
29
+ /** is this `*.check.ts` source a legacy check SCRIPT rather than a check
30
+ * file? Read, never imported: a script runs its whole body on import. It is
31
+ * legacy when it carries a legacy declaration, or when nothing it exports by
32
+ * default could be a check object. */
33
+ export function isLegacyCheckSource(src: string): boolean {
34
+ return LEGACY_DECL_RE.test(src) || !DEFAULT_OBJECT_RE.test(src)
35
+ }
36
+
37
+ /** the name a repo file answers to when it declares no id: its basename, or
38
+ * its repo-relative path when another `*.check.ts` shares that basename.
39
+ * Counted over EVERY file with the suffix, legacy scripts included, so both
40
+ * readers give one file one name. */
41
+ export function fallbackNames(root: string, files: string[]): Map<string, string> {
42
+ const base = (f: string): string => basename(f).slice(0, -CHECK_SUFFIX.length)
43
+ const seen = new Map<string, number>()
44
+ for (const f of files) seen.set(base(f), (seen.get(base(f)) ?? 0) + 1)
45
+ return new Map(files.map((f) => [f, (seen.get(base(f)) ?? 0) > 1 ? relative(root, f).replace(/\\/g, '/').slice(0, -CHECK_SUFFIX.length) : base(f)]))
46
+ }
47
+
48
+ export type LegacyCheck = {
49
+ /** absolute path to the script */
50
+ path: string
51
+ /** display + cache name — file base, disambiguated by relative path on collision */
52
+ name: string
53
+ /** `export const blocking = true | false`, read statically. null = the file
54
+ * never said, which is an error: a check that silently does not block is
55
+ * the failure nobody sees. */
56
+ blocking: boolean | null
57
+ /** `export const inputs = ['a', 'b']`, read statically — the repo-relative
58
+ * paths this check reads. Declares the CONTENT population for the ledger
59
+ * key layer (ledger/git-key.ts). null = never cached, which is the safe
60
+ * default and the whole point of default deny. */
61
+ inputs: string[] | null
62
+ /** `export const ttl = '24h'`, read statically — declares the WORLD
63
+ * population: this check probes something no file on disk describes. */
64
+ ttl: string | null
65
+ /** `export const never = 'why'`, read statically — a DECIDED never
66
+ * cacheable, which takes the check off the burn-down list. */
67
+ never: string | null
68
+ /** `export const gate = false`, read statically — this check is NOT part of
69
+ * `shipkit ci`. `ci` answers "is my change good"; a check that answers
70
+ * something else, and that can sit red for months without anything being
71
+ * wrong, does not belong in the answer. `shipkit check` still runs it.
72
+ * Default true: a check is in the gate unless it opts out by name. */
73
+ gate: boolean
74
+ }
75
+
76
+ // read without importing: checks run as subprocesses, and discovery must stay
77
+ // pure-fs. `export const blocking = true` (or false), nothing cleverer.
78
+ // the tail is the gate's OWN formatter: oxfmt terminates every statement, and
79
+ // the fix pass runs it, so a reader that refuses a semicolon refuses the shape
80
+ // the gate itself just wrote.
81
+ const TAIL = String.raw`(?:\s+as\s+const)?\s*;?\s*$`
82
+ const BLOCKING_RE = new RegExp(String.raw`^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)` + TAIL, 'm')
83
+ // `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
84
+ // (a computed list, a spread) is deliberately unreadable here and lands as no
85
+ // declaration, which means never cached: a scanner guessing at a program is
86
+ // how a key comes to describe files the check does not read.
87
+ const INPUTS_RE = /^\s*export\s+const\s+inputs\s*(?::[^=]*)?=\s*\[([^\]]*)\]/m
88
+ const TTL_RE = /^\s*export\s+const\s+ttl\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
89
+ const NEVER_RE = /^\s*export\s+const\s+never\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
90
+ // `export const gate = false` takes a check out of `shipkit ci`. Only the
91
+ // literal false opts out: anything unreadable stays in the gate, because a
92
+ // scanner that guesses its way to SKIPPING a check is how coverage disappears
93
+ const GATE_RE = new RegExp(String.raw`^\s*export\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*false` + TAIL, 'm')
94
+ const STRING_ITEM_RE = /['"]([^'"]+)['"]/g
95
+
96
+ /** the declarations of one legacy check script, read out of its source. Null
97
+ * when the source is a check file instead */
98
+ export function readLegacyCheck(path: string, name: string, src: string): LegacyCheck | null {
99
+ if (!isLegacyCheckSource(src)) return null
100
+ const m = src.match(BLOCKING_RE)
101
+ const im = src.match(INPUTS_RE)
102
+ const items = im == null ? [] : [...im[1]!.matchAll(STRING_ITEM_RE)].map((x) => x[1]!)
103
+ return {
104
+ path,
105
+ name,
106
+ blocking: m == null ? null : m[1] === 'true',
107
+ inputs: items.length > 0 ? items : null,
108
+ ttl: src.match(TTL_RE)?.[1] ?? null,
109
+ never: src.match(NEVER_RE)?.[1] ?? null,
110
+ gate: src.match(GATE_RE) == null,
111
+ }
112
+ }