rvlib-shipkit 0.38.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +371 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +216 -18
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -1,18 +1,48 @@
1
1
  // Local graph server: `load()` runs on EVERY request, so a reload always shows
2
2
  // the wiki as it is on disk right now — no stale exports, no build step.
3
+ import { relative } from 'path'
4
+ import { gitRoot } from '../core/git-root.ts'
5
+ import { sameOrigin } from '../serve/same-origin.ts'
3
6
  import { graphHtml } from 'rvlib-shipkit/src/wiki/graph-page.ts'
7
+ import { acceptPositions, positionsFile, readPositions, seedPositions } from 'rvlib-shipkit/src/wiki/graph-positions.ts'
4
8
  import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
5
9
 
6
- export type ServeMdGraphConfig = { port: number; load: () => MdGraph; title?: string }
10
+ /** the one path from a graph to its page and back: the hub and the standalone
11
+ * server both mount it. `file` null = no memory, the page never saves */
12
+ export function graphRoutes(o: { load: () => MdGraph; title?: string; file: string | null; saveUrl: string }) {
13
+ return {
14
+ page(): string {
15
+ const g = o.load()
16
+ if (o.file == null) return graphHtml(g, { title: o.title })
17
+ return graphHtml(g, { title: o.title, seed: seedPositions(g, readPositions(o.file)), saveUrl: o.saveUrl })
18
+ },
19
+ save(req: Request): Promise<Response> | Response {
20
+ return o.file == null ? new Response('not found', { status: 404 }) : acceptPositions(req, o.file, o.load)
21
+ },
22
+ }
23
+ }
24
+
25
+ export type ServeMdGraphConfig = { port: number; load: () => MdGraph; title?: string; root?: string }
26
+
27
+ /** outside a git checkout the standalone graph keeps no memory: it has no
28
+ * untracked place to write to */
29
+ export function standalonePositionsFile(root: string): string | null {
30
+ const top = gitRoot(root)?.top
31
+ if (top == null) return null
32
+ return positionsFile(root, `wiki-serve.${relative(top, root) || 'root'}`)
33
+ }
7
34
 
