rvlib-shipkit 1.3.0 → 1.5.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 (203) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +12 -2
  7. package/src/check.ts +73 -98
  8. package/src/checks/SK001-package-json-shape.check.ts +26 -4
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  10. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  11. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  12. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  13. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  14. package/src/checks/SK007-github-visibility.check.ts +4 -2
  15. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  16. package/src/checks/SK009-commit-ai-tells.check.ts +94 -74
  17. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  18. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  19. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  20. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  21. package/src/checks/SK014-prose-ste.check.ts +20 -6
  22. package/src/checks/SK015-nested-repo.check.ts +4 -2
  23. package/src/checks/SK016-legacy-check-shapes.check.ts +23 -16
  24. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  25. package/src/checks/SK018-test-files-run.check.ts +6 -2
  26. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  27. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  28. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  29. package/src/checks/SK024-identity-colors.check.ts +5 -3
  30. package/src/checks/SK025-release-drift.check.ts +4 -5
  31. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  32. package/src/checks/SK027-agent-wiki-links.check.ts +34 -9
  33. package/src/checks/SK028-readme.check.ts +10 -2
  34. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  35. package/src/cli/cmd_Asks.ts +5 -5
  36. package/src/cli/cmd_Assets.ts +7 -7
  37. package/src/cli/cmd_Bump.ts +12 -11
  38. package/src/cli/cmd_Check.ts +8 -7
  39. package/src/cli/cmd_Checks.ts +7 -6
  40. package/src/cli/cmd_Ci.ts +12 -11
  41. package/src/cli/cmd_Commands.ts +16 -13
  42. package/src/cli/cmd_ConfigKeys.ts +7 -6
  43. package/src/cli/cmd_Contracts.ts +3 -3
  44. package/src/cli/cmd_CrossRepoActivity.ts +10 -6
  45. package/src/cli/cmd_Fleet.ts +9 -7
  46. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  47. package/src/cli/cmd_Home.ts +23 -17
  48. package/src/cli/cmd_Hooks.ts +6 -5
  49. package/src/cli/cmd_Init.ts +5 -4
  50. package/src/cli/cmd_Keys.ts +8 -7
  51. package/src/cli/cmd_Mv.ts +7 -6
  52. package/src/cli/cmd_Needs.ts +4 -3
  53. package/src/cli/cmd_Overview.ts +4 -3
  54. package/src/cli/cmd_Pb.ts +2 -2
  55. package/src/cli/cmd_Planned.ts +6 -5
  56. package/src/cli/cmd_Prose.ts +5 -5
  57. package/src/cli/cmd_Reflect.ts +7 -7
  58. package/src/cli/cmd_Release.ts +6 -5
  59. package/src/cli/cmd_Releases.ts +5 -4
  60. package/src/cli/cmd_Serve.ts +4 -3
  61. package/src/cli/cmd_Smoke.ts +5 -4
  62. package/src/cli/cmd_Start.ts +7 -6
  63. package/src/cli/cmd_Status.ts +4 -3
  64. package/src/cli/cmd_Tui.ts +3 -2
  65. package/src/cli/cmd_When.ts +71 -0
  66. package/src/cli/cmd_Wiki.ts +4 -4
  67. package/src/cli.ts +7 -2
  68. package/src/collections/checks.ts +2 -0
  69. package/src/collections/collection-types.ts +24 -4
  70. package/src/collections/providers.ts +6 -2
  71. package/src/contracts/apply-contracts.ts +9 -8
  72. package/src/contracts/contract-types.ts +8 -2
  73. package/src/contracts/contracts-engine.ts +34 -13
  74. package/src/contracts/manifests/identity.contract.ts +6 -5
  75. package/src/contracts/manifests/social.contract.ts +4 -2
  76. package/src/contracts/manifests/steam.contract.ts +7 -3
  77. package/src/contracts/web-surface.ts +59 -0
  78. package/src/core/budget.ts +19 -9
  79. package/src/core/canonical-scripts.ts +1 -1
  80. package/src/core/check-runner.ts +63 -25
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +31 -7
  83. package/src/core/layout.ts +2 -0
  84. package/src/core/legacy-shapes.ts +39 -10
  85. package/src/core/motherhome.ts +88 -66
  86. package/src/core/one-way-in-policy.ts +2 -2
  87. package/src/core/outcome-words.ts +18 -2
  88. package/src/core/port-check-cli.ts +52 -0
  89. package/src/core/port-check-file.ts +295 -0
  90. package/src/core/port-check-files.ts +31 -0
  91. package/src/core/retention.ts +0 -14
  92. package/src/core/run-check.ts +14 -14
  93. package/src/core/run-ci.ts +11 -7
  94. package/src/core/run-fix.ts +30 -23
  95. package/src/core/run-fixers.ts +42 -5
  96. package/src/core/runs-matrix.ts +179 -0
  97. package/src/core/runs.ts +242 -0
  98. package/src/core/update-policy.ts +20 -0
  99. package/src/core/when-block.ts +29 -30
  100. package/src/core/when.ts +41 -103
  101. package/src/core/wire.ts +34 -9
  102. package/src/fleet/scan-fleet.ts +20 -4
  103. package/src/fleet/update-fleet.ts +6 -6
  104. package/src/home/activity-shape.ts +4 -1
  105. package/src/home/activity.ts +22 -15
  106. package/src/home/gui-app/ShipkitGui.swift +144 -25
  107. package/src/home/gui-app.ts +93 -30
  108. package/src/home/home-launchd.ts +30 -6
  109. package/src/home/home-routes.ts +14 -2
  110. package/src/home/home-start.ts +33 -5
  111. package/src/home/hubs.ts +5 -5
  112. package/src/home/machine-stores.ts +4 -2
  113. package/src/home/open-app.ts +23 -10
  114. package/src/home/pm-config.ts +15 -11
  115. package/src/home/pm-inputs.ts +76 -0
  116. package/src/home/pm.ts +11 -3
  117. package/src/home/process-rows.ts +23 -6
  118. package/src/home/resources.ts +67 -15
  119. package/src/home/run-daemon.ts +6 -1
  120. package/src/home/run-pm.ts +30 -5
  121. package/src/home/serve-web.ts +2 -2
  122. package/src/home/supervise.ts +24 -1
  123. package/src/home/widgets.ts +32 -3
  124. package/src/hooks/commit-message.ts +2 -3
  125. package/src/hooks/run-hooks.ts +60 -6
  126. package/src/init-repo.ts +6 -6
  127. package/src/ledger/gate-checks.ts +115 -61
  128. package/src/ledger/git-key.ts +5 -1
  129. package/src/ledger/ledger.ts +5 -5
  130. package/src/ledger/store.ts +11 -0
  131. package/src/ledger/toolchain-identity.ts +45 -27
  132. package/src/ledger/world-cache.ts +17 -15
  133. package/src/plan/repo-source.ts +18 -39
  134. package/src/release/run-release.ts +5 -0
  135. package/src/repo-config.ts +36 -56
  136. package/src/rules/rule.ts +2 -2
  137. package/src/scaffold/files/agent-coding.md +1 -1
  138. package/src/scaffold/files/agent-index.md +1 -1
  139. package/src/scaffold/files/verify-skill.md +2 -2
  140. package/src/serve/hub-app/activity-st.ts +5 -4
  141. package/src/serve/hub-app/activity-view.tsx +4 -4
  142. package/src/serve/hub-app/admin-view.tsx +13 -11
  143. package/src/serve/hub-app/check-view.tsx +66 -46
  144. package/src/serve/hub-app/check-words.ts +1 -11
  145. package/src/serve/hub-app/checks-svc.ts +16 -36
  146. package/src/serve/hub-app/col-tables-view.tsx +131 -0
  147. package/src/serve/hub-app/col-tables.ts +115 -0
  148. package/src/serve/hub-app/cols-svc.ts +33 -4
  149. package/src/serve/hub-app/dock-st.ts +3 -2
  150. package/src/serve/hub-app/dock-view.tsx +36 -0
  151. package/src/serve/hub-app/dock.css +7 -1
  152. package/src/serve/hub-app/float-cards.tsx +30 -0
  153. package/src/serve/hub-app/floating.tsx +41 -4
  154. package/src/serve/hub-app/git-st.ts +42 -0
  155. package/src/serve/hub-app/git-view.tsx +61 -0
  156. package/src/serve/hub-app/hash-route.ts +45 -1
  157. package/src/serve/hub-app/header-bar.tsx +62 -72
  158. package/src/serve/hub-app/health-svc.ts +3 -2
  159. package/src/serve/hub-app/home-st.ts +15 -3
  160. package/src/serve/hub-app/home-strip.css +69 -19
  161. package/src/serve/hub-app/home-strip.tsx +192 -118
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +21 -5
  164. package/src/serve/hub-app/hub-types.ts +11 -5
  165. package/src/serve/hub-app/hub.css +88 -44
  166. package/src/serve/hub-app/icons.tsx +15 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +11 -8
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +34 -49
  171. package/src/serve/hub-app/palette-st.ts +5 -5
  172. package/src/serve/hub-app/palette-view.tsx +1 -1
  173. package/src/serve/hub-app/plan-view.tsx +51 -0
  174. package/src/serve/hub-app/planned-st.ts +9 -4
  175. package/src/serve/hub-app/processes-view.tsx +38 -0
  176. package/src/serve/hub-app/project-list.tsx +3 -3
  177. package/src/serve/hub-app/projects-svc.ts +15 -10
  178. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  179. package/src/serve/hub-app/reach.ts +36 -0
  180. package/src/serve/hub-app/readme-svc.ts +3 -2
  181. package/src/serve/hub-app/repo-sections.ts +117 -0
  182. package/src/serve/hub-app/repo-st.ts +47 -10
  183. package/src/serve/hub-app/runs-tabs.ts +1 -1
  184. package/src/serve/hub-app/sidebar.tsx +12 -37
  185. package/src/serve/hub-app/timeline-svc.ts +3 -2
  186. package/src/serve/hub-app/timeline-view.tsx +14 -11
  187. package/src/serve/hub-app/tree-build.ts +149 -155
  188. package/src/serve/hub-app/tree-row.tsx +39 -9
  189. package/src/serve/hub-app/tree-svc.ts +70 -49
  190. package/src/serve/hub-app/when-view.tsx +89 -0
  191. package/src/serve/hub-app/when.css +17 -0
  192. package/src/serve/hub-runtime.ts +3 -16
  193. package/src/serve/project-gutter.ts +1 -2
  194. package/src/serve/run-serve.ts +1 -1
  195. package/src/serve/serve-hub.ts +4 -14
  196. package/src/serve/warm-cell.ts +49 -16
  197. package/src/suite/judge-suites.ts +42 -17
  198. package/src/suite/suite-cli.ts +5 -8
  199. package/src/wiki/lint-md.ts +2 -0
  200. package/src/wiki/scan-md.ts +37 -10
  201. package/src/serve/hub-app/processes-svc.ts +0 -63
  202. package/src/serve/hub-app/repo-sections.tsx +0 -61
  203. package/src/serve/repo-processes.ts +0 -89