8
- // routes: `/` the interactive graph · `/graph.json` the raw graph data
35
+ // routes: `/` the interactive graph · `/graph.json` the raw graph data · `/positions` the one save per display
9
36
  export function serveMdGraph(cfg: ServeMdGraphConfig) {
37
+ const routes = graphRoutes({ load: cfg.load, title: cfg.title, file: cfg.root == null ? null : standalonePositionsFile(cfg.root), saveUrl: '/positions' })
10
38
  return Bun.serve({
11
39
  port: cfg.port,
12
40
  fetch(req) {
13
41
  const path = new URL(req.url).pathname
14
- if (path === '/') return new Response(graphHtml(cfg.load(), { title: cfg.title }), { headers: { 'content-type': 'text/html; charset=utf-8' } })
42
+ if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the graph page', { status: 403 })
43
+ if (path === '/') return new Response(routes.page(), { headers: { 'content-type': 'text/html; charset=utf-8' } })
15
44
  if (path === '/graph.json') return Response.json(cfg.load())
45
+ if (path === '/positions') return routes.save(req)
16
46
  return new Response('not found', { status: 404 })
17
47
  },
18
48
  })
@@ -1,51 +0,0 @@
1
- // shipkit rules, render the rule registry: shipkit's built-in SK rules
2
- // plus whatever the repo registered through `repo.config.ts rules: []`.
3
- // Agents read ids HERE (or --json), never guess or mint one: the registry
4
- // is the single source, and a repo whose own invariants are absent from it
5
- // is a repo whose agents cannot know what binds them.
6
- import { Command, Option } from 'clipanion'
7
- import { RULES } from '../rules/rule-registry.ts'
8
- import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
9
-
10
- export type RuleRow = { id: string; title: string; owner: string; blocking: boolean; fixable: boolean; source: 'shipkit' | 'repo'; exempt?: string }
11
-
12
- /** the catalog: built-in rules, then the repo's own */
13
- export function ruleRows(config: RepoConfig | null): RuleRow[] {
14
- const row = (source: RuleRow['source']) => (r: { id: string; title: string; owner: string; blocking: boolean; fix?: unknown }): RuleRow =>
15
- ({ id: r.id, title: r.title, owner: r.owner, blocking: r.blocking, fixable: r.fix != null, source, ...(config?.ruleExempt?.[r.id] == null ? {} : { exempt: config.ruleExempt[r.id] }) })
16
- return [...RULES.map(row('shipkit')), ...(config?.rules ?? []).map(row('repo'))]
17
- }
18
-
19
- export class RulesCommand extends Command {
20
- static override paths = [['rules']]
21
-
22
- static override usage = Command.Usage({
23
- category: 'Health',
24
- description: 'every rule that runs here: whether it BLOCKS or only warns, what it lints, the owning doc, and whether --fix can repair it. Built-in SK rules plus the repo\'s own.',
25
- examples: [
26
- ['list rules', 'shipkit rules'],
27
- ['machine-readable', 'shipkit rules --json'],
28
- ],
29
- })
30
-
31
- json = Option.Boolean('--json', false, { description: 'machine-readable [{id, title, owner, blocking, fixable, source}]' })
32
-
33
- async execute(): Promise<number> {
34
- // a repo without repo.config.ts still gets the built-in catalog: this
35
- // command must answer "what rules exist" from anywhere
36
- const config = await loadRepoConfig(process.cwd()).catch(() => null)
37
- const repoRules = config?.rules ?? []
38
- const rows = ruleRows(config)
39
- if (this.json) {
40
- this.context.stdout.write(`${JSON.stringify(rows)}\n`)
41
- return 0
42
- }
43
- // a catalog, not a health run — no wire prefixes (`ok:` would assert
44
- // a passing check into anything that parses wire output)
45
- for (const r of rows)
46
- this.context.stdout.write(`${r.blocking ? 'BLOCKS ' : 'warns '}${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}${r.exempt == null ? '' : ` · EXEMPT here: ${r.exempt}`}\n`)
47
- if (repoRules.length > 0)
48
- this.context.stdout.write(`\n${RULES.length} built-in · ${repoRules.length} repo-local (repo.config.ts rules)\n`)
49
- return 0
50
- }
51
- }
@@ -1,82 +0,0 @@
1
- // shipkit units — THE inventory: every task, rule and discovered check on one
2
- // table, with what it declared about itself. `shipkit rules` covers a third of
3
- // it, `shipkit keys` answers a different question (what is a unit keyed on,
4
- // right now), and neither says whether a unit blocks you or only reports the
5
- // world. That answer had no surface, so it was reconstructed by reading source
6
- // files, which is how a repo ends up with checks nobody can account for.
7
- import { writeFileSync } from 'fs'
8
- import { resolve } from 'path'
9
- import { Command, Option } from 'clipanion'
10
- import { loadRepoConfig } from '../repo-config.ts'
11
- import { envTier, gateInventory, tieredTasks } from '../ledger/gate-units.ts'
12
- import { renderUnitTable } from '../ledger/unit-table.ts'
13
- import { classify } from '../ledger/git-key.ts'
14
-
15
- export class UnitsCommand extends Command {
16
- static override paths = [['units']]
17
-
18
- static override usage = Command.Usage({
19
- category: 'Health',
20
- description: 'every gate unit on one table: task, rule or check, whether it blocks or only warns, whether the gate runs it or only `shipkit check`, and how it caches. Reports, never gates',
21
- examples: [
22
- ['the whole inventory', 'shipkit units'],
23
- ['the big markdown table, written to a file', 'shipkit units --out STATUS.full.md'],
24
- ['only the ones the gate never runs', 'shipkit units --proactive'],
25
- ['machine-readable', 'shipkit units --json'],
26
- ],
27
- })
28
-
29
- json = Option.Boolean('--json', false, { description: 'machine-readable UnitRow[] with the resolved cache population' })
30
- md = Option.Boolean('--md', false, { description: 'the full markdown table on stdout, the same bytes --out writes' })
31
- out = Option.String('--out', { required: false, description: 'write the markdown table to this file instead of stdout' })
32
- blocking = Option.Boolean('--blocking', false, { description: 'only units whose failure can stop you' })
33
- proactive = Option.Boolean('--proactive', false, { description: 'only units the gate never runs (`shipkit check` reports them)' })
34
- full = Option.Boolean('--full', false, { description: 'resolve tasks the way a FULL-tier run resolves them' })
35
-
36
- async execute(): Promise<number> {
37
- const root = process.cwd()
38
- const c = await loadRepoConfig(root)
39
- const tier = this.full ? 'full' : envTier()
40
- let rows = gateInventory(root, c, tieredTasks(c, root, tier))
41
- if (this.blocking) rows = rows.filter((r) => r.blocking !== false)
42
- if (this.proactive) rows = rows.filter((r) => !r.gate)
43
-
44
- if (this.json) {
45
- this.context.stdout.write(`${JSON.stringify(rows.map((r) => ({ ...r, population: classify(r.decl).population })))}\n`)
46
- return 0
47
- }
48
-
49
- if (this.md || this.out != null) {
50
- const md = renderUnitTable({ name: c.name, rows })
51
- if (this.out != null) {
52
- const target = resolve(root, this.out)
53
- writeFileSync(target, `${md.trimEnd()}\n`)
54
- this.context.stdout.write(`ok: ${rows.length} unit(s) written to ${this.out}\n`)
55
- return 0
56
- }
57
- this.context.stdout.write(`${md}\n`)
58
- return 0
59
- }
60
-
61
- // a catalog, not a health run — no wire prefixes, or anything parsing
62
- // wire output reads this as a passing check
63
- this.context.stdout.write(`${'kind'.padEnd(5)} ${'unit'.padEnd(28)} ${'runs in'.padEnd(9)} ${'failure'.padEnd(10)} ${'cache'.padEnd(11)} ttl\n`)
64
- for (const r of rows) {
65
- const cls = classify(r.decl)
66
- const cache = cls.error != null ? 'BROKEN' : cls.population === 'none' ? (cls.decided === true ? 'never' : 'UNDECLARED') : cls.population
67
- const blocking = r.blocking == null ? 'UNDECLARED' : r.blocking ? 'blocks' : 'warns'
68
- const runsIn = r.optIn ? 'opt-in' : r.gate ? 'gate' : 'proactive'
69
- this.context.stdout.write(
70
- `${r.decl.kind.padEnd(5)} ${r.decl.id.padEnd(28)} ${runsIn.padEnd(9)} ${blocking.padEnd(10)} ${cache.padEnd(11)} ${r.decl.ttl ?? ''}\n`,
71
- )
72
- }
73
- const undeclared = rows.filter((r) => { const cl = classify(r.decl); return cl.population === 'none' && cl.decided !== true }).length
74
- const proactive = rows.filter((r) => !r.gate && !r.optIn).length
75
- this.context.stdout.write(
76
- `\n${rows.length} units — ${rows.length - proactive} in the gate, ${proactive} proactive · ${undeclared} still uncacheable with nobody having looked\n`,
77
- )
78
- if (undeclared > 0) this.context.stdout.write(` why each one: shipkit keys --burn-down\n`)
79
- this.context.stdout.write(` the full table, refreshed by every gate run: STATUS.full.md\n`)
80
- return 0
81
- }
82
- }
@@ -1,32 +0,0 @@
1
- // graphs collection — the MOUNTED md graphs as CHECKED section entries. The
2
- // mounts arrive as data from the hub's own route resolution (serve/run-serve),
3
- // never re-derived here: a card linking at an unmounted route renders the 404
4
- // body inside its iframe. Each instance carries its lint state (orphans +
5
- // dangling refs = yellow with counts; clean = quiet). A graph whose lint
6
- // belongs to another repo is link-only, never double-judged here.
7
- import { lintMd } from '../wiki/lint-md.ts'
8
- import type { GraphMount } from '../wiki/graph-mount.ts'
9
- import type { Collection, Instance } from './collection-types.ts'
10
-
11
- export function graphsCollection(mounts: GraphMount[]): Collection {
12
- return {
13
- id: 'graphs',
14
- title: 'graphs',
15
- media: 'data',
16
- list: async () =>
17
- mounts.map((m): Instance => {
18
- const id = `graph:${m.id}`
19
- const href = `/graph/${m.id}`
20
- const label = m.note != null ? `${m.label} (${m.note})` : m.label
21
- if (m.lint == null) return { id, text: label, href }
22
- const r = lintMd({ root: m.lint.root, index: m.lint.index })
23
- const issues = r.orphans.length + r.dangling.length
24
- return {
25
- id,
26
- text: issues === 0 ? label : `${label} — ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s)`,
27
- severity: issues === 0 ? undefined : 'yellow',
28
- href,
29
- }
30
- }),
31
- }
32
- }
@@ -1,43 +0,0 @@
1
- // ONE contract evaluation per gate run.
2
- //
3
- // Two surfaces report fulfillness inside a single `shipkit ci`: the `contracts`
4
- // TASK, which exists because deploy tasks declare `deps: ['contracts']` and so
5
- // need a real ordering edge in the DAG, and rule SK002, which exists because a
6
- // `shipkit check` with no task phase must still report it. Both are load
7
- // bearing and neither can be deleted: dropping the task would silently ungate
8
- // every deploy that depends on it, since checks run AFTER every task.
9
- //
10
- // So they stay two REPORTS of one evaluation. The task runs first and its
11
- // result is what SK002 reads. THE TASK IS AUTHORITATIVE (contracts.md says so
12
- // too); SK002 evaluating on its own is the fallback for a run with no task
13
- // phase, not a second opinion.
14
- //
15
- // The scope is EXPLICIT and never ambient. A memo that outlived its run would
16
- // let the hub, which rebuilds its collection list on every request, answer
17
- // from a snapshot of whatever the last gate saw. Only a caller that owns a run
18
- // opens one.
19
- import { evaluateContracts, type ContractsConfig, type ContractsResult } from './contracts-engine.ts'
20
- import type { ContractManifest } from './contract-types.ts'
21
-
22
- let open = false
23
- let cached: { key: string; result: Promise<ContractsResult> } | null = null
24
-
25
- /** run `fn` as ONE gate run: contract evaluation inside it happens once. */
26
- export async function withContractsScope<T>(fn: () => Promise<T>): Promise<T> {
27
- const outer = open
28
- open = true
29
- try { return await fn() } finally { if (!outer) { open = false; cached = null } }
30
- }
31
-
32
- /** the evaluation, memoised only while a run scope is open. Outside one it is
33
- * a plain call, so `shipkit contracts` and the hub always measure reality. */
34
- export function evaluateContractsOnce(manifests: ContractManifest[], cfg: ContractsConfig): Promise<ContractsResult> {
35
- if (!open) return evaluateContracts(manifests, cfg)
36
- // the config decides the verdict, so it belongs in the identity: a run that
37
- // somehow evaluated two different configs must not serve one for the other
38
- const key = JSON.stringify(cfg)
39
- if (cached != null && cached.key === key) return cached.result
40
- const result = evaluateContracts(manifests, cfg)
41
- cached = { key, result }
42
- return result
43
- }
@@ -1,302 +0,0 @@
1
- // CheckSuite — declare tools / secrets / checks, then `await suite.run()`;
2
- // renders the v0 wire contract (wire.ts) with exit code.
3
- //
4
- // The TTL cache is the API-spam guard: a passing check with `remainValidFor`
5
- // replays without touching the network. Only clean passes are cached;
6
- // WARN/FAIL evict. Busts: version bump, secret rotation (key hashes the
7
- // secret value), `<freshEnv>=1`.
8
- //
9
- // STORAGE is not this module's concern any more: ledger/store.ts owns the
10
- // one store, its shape, and its housekeeping (one immutable file per key,
11
- // tmp + rename, age-based sweep). This is the WORLD half of the three
12
- // populations — an answer nothing on disk describes, remembered for a TTL and
13
- // busted by rotation. The CONTENT half is keyed instead of timed and lives in
14
- // ledger/ledger.ts. Same store, different key.
15
- //
16
- // Secret VALUES never enter this module's state beyond the injected
17
- // getSecret call + a truncated hash for cache-busting.
18
- import { existsSync } from 'fs'
19
- import { CheckCtx, RateLimited, ToolUnresolved } from './check-ctx.ts'
20
- import { dropStored, entryPath, FRESH_ENV, readStored, sweepStore, writeStored } from '../ledger/store.ts'
21
- import { fmtStamp, formatLine, parseDuration, type Line } from './wire.ts'
22
-
23
- type Logic = (t: CheckCtx) => unknown
24
- type Prep = { skip?: string; cacheExtra?: string }
25
- type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic; blocking: boolean }
26
-
27
- /** the longest a MEASURED NEGATIVE is remembered, whatever TTL the unit
28
- * declared. Long enough to stop a re-ask storm inside one working session,
29
- * short enough that the world being fixed is noticed the same day. A clean
30
- * answer is not capped: "fine" is what a long TTL is for. */
31
- const NEGATIVE_TTL_CAP_MS = 60 * 60 * 1000
32
-
33
- export type GetSecret = (name: string, field: 'get' | 'account') => string | null
34
-
35
- export type SecretSpec = {
36
- remainValidFor?: string | number
37
- version?: number
38
- /** appended to the unstored skip message — a POINTER to where to mint (URL, file, or doc section), never inline mint steps */
39
- skipHint: string
40
- }
41
-
42
- export type CheckOutcome = { lines: Line[]; status: 'pass' | 'fail' | 'warn' | 'skip'; cached: boolean; /** wall time of the LOGIC, ms — 0 on a replay, since a replay measured nothing */ ms: number }
43
-
44
- export type CheckSuiteOpts = {
45
- name: string
46
- /** the ONE store root (ledger/store.ts storeRoot). Injected rather than
47
- * resolved here so a test can point a suite at a scratch dir. */
48
- storeDir: string
49
- now?: () => number
50
- /** secret backend — consumers inject their own store; default = nothing stored, every secret check skips */
51
- getSecret?: GetSecret
52
- /** TEST SEAM: the env var that busts this suite's replays. Production is
53
- * always the one house-wide flag (ledger/store.ts FRESH_ENV); a test
54
- * overrides it so busting one suite cannot poison a neighbour's run. */
55
- freshEnv?: string
56
- /** per-check completion tap (the TUI streams results in) — cached replays
57
- * report cached:true, prep-skips (unstored secret) report status 'skip' */
58
- onResult?: (name: string, r: CheckOutcome) => void
59
- }
60
-
61
- export class CheckSuite {
62
- readonly name: string
63
- private runners: Runner[] = []
64
- private toolPaths = new Map<string, string>()
65
- private storeDir: string
66
- private now: () => number
67
- private getSecret: GetSecret
68
- private freshEnv: string
69
- private onResult?: (name: string, r: CheckOutcome) => void
70
-
71
- constructor(opts: CheckSuiteOpts) {
72
- this.name = opts.name
73
- this.storeDir = opts.storeDir
74
- this.now = opts.now ?? Date.now
75
- this.getSecret = opts.getSecret ?? (() => null)
76
- this.freshEnv = opts.freshEnv ?? FRESH_ENV
77
- this.onResult = opts.onResult
78
- }
79
-
80
- /** `blocking` decides what a NEGATIVE result may be cached as: see the
81
- * measured-negative rule in execute(). Default true, the safe side: a
82
- * check that never declared itself is treated as being about this repo,
83
- * so its failures are never replayed. */
84
- addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; cacheExtra?: () => string; logic: Logic; blocking?: boolean }): void {
85
- this.runners.push({ name: spec.name, ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0, version: spec.version ?? 1, prep: spec.cacheExtra != null ? () => ({ cacheExtra: spec.cacheExtra!() }) : undefined, logic: spec.logic, blocking: spec.blocking ?? true })
86
- }
87
-
88
- defineRequiredTool(name: string, opts: { candidates?: string[]; probe?: { args: string[]; expect?: RegExp }; fix: string }): void {
89
- this.addCheck({
90
- name,
91
- logic: async (t) => {
92
- // explicit candidate paths beat ambient PATH — a stale global (e.g. tsc 4.x) must not shadow the pinned one
93
- const bin = opts.candidates?.find((c) => existsSync(c)) ?? Bun.which(name)
94
- if (bin == null) return t.fail(`${name} not installed`, opts.fix)
95
- this.toolPaths.set(name, bin)
96
- if (opts.probe == null) return t.ok(`${name} present`)
97
- const p = await t.exec([bin, ...opts.probe.args])
98
- const first = p.out.trim().split('\n')[0] ?? ''
99
- if (p.code !== 0 || (opts.probe.expect != null && !opts.probe.expect.test(p.out.trim())))
100
- return t.fail(`${name} present but broken (${first.slice(0, 120)})`, opts.fix)
101
- // version-ish summary, not the whole banner (some CLIs append build date + ref)
102
- t.ok(`${name} ${(first.split(',')[0] ?? first).slice(0, 80)}`)
103
- },
104
- })
105
- }
106
-
107
- defineRequiredSecret(spec: SecretSpec & { name: string; account?: false; validate: (value: string, t: CheckCtx) => unknown }): void
108
- defineRequiredSecret(spec: SecretSpec & { name: string; account: true; validate: (cred: { value: string; account: string }, t: CheckCtx) => unknown }): void
109
- defineRequiredSecret(spec: SecretSpec & { name: string[]; validate: (values: string[], t: CheckCtx) => unknown }): void
110
- defineRequiredSecret(spec: SecretSpec & { name: string | string[]; account?: boolean; validate: (v: never, t: CheckCtx) => unknown }): void {
111
- const names = [spec.name].flat()
112
- const primary = names[0]
113
- if (primary == null) throw new Error('defineRequiredSecret: name must be non-empty')
114
- // probes may run without typechecking — enforce the required pointer at define time too
115
- if (typeof spec.skipHint !== 'string' || spec.skipHint.length === 0)
116
- throw new Error(`defineRequiredSecret(${primary}): skipHint is required — a pointer to where to mint (URL, file, or doc section)`)
117
- const validate = spec.validate as (v: unknown, t: CheckCtx) => unknown
118
- let payload: unknown
119
- this.runners.push({
120
- // conservative on purpose: a secret probe keeps today's behaviour,
121
- // where no negative is remembered. Its cache key already hashes the
122
- // secret VALUE, so a rotation busts it, which would make caching a
123
- // measured 403 sound. That is a severity call for whoever owns the
124
- // secrets surface, not a side effect of this change.
125
- blocking: true,
126
- // bare secret name — this shows up in human-facing WARN/FAIL lines
127
- name: names.length === 1 ? primary : primary.split('/').slice(0, 2).join('/') + '/*',
128
- ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0,
129
- version: spec.version ?? 1,
130
- prep: () => {
131
- const values = names.map((n) => this.getSecret(n, 'get'))
132
- const account = spec.account === true ? this.getSecret(primary, 'account') : undefined
133
- const missing = names.filter((_, i) => values[i] == null)
134
- if (spec.account === true && account == null) missing.push(`${primary} (account field)`)
135
- if (missing.length > 0)
136
- return { skip: `${missing.join(', ')} unstored — ${spec.skipHint}` }
137
- payload = spec.account === true ? { value: values[0], account } : Array.isArray(spec.name) ? values : values[0]
138
- // secret rotation busts the cache: key = hash of the value(s)
139
- const material = [...(values as string[]), account ?? ''].join('\0')
140
- return { cacheExtra: new Bun.CryptoHasher('sha256').update(material).digest('hex').slice(0, 16) }
141
- },
142
- logic: (t) => validate(payload, t),
143
- })
144
- }
145
-
146
- /** the key a world answer is remembered under: the check's own version
147
- * plus whatever the prep declared (a secret's value hash, a rule's
148
- * cacheKey). A version bump and a rotation both move it, so neither can
149
- * replay across the change. */
150
- private cacheKey(r: Runner, extra: string): string {
151
- return `v${r.version}\0${extra}`
152
- }
153
-
154
- // test seam — run() prints + exits; execute() returns the structured result
155
- /** ONE check: cache replay, run, classify, and what it wants remembered.
156
- * Pure with respect to its neighbours, which is what lets execute() run
157
- * them together. */
158
- private async runOne(r: Runner): Promise<{ lines: Line[]; skip: string | null; ran: boolean; failed: boolean }> {
159
- const prep = r.prep?.() ?? {}
160
- if (prep.skip != null) {
161
- this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false, ms: 0 })
162
- return { lines: [], skip: prep.skip, ran: false, failed: false }
163
- }
164
- const key = this.cacheKey(r, prep.cacheExtra ?? '')
165
- const path = entryPath(this.storeDir, this.name, r.name, key)
166
- const entry = readStored<Line[]>(path, key)
167
- // an entry may keep for LESS than the declared TTL: a measured negative
168
- // writes its own shorter window (below), so the run after a fix sees
169
- // the fix rather than the memory of the breakage
170
- const keeps = entry?.keeps ?? r.ttlMs
171
- const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
172
- && entry != null && this.now() - entry.at < keeps && this.now() >= entry.at
173
- if (fresh) {
174
- // the stamp was baked in when the check RAN (below): a replay
175
- // prints the stored lines VERBATIM, so the live run and every
176
- // later replay render the same string, and the line changes
177
- // only when a re-validation happens. Annotating replays but
178
- // not live runs still diffed on every fresh-vs-cached
179
- // alternation, so neither side annotates
180
- // a replayed WORLD negative keeps its severity: it is still the
181
- // answer, and demoting it to a pass would hide a real finding
182
- const replayedWarn = entry.payload.some((l) => l.kind === 'warn' || l.kind === 'fail')
183
- this.onResult?.(r.name, { lines: entry.payload, status: replayedWarn ? 'warn' : 'pass', cached: true, ms: 0 })
184
- return { lines: entry.payload, skip: null, ran: true, failed: false }
185
- }
186
- const t = new CheckCtx(this.toolPaths)
187
- const started = performance.now()
188
- try {
189
- await r.logic(t)
190
- } catch (e) {
191
- // both of these mean the probe never reached its subject, so the
192
- // run has no answer to remember
193
- if (e instanceof RateLimited) t.unreachable(`${r.name} not validated this run (${e.message}) — retry later`)
194
- else if (e instanceof ToolUnresolved) { t.unmeasured = true; t.note(`${r.name} skipped (${e.message})`) }
195
- else t.fail(`${r.name} errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
196
- }
197
- // AN ANSWER CACHES. THE ABSENCE OF ONE DOES NOT. That distinction, not
198
- // pass-versus-fail, is what decides cacheability here, and a future
199
- // reader is warned off "fixing" the asymmetry:
200
- //
201
- // measured negative a probe that RAN and came back bad ("the box is
202
- // off", "the token answered 403", "five repos are
203
- // uncatalogued") is a real answer about the world.
204
- // Replaying it is CORRECT for a WHILE: the world
205
- // does not fix itself between two gate runs, and
206
- // re-asking every 30 seconds spends a third-party
207
- // API call to be told the same thing. It is NOT
208
- // correct for the full TTL, and that asymmetry is
209
- // deliberate: a clean pass says the world is fine,
210
- // which a long TTL is exactly right for, while a
211
- // negative names something a HUMAN is expected to
212
- // go fix, and the fix is invisible from here. A
213
- // 100d TTL over a revoked token would show the
214
- // same red for 100 days after it was re-minted,
215
- // because the token value is not in this key. So a
216
- // negative keeps for NEGATIVE_TTL_CAP at most.
217
- // unmeasured a probe that could not run at all ("registry
218
- // unreachable", "no cred", "rate limited", tool
219
- // missing) produced no answer. Caching it asserts
220
- // a dead network as fact long after it recovered.
221
- // `skip` lines and the unmeasured flag mark these.
222
- //
223
- // A measured negative caches ONLY for a check about the WORLD. A
224
- // blocking check reports THIS REPO, where the whole point is that a
225
- // human fixes it and the next run goes green: replaying its red for a
226
- // TTL would be the cache lying about work already done.
227
- const unmeasured = t.unmeasured || t.lines.some((l) => l.kind === 'skip')
228
- const clean = !t.failed && !t.warned
229
- const cacheable = r.ttlMs > 0 && !unmeasured && (clean || !r.blocking)
230
- // a clean answer keeps for the whole declared TTL. a negative keeps
231
- // only long enough to stop the re-ask storm, so the run after a human
232
- // fixes the world sees the fix instead of the memory of the breakage
233
- const keepsFor = clean ? r.ttlMs : Math.min(r.ttlMs, NEGATIVE_TTL_CAP_MS)
234
- const lines = cacheable
235
- ? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (${clean ? 'cached' : 'measured'} at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + keepsFor)})` } : l))
236
- : t.lines
237
- this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false, ms: performance.now() - started })
238
- // this check writes its OWN file, and only its own: nothing here reads
239
- // or rewrites a neighbour's entry, so a racing runner can lose nothing
240
- if (r.ttlMs > 0) {
241
- if (cacheable) writeStored(path, key, lines, this.now(), keepsFor)
242
- // an unmeasured run evicts whatever was stored: a later run must
243
- // re-probe rather than replay an answer recorded before the outage.
244
- // A racing runner may have written a good answer into that same
245
- // file a moment ago and lose it here, which costs one re-probe and
246
- // can never produce a stale green, so it is the safe direction.
247
- else if (entry != null) dropStored(path)
248
- }
249
- return { lines, skip: null, ran: true, failed: t.failed }
250
- }
251
-
252
- async execute(): Promise<{ lines: Line[]; exitCode: number }> {
253
- sweepStore(this.storeDir, this.name, this.now())
254
-
255
- // CONCURRENT, bounded. Checks are independent by contract: each answers
256
- // one question about the world or the repo and shares nothing with its
257
- // neighbours, and most of the wall time is a network round trip or a
258
- // cold `bun` start. In series, twenty checks cost the SUM; together
259
- // they cost the slowest one. Measured on this repo: 18.4s to 6.0s.
260
- // Order is preserved by collecting per runner and concatenating in
261
- // declaration order, so the wire output is byte-identical to the
262
- // sequential run and the frozen contract still holds.
263
- const cap = Math.max(2, Math.min(8, navigator.hardwareConcurrency || 4))
264
- const results = new Array<Awaited<ReturnType<CheckSuite['runOne']>>>(this.runners.length)
265
- let next = 0
266
- const worker = async (): Promise<void> => {
267
- for (;;) {
268
- const i = next++
269
- if (i >= this.runners.length) return
270
- results[i] = await this.runOne(this.runners[i]!)
271
- }
272
- }
273
- await Promise.all(Array.from({ length: Math.min(cap, this.runners.length) }, () => worker()))
274
-
275
- const out: Line[] = []
276
- const skipped: string[] = []
277
- let ran = 0
278
- let anyFail = false
279
- for (let i = 0; i < this.runners.length; i++) {
280
- const r = results[i]!
281
- out.push(...r.lines)
282
- if (r.skip != null) skipped.push(r.skip)
283
- if (r.ran) ran++
284
- if (r.failed) anyFail = true
285
- }
286
-
287
- // contract: `skip:` lines ONLY when no check ran at all (whole probe is
288
- // moot without the creds); otherwise a neutral note so passing probes
289
- // are not relabeled 'check skip'
290
- if (ran === 0) out.push(...skipped.map((s): Line => ({ kind: 'skip', text: `${s} (creds gate reports it)` })))
291
- else out.push(...skipped.map((s): Line => ({ kind: 'note', text: `${s} — validation skipped (creds gate reports it)` })))
292
-
293
- return { lines: out, exitCode: anyFail ? 1 : 0 }
294
- }
295
-
296
- async run(): Promise<never> {
297
- const { lines, exitCode } = await this.execute()
298
- const color = process.stdout.isTTY && process.env.NO_COLOR == null
299
- for (const l of lines) console.log(formatLine(l, color))
300
- process.exit(exitCode)
301
- }
302
- }