@@ -0,0 +1,242 @@
1
+ // WHERE A CHECK RUNS AND HOW ITS ANSWER IS KEPT, declared in full by every
2
+ // check, with no default anywhere.
3
+ //
4
+ // Every context that can run a check body or its repair is one key of `runs`,
5
+ // plus `fix` (which command applies the repair), `cache` (how the answer is
6
+ // kept) and `applies` (whether this repo gives it anything to do). A check
7
+ // file writes every key. A row with no file to say it in (a floor slot, a
8
+ // config task, a legacy form) gets every key from the code that derives it,
9
+ // with the reason, so no row ever resolves to a value nobody wrote.
10
+ //
11
+ // the one constraint a reader breaks first: a missing key is a load error,
12
+ // never a default. `runsProblem` and `cacheProblem` are the only judges, and
13
+ // `check()` calls both at import time.
14
+ import type { CheckEnv } from '../check.ts'
15
+ import { cadenceOf, ONCE, type Every, type WhenInactiveDecl } from '../plan/cadence.ts'
16
+ import { parseDuration } from './wire.ts'
17
+
18
+ // ── the declaration ──────────────────────────────────────────────────────
19
+
20
+ /** the pre-commit hook: never, or in process on the working tree under a
21
+ * budget of at most PRE_COMMIT_MAX_MS */
22
+ export type OnPreCommit = false | { budgetMs: number }
23
+ /** the daemon after a quiet save burst: never, only when the check's key
24
+ * moved, or after every burst */
25
+ export type OnSave = false | 'moved' | 'burst'
26
+ /** the daemon on its own time: never, once while no answer exists for the
27
+ * fact's key, or on a period with what to do while the repo is inactive */
28
+ export type OnClock = false | 'once' | { every: Every; inactive: WhenInactiveDecl }
29
+
30
+ export type Runs = {
31
+ /** `git commit`, in the pre-commit hook, before the message */
32
+ onPreCommit: OnPreCommit
33
+ /** `shipkit ci`: the gate an agent runs once before it pushes its work,
34
+ * and a whole run from the hub's run button */
35
+ onCi: boolean
36
+ /** `shipkit ci --full`: the full tests tier. Every `onCi` row is here too */
37
+ onFull: boolean
38
+ /** the preflight of `shipkit bump` and `shipkit release`: full tier,
39
+ * frozen, no cache read, no repair */
40
+ onRelease: boolean
41
+ /** `shipkit check`, the TUI checks panel, the hub health run */
42
+ onHealth: boolean
43
+ /** the daemon, after a quiet save burst in the repo */
44
+ onSave: OnSave
45
+ /** the daemon, on its own time */
46
+ onClock: OnClock
47
+ }
48
+
49
+ /** which command applies the check's `repair`: none exists, the gate's fix
50
+ * pass (and `shipkit check --fix`), or only `shipkit check --fix <id>` */
51
+ export type FixWhen = 'none' | 'ci' | 'manual'
52
+
53
+ /** how an answer is kept. Every variant names its one parameter */
54
+ export type CacheDecl =
55
+ /** keyed on the content of these repo paths (and the check's own code) */
56
+ | { by: 'files'; inputs: string[]; key: ((env: CheckEnv) => string) | null }
57
+ /** keyed on what the body read through its context, re-checked before a replay */
58
+ | { by: 'reads' }
59
+ /** a clean answer about the world keeps for `span` */
60
+ | { by: 'ttl'; span: string; key: ((env: CheckEnv) => string) | null }
61
+ /** a decided fact, keyed on the declared value alone, never expires */
62
+ | { by: 'fact'; key: (env: CheckEnv) => string }
63
+ /** no key can describe it, with the reason: measured every time */
64
+ | { by: 'never'; why: string }
65
+
66
+ export type CacheBy = CacheDecl['by']
67
+
68
+ /** whether this repo gives the check anything to do. A reason back makes the
69
+ * row n/a in every context: never run, never planned, shown with the reason */
70
+ export type Applies = 'always' | ((env: CheckEnv) => string | null)
71
+
72
+ export const RUNS_KEYS = ['onPreCommit', 'onCi', 'onFull', 'onRelease', 'onHealth', 'onSave', 'onClock'] as const
73
+ export type RunsAxis = (typeof RUNS_KEYS)[number]
74
+ export const CACHE_BY: readonly CacheBy[] = ['files', 'reads', 'ttl', 'fact', 'never']
75
+ export const FIX_WHEN: readonly FixWhen[] = ['none', 'ci', 'manual']
76
+
77
+ /** the ceiling a pre-commit check may declare. The hook boots in about
78
+ * 500ms, and the text gates it already runs cost 50 to 100ms together, so a
79
+ * check that needs more than this belongs in `onCi` */
80
+ export const PRE_COMMIT_MAX_MS = 100
81
+
82
+ // ── the judges ───────────────────────────────────────────────────────────
83
+
84
+ const isBool = (v: unknown): v is boolean => typeof v === 'boolean'
85
+
86
+ function everyProblem(v: unknown): string | null {
87
+ if (typeof v === 'function') return null
88
+ if (v == null || v === ONCE) return `onClock.every takes a span, a range, a cron or a function, never ${JSON.stringify(v)} (use onClock: false or 'once')`
89
+ const c = cadenceOf(v as Every & (string | [string, string]), '')
90
+ return typeof c === 'string' ? `onClock.every: ${c}` : null
91
+ }
92
+
93
+ /** what is wrong with a `runs` object, or null. Every key is required */
94
+ export function runsProblem(r: unknown): string | null {
95
+ if (r == null || typeof r !== 'object') return `runs is required: { ${RUNS_KEYS.join(', ')} }, every key explicit`
96
+ const o = r as Record<string, unknown>
97
+ const missing = RUNS_KEYS.filter((k) => !(k in o))
98
+ if (missing.length > 0) return `runs is missing ${missing.join(', ')}: every key is explicit, no default`
99
+ const unknown = Object.keys(o).filter((k) => !(RUNS_KEYS as readonly string[]).includes(k))
100
+ if (unknown.length > 0) return `runs has unknown key ${unknown.join(', ')} (the keys: ${RUNS_KEYS.join(', ')})`
101
+ const p = o.onPreCommit
102
+ if (p !== false) {
103
+ const b = (p as { budgetMs?: unknown } | null)?.budgetMs
104
+ if (p == null || typeof p !== 'object' || typeof b !== 'number' || !Number.isInteger(b) || b < 1 || b > PRE_COMMIT_MAX_MS)
105
+ return `runs.onPreCommit takes false or { budgetMs: 1..${PRE_COMMIT_MAX_MS} }`
106
+ }
107
+ for (const k of ['onCi', 'onFull', 'onRelease', 'onHealth'] as const) if (!isBool(o[k])) return `runs.${k} takes true or false`
108
+ if (o.onSave !== false && o.onSave !== 'moved' && o.onSave !== 'burst') return `runs.onSave takes false, 'moved' or 'burst'`
109
+ const c = o.onClock
110
+ if (c !== false && c !== ONCE) {
111
+ if (c == null || typeof c !== 'object') return `runs.onClock takes false, 'once' or { every, inactive }`
112
+ const cc = c as { every?: unknown; inactive?: unknown }
113
+ if (!('every' in cc) || !('inactive' in cc)) return `runs.onClock takes { every, inactive }, both keys explicit`
114
+ const e = everyProblem(cc.every)
115
+ if (e != null) return `runs.${e}`
116
+ if (typeof cc.inactive !== 'function' && cc.inactive !== 'run' && cc.inactive !== 'wait') return `runs.onClock.inactive takes 'run', 'wait' or a function returning one`
117
+ }
118
+ if (o.onCi === true && o.onFull !== true) return `runs.onCi true needs onFull true: the full gate is the quick gate plus more`
119
+ return null
120
+ }
121
+
122
+ /** what is wrong with a `cache` object, or null */
123
+ export function cacheProblem(c: unknown): string | null {
124
+ if (c == null || typeof c !== 'object') return `cache is required: { by: ${CACHE_BY.map((b) => `'${b}'`).join(' | ')}, ... }`
125
+ const o = c as Record<string, unknown>
126
+ const want: Record<CacheBy, string[]> = { files: ['inputs', 'key'], reads: [], ttl: ['span', 'key'], fact: ['key'], never: ['why'] }
127
+ const by = o.by as CacheBy
128
+ if (!CACHE_BY.includes(by)) return `cache.by takes ${CACHE_BY.map((b) => `'${b}'`).join(', ')}`
129
+ const keys = want[by]
130
+ const missing = keys.filter((k) => !(k in o))
131
+ if (missing.length > 0) return `cache { by: '${by}' } is missing ${missing.join(', ')}: every key is explicit`
132
+ const unknown = Object.keys(o).filter((k) => k !== 'by' && !keys.includes(k))
133
+ if (unknown.length > 0) return `cache { by: '${by}' } has unknown key ${unknown.join(', ')} (it takes ${keys.length === 0 ? 'nothing else' : keys.join(', ')})`
134
+ if (by === 'files' && (!Array.isArray(o.inputs) || o.inputs.length === 0 || o.inputs.some((p) => typeof p !== 'string' || p === ''))) return `cache.inputs is a non empty list of repo relative paths`
135
+ if ((by === 'files' || by === 'ttl') && o.key !== null && typeof o.key !== 'function') return `cache.key is null or a function of { root, config }`
136
+ if (by === 'fact' && typeof o.key !== 'function') return `cache { by: 'fact' } needs key: a function of { root, config } returning the declared value`
137
+ if (by === 'ttl') {
138
+ if (typeof o.span !== 'string') return `cache.span is a duration, as in '24h'`
139
+ try { if (parseDuration(o.span) <= 0) return `cache.span must be above zero` } catch (e) { return `cache.span: ${(e as Error).message}` }
140
+ }
141
+ if (by === 'never' && (typeof o.why !== 'string' || o.why.trim() === '')) return `cache { by: 'never' } needs why: the reason no key can describe it`
142
+ return null
143
+ }
144
+
145
+ /** what a whole declaration must satisfy together, or null */
146
+ export type WholeDecl = { runs: Runs; cache: CacheDecl; fix: FixWhen; repair: unknown; build: boolean; body: 'run' | 'cmd'; blocking: boolean }
147
+
148
+ export function combinationProblem(d: WholeDecl): string | null {
149
+ const { runs: r, cache: c } = d
150
+ if (!FIX_WHEN.includes(d.fix)) return `fix takes 'none', 'ci' or 'manual'`
151
+ if (d.fix !== 'none' && typeof d.repair !== 'function') return `fix '${d.fix}' needs a repair function`
152
+ if (d.fix === 'none' && d.repair != null) return `a repair function needs fix 'ci' or 'manual', never 'none'`
153
+ if (d.fix === 'ci' && !r.onCi) return `fix 'ci' needs onCi true: the gate's fix pass runs the body first, so a check the gate does not run would run there anyway. Say 'manual'`
154
+ if (d.build && (r.onHealth || r.onPreCommit !== false || d.fix !== 'none'))
155
+ return `a build step runs only in gates: onHealth false, onPreCommit false, fix 'none' (a tool repairs its own findings)`
156
+ if (r.onPreCommit !== false) {
157
+ if (d.body === 'cmd') return `onPreCommit needs an in process run body, never a cmd`
158
+ if (!d.blocking) return `onPreCommit needs blocking true: the hook only refuses, it never warns about the world`
159
+ if (c.by === 'ttl' || c.by === 'fact' || c.by === 'never') return `onPreCommit needs cache files or reads: the hook reads the tree, never the world`
160
+ }
161
+ if (r.onSave === 'moved' && (c.by === 'ttl' || c.by === 'never')) return `onSave 'moved' needs cache files, reads or fact: nothing can move under ${c.by}`
162
+ if (r.onClock === ONCE && c.by !== 'fact') return `onClock 'once' needs cache fact: once means while no answer exists for the fact's key`
163
+ if (c.by === 'fact' && r.onClock !== ONCE) return `cache fact needs onClock 'once': a fact nobody runs once is a fact never answered`
164
+ if (c.by === 'reads' && d.body === 'cmd') return `cache reads needs a run body: a subprocess records no reads, say files or never`
165
+ const triggered = r.onPreCommit !== false || r.onCi || r.onFull || r.onRelease || r.onHealth || r.onSave !== false || r.onClock !== false
166
+ // an opt in build step (a deploy) is the one row nothing but a name runs:
167
+ // `shipkit ci --with <id>` asks it on purpose
168
+ if (!triggered && d.fix === 'none' && !d.build) return `nothing runs it: every trigger is false and fix is 'none'. Delete the file, turn it off in the repo with a reason, or make it an opt in build step (build: true)`
169
+ return null
170
+ }
171
+
172
+ // ── the resolved row ─────────────────────────────────────────────────────
173
+
174
+ /** where one resolved value came from */
175
+ export type RunsSource = 'check file' | 'repo.config.ts' | 'derived'
176
+
177
+ export type ResolvedRuns = Runs & {
178
+ fix: FixWhen
179
+ /** a reason the row runs in no context: the repo turned it off, or it
180
+ * does not apply here. Null for a live row */
181
+ off: string | null
182
+ /** where each value came from */
183
+ source: Record<RunsAxis | 'fix', RunsSource>
184
+ /** why a derived row has the values it has, null for a declared one */
185
+ derivedWhy: string | null
186
+ }
187
+
188
+ /** every value from one source */
189
+ export function sourced(r: Runs, fix: FixWhen, from: RunsSource, derivedWhy: string | null = null, off: string | null = null): ResolvedRuns {
190
+ const source = Object.fromEntries([...RUNS_KEYS, 'fix'].map((k) => [k, from])) as ResolvedRuns['source']
191
+ return { ...r, fix, off, source, derivedWhy }
192
+ }
193
+
194
+ /** runs in no context at all */
195
+ export const NOWHERE: Runs = { onPreCommit: false, onCi: false, onFull: false, onRelease: false, onHealth: false, onSave: false, onClock: false }
196
+
197
+ /** the context a runner asks about */
198
+ export type RunContext = 'preCommit' | 'ci' | 'full' | 'release' | 'health'
199
+
200
+ /** does this context run the row? Off is never */
201
+ export function runsIn(r: ResolvedRuns, ctx: RunContext): boolean {
202
+ if (r.off != null) return false
203
+ switch (ctx) {
204
+ case 'preCommit': return r.onPreCommit !== false
205
+ case 'ci': return r.onCi
206
+ case 'full': return r.onFull
207
+ case 'release': return r.onRelease
208
+ case 'health': return r.onHealth
209
+ }
210
+ }
211
+
212
+ /** the daemon plans it at all */
213
+ export const planned = (r: ResolvedRuns): boolean => r.off == null && (r.onSave !== false || r.onClock !== false)
214
+
215
+ /** the one line a surface prints for a row that is off */
216
+ export const offLine = (id: string, r: ResolvedRuns): string => `${id} off: ${r.off ?? '(no reason)'}`
217
+
218
+ /** the cell a matrix prints for one axis */
219
+ export function cellText(r: ResolvedRuns, k: RunsAxis | 'fix'): string {
220
+ const v = k === 'fix' ? r.fix : r[k]
221
+ if (v === true) return 'yes'
222
+ if (v === false) return '·'
223
+ if (typeof v === 'string') return v
224
+ if (k === 'onPreCommit') return `${(v as { budgetMs: number }).budgetMs}ms`
225
+ const c = v as { every: Every; inactive: WhenInactiveDecl }
226
+ const every = typeof c.every === 'function' ? 'fn' : Array.isArray(c.every) ? `${c.every[0]}..${c.every[1]}` : String(c.every)
227
+ return `${every}${c.inactive === 'run' ? ' (runs inactive)' : ''}`
228
+ }
229
+
230
+ /** a files cache as one short cell: three paths, then a count */
231
+ export const filesText = (inputs: string[]): string => `files ${inputs.length <= 3 ? inputs.join(' ') : `${inputs.slice(0, 3).join(' ')} +${inputs.length - 3}`}`
232
+
233
+ /** the cache variant as one short cell */
234
+ export function cacheText(c: CacheDecl): string {
235
+ switch (c.by) {
236
+ case 'files': return filesText(c.inputs)
237
+ case 'reads': return 'reads'
238
+ case 'ttl': return `ttl ${c.span}`
239
+ case 'fact': return 'fact'
240
+ case 'never': return `never: ${c.why}`
241
+ }
242
+ }
@@ -2,6 +2,8 @@
2
2
  // startup self-update and `shipkit fleet --update`. An automatic update never
3
3
  // crosses a breaking boundary. It reports the jump and names the one command
4
4
  // that takes it on purpose.
5
+ import { lstatSync } from 'fs'
6
+ import { join } from 'path'
5
7
 
6
8
  /** set by `shipkit fleet --update --major` on the repo run it spawns */
7
9
  export const ALLOW_MAJOR_ENV = 'SHIPKIT_ALLOW_MAJOR'
@@ -23,3 +25,21 @@ export function isBreaking(installed: string, latest: string): boolean {
23
25
  * name, which is what `fleet --only` matches */
24
26
  export const breakingAdvice = (name: string, latest: string, repo: string): string =>
25
27
  `${name} ${latest} available (breaking): run \`shipkit fleet --update --major --only ${repo}\``
28
+
29
+ /** the lowest version a semver pin admits (`^1.3.0` → 1.3.0, `1.3.0` →
30
+ * 1.3.0), null for anything else (`link:`, a tag, a url, a range with two
31
+ * ends). What a fresh clone of the repo installs is judged from this */
32
+ export function pinFloor(spec: string): string | null {
33
+ const m = /^(?:[\^~]|>=|=)?\s*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(spec.trim())
34
+ return m?.[1] ?? null
35
+ }
36
+
37
+ /** true when node_modules/<name> is a symlink (`bun link <name>`): it then
38
+ * carries a source tree, whose version says nothing about what the repo pins */
39
+ export function isLinkedInstall(root: string, name: string): boolean {
40
+ try {
41
+ return lstatSync(join(root, 'node_modules', name)).isSymbolicLink()
42
+ } catch {
43
+ return false
44
+ }
45
+ }
@@ -12,7 +12,7 @@
12
12
  // 1. the file must not have moved since the caller read it (`expect`)
13
13
  // 2. the block must still MATCH what the config loads, or a hand edit the
14
14
  // writer cannot account for would be silently overwritten
15
- // 3. a `when` or `tests` declared outside the block is refused, because a
15
+ // 3. an `off` or `tests` declared outside the block is refused, because a
16
16
  // second declaration is a duplicate key rather than a merge
17
17
  // 4. the value is validated the way `defineRepo` validates it, before it
18
18
  // reaches the file
@@ -28,7 +28,6 @@ import { existsSync, readFileSync } from 'fs'
28
28
  import { join } from 'path'
29
29
  import { atomicWrite } from './locked-write.ts'
30
30
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
31
- import { parseWhen } from './when.ts'
32
31
  import type { SlowSuite, TestsConfig } from '../suite/judge-suites.ts'
33
32
  import { normWord, proseWordsProblems, type ProseAllowWord, type ProseBanWord, type ProseWords } from '../prose/ste.ts'
34
33
 
@@ -41,7 +40,7 @@ export const WORDS_END = '// <<< shipkit prose words'
41
40
 
42
41
  /** every key this block owns. A repo declaring one of them outside the
43
42
  * markers keeps it, and the block refuses to write rather than duplicate it */
44
- const OWNED = ['when', 'tests'] as const
43
+ const OWNED = ['off', 'tests'] as const
45
44
 
46
45
  export type BlockState = 'missing-file' | 'missing-block' | 'stale' | 'current'
47
46
 
@@ -51,7 +50,8 @@ export type GateBlock = {
51
50
  * the write, and a mismatch means somebody else edited it meanwhile */
52
51
  hash: string
53
52
  state: BlockState
54
- when: Record<string, string>
53
+ /** check id → why this repo turns it off */
54
+ off: Record<string, string>
55
55
  tests: TestsConfig
56
56
  /** true when a key this block owns is declared outside the markers */
57
57
  declaredOutside: boolean
@@ -65,8 +65,8 @@ export type GateBlock = {
65
65
  }
66
66
 
67
67
  export type GateEdit = {
68
- /** check id → the value, or null to drop the entry */
69
- when?: Record<string, string | null>
68
+ /** check id → why it is off, or null to turn it back on */
69
+ off?: Record<string, string | null>
70
70
  /** suites to declare slow, each with the case `defineRepo` demands */
71
71
  slow?: SlowSuite[]
72
72
  /** suite files to drop back to the quick tier */
@@ -109,12 +109,12 @@ const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'
109
109
  const k = (s: string): string => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : q(s))
110
110
 
111
111
  /** the bytes between the markers, markers included */
112
- export function renderGateBlock(when: Record<string, string>, tests: TestsConfig, pad: string): string[] {
112
+ export function renderGateBlock(off: Record<string, string>, tests: TestsConfig, pad: string): string[] {
113
113
  const out = [`${pad}${GATE_BEGIN}`]
114
- const ids = Object.keys(when).sort()
114
+ const ids = Object.keys(off).sort()
115
115
  if (ids.length > 0) {
116
- out.push(`${pad}when: {`)
117
- for (const id of ids) out.push(`${pad}${pad}${k(id)}: ${q(when[id]!)},`)
116
+ out.push(`${pad}off: {`)
117
+ for (const id of ids) out.push(`${pad}${pad}${k(id)}: ${q(off[id]!)},`)
118
118
  out.push(`${pad}},`)
119
119
  }
120
120
  const slow = tests.slow ?? []
@@ -219,13 +219,13 @@ export function configDirty(root: string): boolean {
219
219
  /** what the block holds today, and whether it still describes the config */
220
220
  export async function readGateBlock(root: string): Promise<GateBlock> {
221
221
  const path = configPath(root)
222
- if (!existsSync(path)) return { path, hash: '', state: 'missing-file', when: {}, tests: {}, declaredOutside: false, words: {}, wordsState: 'no-prose', wordsOutside: false }
222
+ if (!existsSync(path)) return { path, hash: '', state: 'missing-file', off: {}, tests: {}, declaredOutside: false, words: {}, wordsState: 'no-prose', wordsOutside: false }
223
223
  const text = readFileSync(path, 'utf8')
224
224
  const lines = text.split('\n')
225
225
  const b = lines.findIndex(isBegin)
226
226
  const e = lines.findIndex(isEnd)
227
227
  const c = await loadRepoConfig(root, { fresh: true })
228
- const when = c.when ?? {}
228
+ const off = c.off ?? {}
229
229
  const tests = c.tests ?? {}
230
230
  const hash = sha(text)
231
231
  const w = locateWords(lines, c)
@@ -234,7 +234,7 @@ export async function readGateBlock(root: string): Promise<GateBlock> {
234
234
  // no block: the keys it owns must be unclaimed, or a write would be a
235
235
  // second declaration of the same key rather than an edit of the first
236
236
  const declaredOutside = OWNED.some((key) => c[key] != null)
237
- return { path, hash, state: 'missing-block', when, tests, declaredOutside, ...wordsPart }
237
+ return { path, hash, state: 'missing-block', off, tests, declaredOutside, ...wordsPart }
238
238
  }
239
239
  // the block DESCRIBES the config when re-rendering the loaded values
240
240
  // reproduces it byte for byte. Anything else (a hand edit, a second
@@ -242,25 +242,25 @@ export async function readGateBlock(root: string): Promise<GateBlock> {
242
242
  // stale refuses instead of overwriting what somebody wrote
243
243
  const pad = indentOf(lines.slice(b + 1, e))
244
244
  const current = lines.slice(b, e + 1).join('\n')
245
- const expected = renderGateBlock(when, tests, pad === '' ? indentOf(lines) : pad).join('\n')
246
- return { path, hash, state: current === expected ? 'current' : 'stale', when, tests, declaredOutside: false, ...wordsPart }
245
+ const expected = renderGateBlock(off, tests, pad === '' ? indentOf(lines) : pad).join('\n')
246
+ return { path, hash, state: current === expected ? 'current' : 'stale', off, tests, declaredOutside: false, ...wordsPart }
247
247
  }
248
248
 
249
249
  /** does what the loader read back say what was written? BOTH keys the block
250
- * owns, not just `when`: a `tests` entry that loads and parses differently
250
+ * owns, not just `off`: a `tests` entry that loads and parses differently
251
251
  * (a renderer that drops a field, a loader that reshapes one) is exactly
252
252
  * what reading the result back exists to catch, and comparing half of the
253
253
  * block would let that half through. Key and array ORDER are the renderer's,
254
254
  * so the comparison is by content. */
255
- export function roundTripProblem(wrote: { when: Record<string, string>; tests: TestsConfig; words?: ProseWords }, loaded: RepoConfig): string | null {
256
- const normWhen = (m: Record<string, string> | undefined): string => JSON.stringify(Object.entries(m ?? {}).sort())
255
+ export function roundTripProblem(wrote: { off: Record<string, string>; tests: TestsConfig; words?: ProseWords }, loaded: RepoConfig): string | null {
256
+ const normOff = (m: Record<string, string> | undefined): string => JSON.stringify(Object.entries(m ?? {}).sort())
257
257
  const normTests = (t: TestsConfig | undefined): string =>
258
258
  JSON.stringify({
259
259
  budgetMs: t?.budgetMs ?? null,
260
260
  slow: (t?.slow ?? []).map((s) => [s.file, s.catches, s.whyNotFast]).sort(),
261
261
  serial: (t?.serial ?? []).map((s) => [s.file, s.why]).sort(),
262
262
  })
263
- if (normWhen(loaded.when) !== normWhen(wrote.when)) return `the loader read when ${JSON.stringify(loaded.when ?? {})}`
263
+ if (normOff(loaded.off) !== normOff(wrote.off)) return `the loader read off ${JSON.stringify(loaded.off ?? {})}`
264
264
  if (normTests(loaded.tests) !== normTests(wrote.tests)) return `the loader read tests ${JSON.stringify(loaded.tests ?? {})}`
265
265
  if (wrote.words != null) {
266
266
  const normWords = (x: ProseWords | undefined): string => {
@@ -315,13 +315,13 @@ export async function writeGateBlock(root: string, edit: GateEdit, o: { expect?:
315
315
  if (before.state === 'missing-file') throw new Error(`no repo.config.ts in ${root} — run: shipkit init`)
316
316
  if (o.expect != null && o.expect !== before.hash)
317
317
  throw new Error(`repo.config.ts changed since you read it, reload the page and set it again, so this edit cannot overwrite one made elsewhere`)
318
- const touchGate = edit.when != null || edit.slow != null || edit.slowRemove != null
318
+ const touchGate = edit.off != null || edit.slow != null || edit.slowRemove != null
319
319
  const touchWords = edit.words != null
320
- if (!touchGate && !touchWords) throw new Error(`nothing to write: the edit names no when, no slow suite and no prose word`)
320
+ if (!touchGate && !touchWords) throw new Error(`nothing to write: the edit names no off, no slow suite and no prose word`)
321
321
  if (touchGate && before.state === 'stale')
322
322
  throw new Error(`the shipkit gate block in repo.config.ts no longer matches what the config loads, so it was hand edited or something else declares these keys — reconcile it by hand, then use the switch again`)
323
323
  if (touchGate && before.state === 'missing-block' && before.declaredOutside)
324
- throw new Error(`repo.config.ts declares \`when\` or \`tests\` outside the managed block — move that declaration between the markers first, or the switch would write a duplicate key`)
324
+ throw new Error(`repo.config.ts declares \`off\` or \`tests\` outside the managed block — move that declaration between the markers first, or the switch would write a duplicate key`)
325
325
  if (touchWords && before.wordsState === 'no-prose')
326
326
  throw new Error(`repo.config.ts has no \`prose: {\` line to write the word lists under — declare \`prose: { include: [...] }\` first, the lists only matter to a repo rule SK014 checks`)
327
327
  if (touchWords && before.wordsState === 'stale')
@@ -331,12 +331,11 @@ export async function writeGateBlock(root: string, edit: GateEdit, o: { expect?:
331
331
 
332
332
  // the next values, validated the way defineRepo validates them, BEFORE
333
333
  // anything reaches the file
334
- const when: Record<string, string> = { ...before.when }
335
- for (const [id, raw] of Object.entries(edit.when ?? {})) {
336
- if (raw == null) { delete when[id]; continue }
337
- const w = parseWhen(raw)
338
- if (typeof w === 'string') throw new Error(`when.${id} ${w}`)
339
- when[id] = raw
334
+ const off: Record<string, string> = { ...before.off }
335
+ for (const [id, why] of Object.entries(edit.off ?? {})) {
336
+ if (why == null) { delete off[id]; continue }
337
+ if (typeof why !== 'string' || why.trim() === '') throw new Error(`off.${id} needs a reason: a check nobody can explain is a silenced gate`)
338
+ off[id] = why.trim()
340
339
  }
341
340
  const slow = [...(before.tests.slow ?? [])]
342
341
  for (const s of edit.slow ?? []) {
@@ -360,7 +359,7 @@ export async function writeGateBlock(root: string, edit: GateEdit, o: { expect?:
360
359
  const b = lines.findIndex(isBegin)
361
360
  const e = lines.findIndex(isEnd)
362
361
  const pad = b === -1 ? indentOf(lines) : indentOf(lines.slice(b + 1, e)) || indentOf(lines)
363
- const block = renderGateBlock(when, tests, pad)
362
+ const block = renderGateBlock(off, tests, pad)
364
363
  if (b !== -1 && e !== -1) out = [...lines.slice(0, b), ...block, ...lines.slice(e + 1)]
365
364
  else {
366
365
  // the anchor is the object literal `defineRepo({` opens. Without it
@@ -391,7 +390,7 @@ export async function writeGateBlock(root: string, edit: GateEdit, o: { expect?:
391
390
  // round trip is a broken config, so the previous bytes go back
392
391
  try {
393
392
  const after = await loadRepoConfig(root, { fresh: true })
394
- const problem = roundTripProblem({ when, tests, words }, after)
393
+ const problem = roundTripProblem({ off, tests, words }, after)
395
394
  if (problem != null) throw new Error(`the block did not round trip: ${problem}`)
396
395
  } catch (err) {
397
396
  atomicWrite(before.path, text)
package/src/core/when.ts CHANGED
@@ -1,42 +1,34 @@
1
- // WHERE A CHECK RUNS, in ONE declaration, keyed by check id.
1
+ // THE CHECKS A REPO TURNS OFF: `off: { <id>: '<why>' }` in repo.config.ts,
2
+ // the switch the hub writes inside its managed block. Where a check runs is
3
+ // its own declaration (core/runs.ts); the one thing a repo says about a check
4
+ // it does not own is that it does not run here, and why. An off row runs in
5
+ // no context, and every surface shows it with its reason.
2
6
  //
3
- // The question "does this run, and where" used to be answered in three
4
- // unrelated places with three vocabularies: `ruleExempt` for a rule, `optIn`
5
- // for a task, `export const gate` inside a check file. Nothing could read the
6
- // three together, so no surface could show them on one row and no surface
7
- // could write one.
8
- //
9
- // Four answers, and they are the words on screen:
10
- // always every gate and every health pass
11
- // release out of the quick gate, in at `--full` and on the release path
12
- // health the health pass (`shipkit check`) only, never a gate
13
- // off:<why> not run at all, and the reason is mandatory
14
- //
15
- // The third word says what it MEANS, never which command runs it: a value
16
- // nobody can read is a switch nobody moves.
17
- import type { TestTier, CheckRow } from '../ledger/gate-checks.ts'
7
+ // Two retired spellings still read as off while SK029 ports them: a `when`
8
+ // entry `'off:<why>'` (defineRepo refuses the other words) and `ruleExempt`.
9
+ // They die once `shipkit fleet` shows no repo carrying them.
10
+ import type { CheckRow } from '../ledger/gate-checks.ts'
11
+ import type { ResolvedRuns } from './runs.ts'
18
12
  import type { RepoConfig } from '../repo-config.ts'
19
13
 
14
+ /** the coarse word a resolved row reads as on the check page */
20
15
  export type WhenAt = 'always' | 'release' | 'health' | 'off'
21
16
 
22
- /** which declaration is answering: the `when` key the switch writes, one of
23
- * the check file's own `when`, one of the three spellings the key replaces,
24
- * or the default nobody wrote */
25
- export type WhenSource = 'when' | 'check-file' | 'ruleExempt' | 'optIn' | 'gate-export' | 'defaults' | 'default'
17
+ /** which declaration is answering: the repo's `off`, one of its two retired
18
+ * spellings, the check file, the `defaults` block, or a derived row */
19
+ export type WhenSource = 'off' | 'when' | 'ruleExempt' | 'check-file' | 'defaults' | 'default'
26
20
 
27
21
  export type WhenDecl = { at: WhenAt; reason: string | null; source: WhenSource }
28
22
 
29
23
  export const WHEN_AT: readonly WhenAt[] = ['always', 'release', 'health', 'off']
30
24
 
31
- /** the words a config value may use */
25
+ /** the words a retired `when` value used */
32
26
  const WORDS: Record<string, WhenAt> = { always: 'always', release: 'release', health: 'health' }
33
27
 
34
- const DEFAULT: WhenDecl = { at: 'always', reason: null, source: 'default' }
35
-
36
- /** one config value, or the problem with it. A string back is the message
37
- * `defineRepo` throws, so a bad value dies at config load and never mid gate */
28
+ /** one retired `when` value, or the problem with it. defineRepo refuses every
29
+ * word but `off:<why>`, which reads as `off` */
38
30
  export function parseWhen(raw: unknown): WhenDecl | string {
39
- if (typeof raw !== 'string') return `must be a string: always, release, health, or off:<reason>`
31
+ if (typeof raw !== 'string') return `must be a string: off:<reason>`
40
32
  const v = raw.trim()
41
33
  if (v === 'off' || v.startsWith('off:')) {
42
34
  const reason = v.slice(4).trim()
@@ -44,92 +36,38 @@ export function parseWhen(raw: unknown): WhenDecl | string {
44
36
  return { at: 'off', reason, source: 'when' }
45
37
  }
46
38
  const at = WORDS[v]
47
- if (at == null) return `'${v}' is not a when value (always, release, health, off:<reason>)`
39
+ if (at == null) return `'${v}' is not a when value (off:<reason>)`
48
40
  return { at, reason: null, source: 'when' }
49
41
  }
50
42
 
51
- /** the explicit `when` entry for one id, or null. A value that does not parse
52
- * is null here: `defineRepo` already refused it, so reaching this with one
53
- * means the config was never loaded through it */
54
- export function declaredWhen(c: RepoConfig, id: string): WhenDecl | null {
55
- const raw = c.when?.[id]
56
- if (raw == null) return null
57
- const w = parseWhen(raw)
58
- return typeof w === 'string' ? null : w
59
- }
43
+ /** the repo turned this check off, with why and where it said so, or null */
44
+ export type RepoOff = { reason: string; source: 'off' | 'when' | 'ruleExempt' }
60
45
 
61
- /** a rule: `when` wins, then `ruleExempt`, which is off with its reason */
62
- export function whenOfRule(c: RepoConfig, id: string): WhenDecl {
63
- const w = declaredWhen(c, id)
64
- if (w != null) return w
46
+ export function repoOff(c: RepoConfig, id: string): RepoOff | null {
47
+ const own = c.off?.[id]
48
+ if (own != null) return { reason: own, source: 'off' }
49
+ const w = c.when?.[id] == null ? null : parseWhen(c.when[id])
50
+ if (w != null && typeof w !== 'string' && w.at === 'off') return { reason: w.reason ?? '', source: 'when' }
65
51
  const exempt = c.ruleExempt?.[id]
66
- if (exempt != null) return { at: 'off', reason: exempt, source: 'ruleExempt' }
67
- return DEFAULT
68
- }
69
-
70
- /** a task: `when` wins, then `optIn`, which means off the default gate and
71
- * reachable by name (`--with <id>`), which is what `health` means for a task */
72
- export function whenOfTask(c: RepoConfig, t: { id: string; optIn?: boolean }): WhenDecl {
73
- const w = declaredWhen(c, t.id)
74
- if (w != null) return w
75
- if (t.optIn === true) return { at: 'health', reason: null, source: 'optIn' }
76
- return DEFAULT
77
- }
78
-
79
- /** a floor slot the repo turned off with `defaults: { <id>: false }`. That
80
- * declaration REMOVES the task, so it wins over any `when`: a row claiming
81
- * always over a slot that resolves to nothing is a check the gate never
82
- * runs and nobody can see. The source is the `defaults` block itself, and
83
- * the reason names the line, so a page can say which declaration answered */
84
- export function whenOfDisabledSlot(id: string): WhenDecl {
85
- return { at: 'off', reason: `defaults.${id}: false`, source: 'defaults' }
52
+ return exempt == null ? null : { reason: exempt, source: 'ruleExempt' }
86
53
  }
87
54
 
88
- /** a discovered check: the repo's `when` wins, then the file's own `when`,
89
- * so a config can move a check whose file the repo does not own */
90
- export function whenOfCheck(c: RepoConfig, u: { id: string; def: { when?: string } }): WhenDecl {
91
- const w = declaredWhen(c, u.id)
92
- if (w != null) return w
93
- // the legacy spelling still moves a check the repo does not own, and a
94
- // repo statement wins over the file's own default
95
- const exempt = c.ruleExempt?.[u.id]
96
- if (exempt != null) return { at: 'off', reason: exempt, source: 'ruleExempt' }
97
- if (u.def.when == null) return DEFAULT
98
- const own = parseWhen(u.def.when)
99
- // `check()` already refused anything unparseable, so reaching this with a
100
- // string means the object never went through it
101
- return typeof own === 'string' ? DEFAULT : { ...own, source: 'check-file' }
102
- }
103
-
104
- /** a discovered check: `when` wins, then the file's own `export const gate` */
105
- export function whenOfLegacyCheck(c: RepoConfig, d: { name: string; gate: boolean }): WhenDecl {
106
- const w = declaredWhen(c, d.name)
107
- if (w != null) return w
108
- if (!d.gate) return { at: 'health', reason: null, source: 'gate-export' }
109
- return DEFAULT
110
- }
111
-
112
- /** does the GATE run it at this tier? `shipkit check` asks `runsInCheck` */
113
- export function runsInGate(w: WhenDecl, tier: TestTier): boolean {
114
- if (w.at === 'off' || w.at === 'health') return false
115
- return w.at === 'release' ? tier === 'full' : true
116
- }
117
-
118
- /** does the health pass run it? everything but off: `shipkit check` is where
119
- * a proactive probe belongs, and only off means never */
120
- export function runsInCheck(w: WhenDecl): boolean {
121
- return w.at !== 'off'
122
- }
55
+ /** a resolved row the repo turned off: it runs in no context */
56
+ export const withRepoOff = (base: ResolvedRuns, off: RepoOff | null): ResolvedRuns => (off == null ? base : { ...base, off: off.reason })
123
57
 
124
- /** the ONE line a surface prints for a check that is off */
125
- export function offLine(id: string, w: WhenDecl): string {
126
- return `${id} off: ${w.reason ?? '(no reason)'}`
58
+ /** the coarse word a resolved row reads as, for the check page. Derived,
59
+ * never declared */
60
+ export function whenSummary(r: ResolvedRuns, off: RepoOff | { reason: string; source: 'defaults' } | null, fromFile: boolean): WhenDecl {
61
+ const source: WhenSource = off?.source ?? (fromFile ? 'check-file' : 'default')
62
+ if (r.off != null) return { at: 'off', reason: r.off, source }
63
+ const at: WhenAt = r.onCi ? 'always' : r.onFull || r.onRelease ? 'release' : r.onHealth || r.onSave !== false || r.onClock !== false ? 'health' : 'off'
64
+ return { at, reason: at === 'off' ? 'no context runs it' : null, source }
127
65
  }
128
66
 
129
- /** ids in `when` that name no check of this repo. `defineRepo` can only check
130
- * the rule namespace: a task is resolved from the floor and a check is
131
- * discovered on disk, so this is where the rest is caught, loud */
132
- export function unknownWhenIds(rows: CheckRow[], c: RepoConfig): string[] {
67
+ /** ids the repo turns off that name no check of this repo. `defineRepo` can
68
+ * only check the rule namespace: a task is resolved from the floor and a
69
+ * check is discovered on disk, so this is where the rest is caught, loud */
70
+ export function unknownOffIds(rows: CheckRow[], c: RepoConfig): string[] {
133
71
  const known = new Set(rows.map((r) => r.decl.id))
134
- return Object.keys(c.when ?? {}).filter((id) => !known.has(id))
72
+ return [...new Set([...Object.keys(c.off ?? {}), ...Object.keys(c.when ?? {})])].filter((id) => !known.has(id))
135
73
  }