rvlib-shipkit 1.4.0 → 1.6.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 (199) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/assets/gen/comfyts-gen.ts +12 -8
  5. package/src/assets/server/studio-server.ts +14 -2
  6. package/src/check.ts +73 -103
  7. package/src/checks/SK001-package-json-shape.check.ts +11 -6
  8. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  9. package/src/checks/SK003-claude-md-shape.check.ts +7 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  11. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  12. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  13. package/src/checks/SK007-github-visibility.check.ts +4 -2
  14. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  15. package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
  16. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  17. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  18. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  19. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  20. package/src/checks/SK014-prose-ste.check.ts +4 -0
  21. package/src/checks/SK015-nested-repo.check.ts +4 -2
  22. package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
  23. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  24. package/src/checks/SK018-test-files-run.check.ts +6 -2
  25. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  26. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  27. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  28. package/src/checks/SK024-identity-colors.check.ts +5 -3
  29. package/src/checks/SK025-release-drift.check.ts +4 -5
  30. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  31. package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
  32. package/src/checks/SK028-readme.check.ts +5 -1
  33. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  34. package/src/checks/SK030-cache-audit.check.ts +34 -0
  35. package/src/checks/SK031-trace-audit.check.ts +39 -0
  36. package/src/checks/SK032-ignored-inputs.check.ts +79 -0
  37. package/src/cli/cmd_Asks.ts +5 -5
  38. package/src/cli/cmd_Assets.ts +17 -13
  39. package/src/cli/cmd_Bump.ts +12 -11
  40. package/src/cli/cmd_CacheAudit.ts +43 -0
  41. package/src/cli/cmd_Check.ts +8 -7
  42. package/src/cli/cmd_Checks.ts +7 -6
  43. package/src/cli/cmd_Ci.ts +12 -11
  44. package/src/cli/cmd_Commands.ts +16 -13
  45. package/src/cli/cmd_ConfigKeys.ts +7 -6
  46. package/src/cli/cmd_Contracts.ts +3 -3
  47. package/src/cli/cmd_CrossRepoActivity.ts +8 -6
  48. package/src/cli/cmd_Fleet.ts +9 -7
  49. package/src/cli/cmd_GlobalProcesses.ts +120 -7
  50. package/src/cli/cmd_Home.ts +26 -19
  51. package/src/cli/cmd_Hooks.ts +6 -5
  52. package/src/cli/cmd_Init.ts +5 -4
  53. package/src/cli/cmd_Keys.ts +20 -8
  54. package/src/cli/cmd_Mv.ts +7 -6
  55. package/src/cli/cmd_Needs.ts +4 -3
  56. package/src/cli/cmd_Overview.ts +5 -4
  57. package/src/cli/cmd_Pb.ts +2 -2
  58. package/src/cli/cmd_Planned.ts +6 -5
  59. package/src/cli/cmd_Prose.ts +5 -5
  60. package/src/cli/cmd_Reflect.ts +7 -7
  61. package/src/cli/cmd_Release.ts +6 -5
  62. package/src/cli/cmd_Releases.ts +5 -4
  63. package/src/cli/cmd_Serve.ts +6 -4
  64. package/src/cli/cmd_Smoke.ts +5 -4
  65. package/src/cli/cmd_Start.ts +7 -6
  66. package/src/cli/cmd_Status.ts +4 -3
  67. package/src/cli/cmd_TraceAudit.ts +62 -0
  68. package/src/cli/cmd_Tui.ts +3 -2
  69. package/src/cli/cmd_When.ts +71 -0
  70. package/src/cli/cmd_Wiki.ts +4 -4
  71. package/src/cli.ts +11 -2
  72. package/src/collections/checks.ts +2 -0
  73. package/src/collections/collection-types.ts +12 -4
  74. package/src/collections/providers.ts +5 -3
  75. package/src/core/app-icon.ts +154 -0
  76. package/src/core/audit-report.ts +21 -0
  77. package/src/core/cache-audit.ts +72 -0
  78. package/src/core/canonical-scripts.ts +2 -2
  79. package/src/core/check-ctx.ts +14 -5
  80. package/src/core/check-runner.ts +43 -23
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +23 -2
  83. package/src/core/floor-resolve.ts +5 -7
  84. package/src/core/git-root.ts +13 -0
  85. package/src/core/ignored-block.ts +94 -0
  86. package/src/core/ignored-inputs.ts +47 -0
  87. package/src/core/jobs.ts +9 -2
  88. package/src/core/legacy-shapes.ts +39 -10
  89. package/src/core/one-lib.ts +59 -4
  90. package/src/core/one-way-in-policy.ts +2 -2
  91. package/src/core/outcome-words.ts +18 -2
  92. package/src/core/port-check-cli.ts +52 -0
  93. package/src/core/port-check-file.ts +295 -0
  94. package/src/core/port-check-files.ts +31 -0
  95. package/src/core/read-set.ts +97 -9
  96. package/src/core/retention.ts +0 -14
  97. package/src/core/run-check.ts +12 -9
  98. package/src/core/run-ci.ts +14 -5
  99. package/src/core/run-fix.ts +30 -23
  100. package/src/core/run-fixers.ts +48 -5
  101. package/src/core/runs-matrix.ts +179 -0
  102. package/src/core/runs.ts +242 -0
  103. package/src/core/trace-audit.ts +120 -0
  104. package/src/core/update-policy.ts +20 -0
  105. package/src/core/when-block.ts +29 -30
  106. package/src/core/when.ts +41 -103
  107. package/src/core/wire.ts +10 -14
  108. package/src/fleet/scan-fleet.ts +20 -4
  109. package/src/fleet/update-fleet.ts +6 -6
  110. package/src/home/gui-app.ts +24 -27
  111. package/src/home/home-launchd.ts +20 -4
  112. package/src/home/home-routes.ts +12 -1
  113. package/src/home/home-start.ts +11 -5
  114. package/src/home/hubs.ts +5 -5
  115. package/src/home/open-app.ts +2 -2
  116. package/src/home/pm.ts +8 -0
  117. package/src/home/run-pm.ts +1 -0
  118. package/src/home/serve-web.ts +2 -2
  119. package/src/home/supervise.ts +24 -1
  120. package/src/home/via-hub.ts +14 -0
  121. package/src/hooks/run-hooks.ts +48 -4
  122. package/src/init-repo.ts +6 -6
  123. package/src/ledger/db.ts +2 -13
  124. package/src/ledger/gate-checks.ts +133 -61
  125. package/src/ledger/git-key.ts +153 -31
  126. package/src/ledger/git-scan.ts +5 -2
  127. package/src/ledger/ledger.ts +136 -21
  128. package/src/ledger/store.ts +14 -0
  129. package/src/ledger/toolchain-identity.ts +248 -45
  130. package/src/plan/repo-source.ts +18 -39
  131. package/src/problems/extract.ts +3 -0
  132. package/src/release/run-release.ts +5 -0
  133. package/src/repo-config.ts +46 -54
  134. package/src/rules/rule.ts +2 -2
  135. package/src/scaffold/files/agent-coding.md +1 -1
  136. package/src/scaffold/files/agent-index.md +1 -1
  137. package/src/scaffold/files/verify-skill.md +2 -2
  138. package/src/serve/hub-app/activity-st.ts +5 -4
  139. package/src/serve/hub-app/activity-view.tsx +1 -1
  140. package/src/serve/hub-app/admin-svc.ts +36 -3
  141. package/src/serve/hub-app/admin-view.tsx +46 -12
  142. package/src/serve/hub-app/check-view.tsx +66 -46
  143. package/src/serve/hub-app/check-words.ts +1 -11
  144. package/src/serve/hub-app/checks-svc.ts +14 -35
  145. package/src/serve/hub-app/col-card.tsx +1 -44
  146. package/src/serve/hub-app/col-tables-view.tsx +84 -11
  147. package/src/serve/hub-app/col-tables.ts +27 -0
  148. package/src/serve/hub-app/cols-svc.ts +5 -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 +6 -0
  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 +14 -2
  160. package/src/serve/hub-app/home-strip.css +43 -12
  161. package/src/serve/hub-app/home-strip.tsx +115 -64
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +32 -8
  164. package/src/serve/hub-app/hub-types.ts +5 -2
  165. package/src/serve/hub-app/hub.css +89 -63
  166. package/src/serve/hub-app/icons.tsx +11 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +15 -4
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +18 -22
  171. package/src/serve/hub-app/plan-view.tsx +51 -0
  172. package/src/serve/hub-app/planned-st.ts +9 -4
  173. package/src/serve/hub-app/processes-view.tsx +45 -0
  174. package/src/serve/hub-app/project-list.css +4 -1
  175. package/src/serve/hub-app/project-list.tsx +6 -3
  176. package/src/serve/hub-app/projects-svc.ts +25 -10
  177. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  178. package/src/serve/hub-app/reach.ts +36 -0
  179. package/src/serve/hub-app/readme-svc.ts +3 -2
  180. package/src/serve/hub-app/repo-sections.ts +131 -0
  181. package/src/serve/hub-app/repo-st.ts +70 -14
  182. package/src/serve/hub-app/sidebar.tsx +17 -42
  183. package/src/serve/hub-app/timeline-svc.ts +3 -2
  184. package/src/serve/hub-app/tree-build.ts +151 -155
  185. package/src/serve/hub-app/tree-row.tsx +55 -12
  186. package/src/serve/hub-app/tree-svc.ts +97 -56
  187. package/src/serve/hub-app/when-view.tsx +89 -0
  188. package/src/serve/hub-app/when.css +17 -0
  189. package/src/serve/hub-runtime.ts +7 -18
  190. package/src/serve/project-gutter-route.ts +22 -1
  191. package/src/serve/run-serve.ts +1 -1
  192. package/src/serve/serve-hub.ts +4 -14
  193. package/src/suite/judge-suites.ts +6 -0
  194. package/src/trace/interpose.c +655 -0
  195. package/src/trace/read-trace.ts +485 -0
  196. package/src/trace/tracer.ts +282 -0
  197. package/src/serve/hub-app/processes-svc.ts +0 -63
  198. package/src/serve/hub-app/repo-sections.tsx +0 -61
  199. package/src/serve/repo-processes.ts +0 -89
@@ -0,0 +1,295 @@
1
+ // THE PORT of a check file written before every context was explicit: its
2
+ // source in, the same check out with `runs`, `cache`, `fix` and `applies`
3
+ // spelled out, carrying the values the retired keys resolved to (the gate,
4
+ // the health pass and the planner of 0.62), so a ported check runs exactly
5
+ // where it ran before. Pure: text in, text out. SK016 reports what this finds
6
+ // and its repair applies it (core/port-check-cli.ts reaches the disk).
7
+ //
8
+ // the one constraint a reader breaks first: it edits the check object by the
9
+ // RANGE of each top level property, found on a masked copy of the source
10
+ // (strings, comments and regex literals blanked), so a value, a comment or a
11
+ // multi line lambda it does not touch comes out byte for byte.
12
+
13
+ /** the keys the explicit declaration retired */
14
+ export const RETIRED_KEYS = ['when', 'inputs', 'ttl', 'never', 'every', 'cron', 'whenInactive', 'planOff', 'cacheKey'] as const
15
+
16
+ /** the range a repo row nothing keys ran on, before it declared one */
17
+ const DAILY = "['20h', '28h']"
18
+
19
+ /** the source with every string, comment, template text and regex literal
20
+ * blanked to spaces, newlines kept, so offsets line up with the original */
21
+ export function maskSource(src: string): string {
22
+ const out = src.split('')
23
+ const blank = (from: number, to: number): void => { for (let k = from; k < to; k++) if (out[k] !== '\n') out[k] = ' ' }
24
+ let i = 0
25
+ // one entry per open template: the brace depth of its current `${`
26
+ const tpl: number[] = []
27
+ let last = ''
28
+ const regexOk = (): boolean => last === '' || '(,=:[!&|?{};+-*%<>~^'.includes(last)
29
+ const readTemplateText = (): void => {
30
+ // at a character inside template text: blank until `${` or the closing backtick
31
+ const start = i
32
+ while (i < src.length && src[i] !== '`' && !(src[i] === '$' && src[i + 1] === '{')) i += src[i] === '\\' ? 2 : 1
33
+ blank(start, i)
34
+ if (src[i] === '`') { i++; tpl.pop(); last = 'x' } else { i += 2; tpl[tpl.length - 1] = 0; last = '{' }
35
+ }
36
+ while (i < src.length) {
37
+ const ch = src[i]!
38
+ const next = src[i + 1]
39
+ if (ch === '/' && next === '/') { const s = i; while (i < src.length && src[i] !== '\n') i++; blank(s, i); continue }
40
+ if (ch === '/' && next === '*') { const s = i; const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 2; blank(s, i); continue }
41
+ if (ch === "'" || ch === '"') {
42
+ const s = i
43
+ i++
44
+ while (i < src.length && src[i] !== ch && src[i] !== '\n') i += src[i] === '\\' ? 2 : 1
45
+ i++
46
+ blank(s + 1, i - 1)
47
+ last = 'x'
48
+ continue
49
+ }
50
+ if (ch === '`') { tpl.push(-1); i++; readTemplateText(); continue }
51
+ if (tpl.length > 0 && tpl[tpl.length - 1]! >= 0) {
52
+ if (ch === '{') tpl[tpl.length - 1]!++
53
+ if (ch === '}') {
54
+ if (tpl[tpl.length - 1] === 0) { tpl[tpl.length - 1] = -1; i++; readTemplateText(); continue }
55
+ tpl[tpl.length - 1]!--
56
+ }
57
+ }
58
+ if (ch === '/' && regexOk()) {
59
+ const s = i
60
+ let j = i + 1
61
+ let inClass = false
62
+ while (j < src.length && src[j] !== '\n') {
63
+ if (src[j] === '\\') { j += 2; continue }
64
+ if (src[j] === '[') inClass = true
65
+ else if (src[j] === ']') inClass = false
66
+ else if (src[j] === '/' && !inClass) break
67
+ j++
68
+ }
69
+ if (src[j] === '/') { j++; while (/[a-z]/.test(src[j] ?? '')) j++; blank(s + 1, j - 1); i = j; last = 'x'; continue }
70
+ }
71
+ if (!/\s/.test(ch)) last = ch
72
+ i++
73
+ }
74
+ return out.join('')
75
+ }
76
+
77
+ /** one top level property of the check object: its whole segment (leading
78
+ * whitespace and comments included), and where its key and value sit */
79
+ export type Prop = { key: string | null; seg: [number, number]; keyAt: number; value: string }
80
+
81
+ export type CheckObject = { open: number; close: number; props: Prop[]; indent: string }
82
+
83
+ const KEY_RE = /^\s*(?:(?:async|get|set)\s+)?([A-Za-z_$][\w$]*)\s*\??\s*[:(]/
84
+
85
+ /** the check object of a file: `check({...})`, else `export default {...}`
86
+ * in a check file. Null when there is none */
87
+ export function findCheckObject(src: string, o: { plainDefault: boolean }): CheckObject | null {
88
+ const m = maskSource(src)
89
+ const call = /\bcheck\s*\(\s*\{/.exec(m)
90
+ const plain = o.plainDefault ? /\bexport\s+default\s*\{/.exec(m) : null
91
+ const hit = call ?? plain
92
+ if (hit == null) return null
93
+ const open = hit.index + hit[0].length - 1
94
+ let depth = 0
95
+ let start = open + 1
96
+ const segs: [number, number][] = []
97
+ let close = -1
98
+ for (let k = open; k < m.length; k++) {
99
+ const ch = m[k]
100
+ if (ch === '{' || ch === '(' || ch === '[') depth++
101
+ else if (ch === '}' || ch === ')' || ch === ']') {
102
+ depth--
103
+ if (depth === 0) { segs.push([start, k]); close = k; break }
104
+ } else if (ch === ',' && depth === 1) { segs.push([start, k]); start = k + 1 }
105
+ }
106
+ if (close < 0) return null
107
+ const props: Prop[] = segs.map(([a, b]) => {
108
+ const km = KEY_RE.exec(m.slice(a, b))
109
+ if (km == null) return { key: null, seg: [a, b], keyAt: -1, value: '' }
110
+ const keyAt = a + km.index + km[0].indexOf(km[1]!)
111
+ const colon = m.indexOf(':', keyAt)
112
+ const isColon = colon >= 0 && colon < b && m.slice(keyAt + km[1]!.length, colon).trim().replace('?', '') === ''
113
+ return { key: km[1]!, seg: [a, b], keyAt, value: isColon ? src.slice(colon + 1, b).trim() : '' }
114
+ })
115
+ const first = props.find((p) => p.key != null)
116
+ const indent = first == null ? ' ' : (/[ \t]*$/.exec(src.slice(0, first.keyAt))?.[0] ?? ' ')
117
+ return { open, close, props, indent }
118
+ }
119
+
120
+ /** what a file's check object lacks or still says in the retired shape: each
121
+ * retired key it declares, `fix` when it is a function, and `runs` when the
122
+ * object declares none (a file that declared nothing ran on defaults) */
123
+ export function retiredKeysIn(src: string, o: { plainDefault: boolean }): string[] {
124
+ const obj = findCheckObject(src, o)
125
+ if (obj == null) return []
126
+ const keys = new Set(obj.props.map((p) => p.key))
127
+ const out: string[] = RETIRED_KEYS.filter((k) => keys.has(k))
128
+ const fix = obj.props.find((p) => p.key === 'fix')
129
+ if (fix != null && !/^(['"])(none|ci|manual)\1$/.test(fix.value)) out.push('fix')
130
+ if (!keys.has('runs') && (keys.has('run') || keys.has('cmd'))) out.push('runs')
131
+ return out
132
+ }
133
+
134
+ /** a string literal's value, or null for any other expression */
135
+ export const literal = (v: string | undefined): string | null => {
136
+ const m = v == null ? null : /^(['"`])([^'"`$\\]*)\1$/.exec(v.trim())
137
+ return m == null ? null : m[2]!
138
+ }
139
+
140
+ /** what a check declared before, as the port reads it */
141
+ export type LegacyDecl = {
142
+ /** the `when` literal, null when absent, undefined when it is not a literal */
143
+ when: string | null | undefined
144
+ build: boolean
145
+ body: 'run' | 'cmd'
146
+ /** the expression text of each declaration, absent when not declared */
147
+ inputs?: string
148
+ ttl?: string
149
+ never?: string
150
+ cacheKey?: string
151
+ every?: string
152
+ cron?: string
153
+ whenInactive?: string
154
+ planOff?: string
155
+ /** a repair function is declared */
156
+ repair: boolean
157
+ /** why the body's imports reach the disk around `c`, for an undeclared
158
+ * `run` body (core/raw-access.ts), else null */
159
+ rawAccess: string | null
160
+ }
161
+
162
+ export type Derived = { runs: string; cache: string; fix: string; applies: string; notes: string[] }
163
+
164
+ /** the explicit declaration a legacy one resolved to, or why it cannot be
165
+ * carried across without a person */
166
+ export function deriveExplicit(d: LegacyDecl, q: (s: string) => string): Derived | { error: string } {
167
+ const notes: string[] = []
168
+ if (d.when === undefined) return { error: '`when` is not a string literal' }
169
+ const whenLit = d.when
170
+ const off = whenLit != null && (whenLit === 'off' || whenLit.startsWith('off:')) ? whenLit.slice(4).trim() || 'off' : null
171
+ const at = off != null ? 'always' : (whenLit ?? 'always')
172
+ if (!['always', 'release', 'health'].includes(at)) return { error: `when '${whenLit}' is not always, release, health or off:<why>` }
173
+ const ttlLit = d.ttl == null ? null : literal(d.ttl)
174
+ const forever = ttlLit === 'forever'
175
+ // the population the gate read it as
176
+ const keyed = d.inputs != null || (d.ttl == null && d.never == null && d.body === 'run' && d.rawAccess == null)
177
+ const world = d.ttl != null
178
+ // the gate axes: a build step never ran in the health pass
179
+ const gates = at === 'always' ? [true, true, true] : at === 'release' ? [false, true, true] : [false, false, false]
180
+ const onHealth = !d.build && true
181
+ // the planner: only an always or health row, and never a build step off the gate
182
+ const plannable = (at === 'always' || at === 'health') && !(d.build && at === 'health')
183
+ const gate = at !== 'health'
184
+ const declaredClock = d.cron != null || d.every !== undefined
185
+ const fallback = forever ? "'once'" : d.ttl != null ? d.ttl : d.build ? null : keyed ? null : DAILY
186
+ const clocked = fallback != null || declaredClock
187
+ const onChange = forever || (!world && (keyed || (gate && !clocked)))
188
+ let onSave = 'false'
189
+ let onClock = 'false'
190
+ if (plannable) {
191
+ onSave = onChange ? (keyed || forever ? "'moved'" : "'burst'") : 'false'
192
+ const every = d.cron ?? (d.every !== undefined ? d.every : fallback)
193
+ if (every != null && every.trim() !== 'null') {
194
+ if (literal(every) === 'once') onClock = "'once'"
195
+ else onClock = `{ every: ${every}, inactive: ${d.whenInactive ?? q('wait')} }`
196
+ }
197
+ }
198
+ if (onClock === "'once'" && !forever) return { error: "every 'once' without ttl 'forever': a fact asked once needs a declared value" }
199
+ if (forever && onClock !== "'once'" && plannable) return { error: "ttl 'forever' with a declared every: a fact is asked once, never on a period" }
200
+ let cache: string
201
+ if (d.inputs != null) cache = `{ by: ${q('files')}, inputs: ${d.inputs}, key: ${d.cacheKey ?? 'null'} }`
202
+ else if (forever) cache = `{ by: ${q('fact')}, key: ${d.cacheKey ?? `() => ${q('declared')}`} }`
203
+ else if (d.ttl != null) cache = `{ by: ${q('ttl')}, span: ${d.ttl}, key: ${d.cacheKey ?? 'null'} }`
204
+ else if (d.never != null) cache = `{ by: ${q('never')}, why: ${d.never} }`
205
+ else if (d.body === 'run' && d.rawAccess == null) cache = `{ by: ${q('reads')} }`
206
+ else if (d.body === 'run') cache = `{ by: ${q('never')}, why: ${q(d.rawAccess!)} }`
207
+ else cache = `{ by: ${q('never')}, why: ${q('a cmd declaring no inputs: nothing keys it')} }`
208
+ if (forever && !plannable) return { error: "ttl 'forever' on a check the planner never ran: a fact nobody asks once is never answered" }
209
+ // the ci fix pass applied every repair; a check off the quick gate now
210
+ // repairs only by name
211
+ const fix = !d.repair ? 'none' : gates[0] ? 'ci' : 'manual'
212
+ if (d.repair && !gates[0]) notes.push("its repair ran in the ci fix pass although the gate did not run it: now fix 'manual', shipkit check --fix <id> applies it")
213
+ if (d.build && d.repair) return { error: 'a build step with a repair: a tool repairs its own findings, drop one of them' }
214
+ const runs = `{ onPreCommit: false, onCi: ${gates[0]}, onFull: ${gates[1]}, onRelease: ${gates[2]}, onHealth: ${d.build ? false : onHealth}, onSave: ${onSave}, onClock: ${onClock} }`
215
+ let applies = d.planOff ?? q('always')
216
+ if (d.planOff != null) notes.push('its planOff only kept it off the planner; as applies it is n/a in every context while it gives a reason')
217
+ if (off != null) {
218
+ if (d.planOff != null) return { error: '`when` off and a planOff: say one reason' }
219
+ applies = `() => ${q(off)}`
220
+ notes.push('it was off in its own file: applies now gives the reason, so it runs nowhere')
221
+ }
222
+ return { runs, cache, fix, applies, notes }
223
+ }
224
+
225
+ export type PortResult =
226
+ | { changed: false; reason: string }
227
+ | { changed: true; source: string; notes: string[] }
228
+ | { changed: false; error: string }
229
+
230
+ /** the file ported, or why not. `rawAccess` is what the caller measured on
231
+ * the file's import closure, needed only for an undeclared `run` body */
232
+ export function portCheckSource(src: string, o: { plainDefault: boolean; rawAccess: string | null }): PortResult {
233
+ const obj = findCheckObject(src, o)
234
+ if (obj == null) return { changed: false, reason: 'no check object' }
235
+ const retired = retiredKeysIn(src, o)
236
+ if (retired.length === 0) return { changed: false, reason: 'already explicit' }
237
+ const byKey = new Map(obj.props.filter((p) => p.key != null).map((p) => [p.key!, p]))
238
+ for (const k of ['runs', 'cache', 'applies']) if (byKey.has(k)) return { changed: false, error: `it declares ${k} beside a retired shape: finish it by hand` }
239
+ const dq = /^\s*import[^\n]*"/m.test(src) && !/^\s*import[^\n]*'/m.test(src)
240
+ const q = (s: string): string => (dq ? `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`)
241
+ const val = (k: string): string | undefined => byKey.get(k)?.value
242
+ const build = val('build')?.trim() === 'true'
243
+ const body = byKey.has('cmd') ? 'cmd' : 'run'
244
+ const whenV = val('when')
245
+ const derived = deriveExplicit({
246
+ when: whenV == null ? null : (literal(whenV) ?? undefined),
247
+ build,
248
+ body,
249
+ inputs: val('inputs'),
250
+ ttl: val('ttl'),
251
+ never: val('never'),
252
+ cacheKey: val('cacheKey'),
253
+ every: val('every'),
254
+ cron: val('cron'),
255
+ whenInactive: val('whenInactive'),
256
+ planOff: val('planOff'),
257
+ repair: retired.includes('fix'),
258
+ rawAccess: o.rawAccess,
259
+ }, q)
260
+ if ('error' in derived) return { changed: false, error: derived.error }
261
+ // rebuild: drop the retired props, keep their comments above the new
262
+ // keys, rename a fix function to repair, insert before the body
263
+ const ind = obj.indent
264
+ const comments: string[] = []
265
+ const kept: string[] = []
266
+ let insertAt = -1
267
+ for (const p of obj.props) {
268
+ const text = src.slice(p.seg[0], p.seg[1])
269
+ if (p.key != null && (RETIRED_KEYS as readonly string[]).includes(p.key)) {
270
+ for (const l of src.slice(p.seg[0], p.keyAt).split('\n')) if (l.trim().startsWith('//')) comments.push(`${ind}${l.trim()}`)
271
+ continue
272
+ }
273
+ if (insertAt < 0 && (p.key === 'run' || p.key === 'cmd' || (p.key === 'fix' && retired.includes('fix')))) insertAt = kept.length
274
+ if (p.key === 'fix' && retired.includes('fix')) {
275
+ const rel = p.keyAt - p.seg[0]
276
+ kept.push(`${text.slice(0, rel)}repair${text.slice(rel + 3)}`)
277
+ continue
278
+ }
279
+ kept.push(text)
280
+ }
281
+ // a trailing comma leaves an empty last segment: keep it last
282
+ const tail = kept.length > 0 && kept[kept.length - 1]!.trim() === '' ? kept.pop()! : null
283
+ if (insertAt < 0 || insertAt > kept.length) insertAt = kept.length
284
+ // each new property is one segment shaped like the others: a newline,
285
+ // the indent, the key. The comments of the keys it replaced lead the first
286
+ const newProps = [
287
+ `\n${comments.map((c) => `${c}\n`).join('')}${ind}runs: ${derived.runs}`,
288
+ `\n${ind}cache: ${derived.cache}`,
289
+ `\n${ind}fix: ${q(derived.fix)}`,
290
+ `\n${ind}applies: ${derived.applies}`,
291
+ ]
292
+ const props = [...kept.slice(0, insertAt), ...newProps, ...kept.slice(insertAt), ...(tail == null ? [] : [tail])]
293
+ const source = `${src.slice(0, obj.open + 1)}${props.join(',')}${src.slice(obj.close)}`
294
+ return { changed: true, source, notes: derived.notes }
295
+ }
@@ -0,0 +1,31 @@
1
+ // which check files of a repo still need the port (core/port-check-file.ts):
2
+ // read through the context alone, so SK029 is cached on the listing and the
3
+ // sources. A legacy check SCRIPT is left to its own static reader.
4
+ import { CHECK_SUFFIX, isLegacyCheckSource, SKIPPED_DIRS, UNIT_SUFFIX } from './check-file-names.ts'
5
+ import type { ScanCtx } from './check-ctx.ts'
6
+ import { retiredKeysIn } from './port-check-file.ts'
7
+
8
+ /** a `.check.ts` or `.check.shipkit.ts` file may export a plain object; a
9
+ * plain unit file is a check only through a `check(...)` call */
10
+ export const plainDefaultFor = (rel: string): boolean => rel.endsWith(CHECK_SUFFIX) || rel.endsWith(`.check${UNIT_SUFFIX}`)
11
+
12
+ const walked = (rel: string): boolean => rel.split('/').slice(0, -1).every((d) => !SKIPPED_DIRS.has(d) && !d.startsWith('.'))
13
+
14
+ /** the cheap text test before the scan: a file that declares `runs` and none
15
+ * of the retired spellings is explicit already */
16
+ const RETIRED_RE = /\b(when|inputs|ttl|never|every|cron|whenInactive|planOff|cacheKey)\s*\??\s*:|\bfix\s*\??\s*:\s*(async\b|\(|function\b|[A-Za-z_$][\w$]*\s*[,\n])/
17
+
18
+ export type Unported = { rel: string; keys: string[] }
19
+
20
+ export function unportedCheckFiles(c: Pick<ScanCtx, 'list' | 'read'>): Unported[] {
21
+ const rels = [...c.list(`**/*${CHECK_SUFFIX}`), ...c.list(`**/*${UNIT_SUFFIX}`)].filter(walked)
22
+ const out: Unported[] = []
23
+ for (const rel of [...new Set(rels)].sort()) {
24
+ const src = c.read(rel)
25
+ if (src == null || isLegacyCheckSource(src)) continue
26
+ if (/\bruns\s*:/.test(src) && !RETIRED_RE.test(src)) continue
27
+ const keys = retiredKeysIn(src, { plainDefault: plainDefaultFor(rel) })
28
+ if (keys.length > 0) out.push({ rel, keys })
29
+ }
30
+ return out
31
+ }
@@ -9,8 +9,8 @@
9
9
  // a millisecond later: the next run hashes the disk again, the two differ, and
10
10
  // the check runs. Nothing here can turn a mid-run edit into a replay.
11
11
  import { createHash } from 'crypto'
12
- import { lstatSync, readdirSync, readFileSync, statSync } from 'fs'
13
- import { join } from 'path'
12
+ import { lstatSync, readdirSync, readFileSync, readlinkSync, statSync } from 'fs'
13
+ import { isAbsolute, join } from 'path'
14
14
 
15
15
  /** `file` or `dir` when the path was there, null when it was not */
16
16
  export type PathKind = 'file' | 'dir' | null
@@ -29,6 +29,60 @@ export type ReadSet = {
29
29
  /** repo-relative path → whether a symlink stood there. Absent from a set
30
30
  * stored before it existed */
31
31
  links?: Record<string, boolean>
32
+ /** path → its stat signature (`statSig`). For a path in `files` it is a
33
+ * fast path: an unchanged signature skips the content hash. For an
34
+ * absolute path outside the repo it is the whole record: a traced task
35
+ * reads its runtime and its tools there, which are too big to hash on
36
+ * every replay and never shared between checkouts anyway */
37
+ sigs?: Record<string, string>
38
+ /** local port → `closed`: a traced task met a refusal there, and its
39
+ * answer holds only while nothing listens on it */
40
+ ports?: Record<string, 'closed'>
41
+ /** symlink → its target, null when no link stands there: what a traced
42
+ * task learned from readlink or lstat */
43
+ targets?: Record<string, string | null>
44
+ }
45
+
46
+ /** the target a symlink at `abs` points to, null when none stands there */
47
+ export function linkTarget(abs: string): string | null {
48
+ try {
49
+ return readlinkSync(abs)
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
55
+ let listenMemo: { at: number; ports: Set<string> } | null = null
56
+
57
+ /** the TCP ports something listens on right now, on any local address. One
58
+ * netstat per second at most: a replay asks for several at once */
59
+ export function listeningPorts(now = Date.now()): Set<string> {
60
+ if (listenMemo != null && now - listenMemo.at < 1_000) return listenMemo.ports
61
+ const ports = new Set<string>()
62
+ const r = Bun.spawnSync(['/usr/sbin/netstat', '-an', '-p', 'tcp'], { stdout: 'pipe', stderr: 'pipe' })
63
+ for (const line of r.stdout.toString().split('\n')) {
64
+ if (!/\bLISTEN\b/.test(line)) continue
65
+ const local = line.trim().split(/\s+/)[3] ?? ''
66
+ const port = local.slice(local.lastIndexOf('.') + 1)
67
+ if (/^\d+$/.test(port)) ports.add(port)
68
+ }
69
+ listenMemo = { at: now, ports }
70
+ return ports
71
+ }
72
+
73
+ /** a path as recorded: repo-relative, or absolute for one outside the repo */
74
+ const at = (root: string, p: string): string => (isAbsolute(p) ? p : join(root, p))
75
+
76
+ /** what stat says about a file: size, modification time to the nanosecond
77
+ * and inode, `-` when nothing is there. Content can change under an equal
78
+ * signature only when a tool restores the mtime on purpose */
79
+ export function statSig(abs: string): string {
80
+ try {
81
+ const s = statSync(abs, { bigint: true })
82
+ return `${s.size}:${s.mtimeNs}:${s.ino}`
83
+ } catch {
84
+ return '-'
85
+ }
32
86
  }
33
87
 
34
88
  /** one entry of a directory, as `c.entries` returns it */
@@ -103,6 +157,9 @@ export class Reads {
103
157
  readonly lists = new Map<string, string>()
104
158
  readonly dirs = new Map<string, string | null>()
105
159
  readonly links = new Map<string, boolean>()
160
+ readonly sigs = new Map<string, string>()
161
+ readonly ports = new Map<string, 'closed'>()
162
+ readonly targets = new Map<string, string | null>()
106
163
 
107
164
  file(path: string, oid: string | null): void { this.files.set(path, oid) }
108
165
  exist(path: string, kind: PathKind): void { this.exists.set(path, kind) }
@@ -116,11 +173,26 @@ export class Reads {
116
173
  for (const [k, v] of o.lists) this.lists.set(k, v)
117
174
  for (const [k, v] of o.dirs) this.dirs.set(k, v)
118
175
  for (const [k, v] of o.links) this.links.set(k, v)
176
+ for (const [k, v] of o.sigs) this.sigs.set(k, v)
177
+ for (const [k, v] of o.ports) this.ports.set(k, v)
178
+ for (const [k, v] of o.targets) this.targets.set(k, v)
179
+ }
180
+
181
+ /** a whole set, as a traced process read it (trace/read-trace.ts) */
182
+ mergeSet(s: ReadSet): void {
183
+ for (const [k, v] of Object.entries(s.files)) this.files.set(k, v)
184
+ for (const [k, v] of Object.entries(s.exists)) this.exists.set(k, v)
185
+ for (const [k, v] of Object.entries(s.lists)) this.lists.set(k, v)
186
+ for (const [k, v] of Object.entries(s.dirs ?? {})) this.dirs.set(k, v)
187
+ for (const [k, v] of Object.entries(s.links ?? {})) this.links.set(k, v)
188
+ for (const [k, v] of Object.entries(s.sigs ?? {})) this.sigs.set(k, v)
189
+ for (const [k, v] of Object.entries(s.ports ?? {})) this.ports.set(k, v)
190
+ for (const [k, v] of Object.entries(s.targets ?? {})) this.targets.set(k, v)
119
191
  }
120
192
 
121
193
  snapshot(): ReadSet {
122
194
  const sorted = <V>(m: Map<string, V>): Record<string, V> => Object.fromEntries([...m].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
123
- return { files: sorted(this.files), exists: sorted(this.exists), lists: sorted(this.lists), dirs: sorted(this.dirs), links: sorted(this.links) }
195
+ return { files: sorted(this.files), exists: sorted(this.exists), lists: sorted(this.lists), dirs: sorted(this.dirs), links: sorted(this.links), ...(this.sigs.size > 0 ? { sigs: sorted(this.sigs) } : {}), ...(this.ports.size > 0 ? { ports: sorted(this.ports) } : {}), ...(this.targets.size > 0 ? { targets: sorted(this.targets) } : {}) }
124
196
  }
125
197
  }
126
198
 
@@ -128,25 +200,41 @@ export class Reads {
128
200
  * does. `list` answers a glob the way `c.list` would right now, and returns
129
201
  * null when it cannot answer honestly (no git scan), which counts as drift. */
130
202
  export function readSetDrift(root: string, set: ReadSet, list: (glob: string) => readonly string[] | null): string | null {
131
- for (const [p, oid] of Object.entries(set.files)) {
132
- const now = oidOnDisk(join(root, p))
133
- if (now !== oid) return `${p} ${oid == null ? 'appeared' : now == null ? 'is gone' : 'changed'}`
203
+ const sigs = set.sigs ?? {}
204
+ for (const [p, sig] of Object.entries(sigs)) {
205
+ if (p in set.files) continue
206
+ const now = statSig(at(root, p))
207
+ if (now !== sig) return `${p} ${sig === '-' ? 'appeared' : now === '-' ? 'is gone' : 'changed'}`
134
208
  }
135
209
  for (const [p, kind] of Object.entries(set.exists)) {
136
- const now = kindOf(join(root, p))
210
+ const now = kindOf(at(root, p))
137
211
  if (now !== kind) return `${p} ${kind == null ? 'appeared' : now == null ? 'is gone' : `is a ${now} now`}`
138
212
  }
213
+ for (const [p, oid] of Object.entries(set.files)) {
214
+ const abs = at(root, p)
215
+ if (sigs[p] != null && statSig(abs) === sigs[p]) continue
216
+ const now = oidOnDisk(abs)
217
+ if (now !== oid) return `${p} ${oid == null ? 'appeared' : now == null ? 'is gone' : 'changed'}`
218
+ }
139
219
  for (const [g, hash] of Object.entries(set.lists)) {
140
220
  const now = list(g)
141
221
  if (now == null) return `${g} cannot be listed without a git scan`
142
222
  if (listingHash(now) !== hash) return `the files matching ${g} changed`
143
223
  }
144
224
  for (const [d, hash] of Object.entries(set.dirs ?? {})) {
145
- const now = entriesHash(dirEntries(join(root, d)))
225
+ const now = entriesHash(dirEntries(at(root, d)))
146
226
  if (now !== hash) return `${d}/ ${hash == null ? 'appeared' : now == null ? 'is gone' : 'changed its entries'}`
147
227
  }
228
+ for (const [p, t] of Object.entries(set.targets ?? {})) {
229
+ if (linkTarget(at(root, p)) !== t) return `${p} points elsewhere now`
230
+ }
231
+ const closed = Object.keys(set.ports ?? {})
232
+ if (closed.length > 0) {
233
+ const open = listeningPorts()
234
+ for (const port of closed) if (open.has(port)) return `a service listens on local port ${port} now`
235
+ }
148
236
  for (const [p, was] of Object.entries(set.links ?? {})) {
149
- if (isLinkAt(join(root, p)) !== was) return `${p} ${was ? 'is no longer a link' : 'became a link'}`
237
+ if (isLinkAt(at(root, p)) !== was) return `${p} ${was ? 'is no longer a link' : 'became a link'}`
150
238
  }
151
239
  return null
152
240
  }
@@ -16,8 +16,6 @@ import { JOBS, JOBS_KEEP_MS, JOBS_MAX, sweepJobs } from './jobs.ts'
16
16
  import { CI_LOG_DIR } from './layout.ts'
17
17
  import { compactDb, dbPath, shipkitRoot } from '../ledger/db.ts'
18
18
  import { SCAN_INDEX_KEEP_MS, sweepScanIndexes } from '../ledger/git-scan.ts'
19
- import { capLog, LOG_MAX_BYTES } from '../home/supervise.ts'
20
- import { processDir } from '../serve/repo-processes.ts'
21
19
  import { KEEP_MS as RECORD_KEEP_MS, MAX_ROWS, recordUse, sweepRuns } from '../ledger/run-log.ts'
22
20
  import { answersDb, answersUse, importLegacyStore, KEEP_MS as ANSWERS_KEEP_MS, legacyStoreDir, MAX_ANSWERS, sweepAnswers } from '../ledger/store.ts'
23
21
 
@@ -169,18 +167,6 @@ export function repoStores(root: string): Store[] {
169
167
  },
170
168
  sweep: (now) => sweepJobs(root, now),
171
169
  },
172
- {
173
- // the dev servers the hub runs for the repo (serve.processes)
174
- id: 'process logs',
175
- where: `${processDir(root)}/*.log`,
176
- cap: { maxFileBytes: LOG_MAX_BYTES },
177
- measure: () => walkUse(processDir(root), { match: (n) => n.endsWith('.log') }),
178
- sweep: () => {
179
- let names: string[] = []
180
- try { names = readdirSync(processDir(root)) } catch { return }
181
- for (const n of names) if (n.endsWith('.log')) capLog(join(processDir(root), n))
182
- },
183
- },
184
170
  ]
185
171
  if (gitDir != null) {
186
172
  const dir = join(gitDir, 'shipkit')
@@ -17,7 +17,7 @@ import { gateBinary } from '../ledger/toolchain-identity.ts'
17
17
  import { runChecks, selectChecks, type Audience, type CheckEvent, type CheckResult, type RunnableCheck } from './check-runner.ts'
18
18
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
19
19
  import { reportToMotherhome } from './motherhome.ts'
20
- import { unknownWhenIds } from './when.ts'
20
+ import { unknownOffIds } from './when.ts'
21
21
  import { unknownBudgetIds } from './budget.ts'
22
22
  import { formatLine, type Line } from './wire.ts'
23
23
  import { writeStatus } from './write-status.ts'
@@ -75,6 +75,8 @@ export type CheckOpts = {
75
75
  buildOnly?: boolean
76
76
  /** the gate asks the proactive checks too (`release.requireHealthy`) */
77
77
  includeProactive?: boolean
78
+ /** the release preflight: the rows declaring `onRelease` run */
79
+ release?: boolean
78
80
  /** ids this caller does not need (a sub-unit release) */
79
81
  exclude?: string[]
80
82
  /** the already-composed check list, when the caller built one (runCi does,
@@ -107,9 +109,9 @@ export type CheckRun = {
107
109
  advisoryFailures: string[]
108
110
  /** checks that never declared `blocking` — always fatal */
109
111
  undeclared: string[]
110
- /** `when` entries naming no check of this repo — a toggle that does
112
+ /** `off` entries naming no check of this repo — a toggle that does
111
113
  * nothing is worse than none, so it is fatal too */
112
- unknownWhen: string[]
114
+ unknownOff: string[]
113
115
  /** `budgets` entries naming no check of this repo, fatal for the same reason */
114
116
  unknownBudgets: string[]
115
117
  /** what every asked-for check did, in declaration order */
@@ -125,6 +127,7 @@ function selectionOf(checks: RunnableCheck[], o: CheckOpts, tier: TestTier) {
125
127
  checks,
126
128
  audience: o.audience ?? 'health',
127
129
  fullTier: tier === 'full',
130
+ release: o.release,
128
131
  skipGroups: o.skipGroups,
129
132
  onlyIds: o.only,
130
133
  withIds: o.withIds,
@@ -211,7 +214,7 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
211
214
  // page just replaced
212
215
  if (summary.parked.length > 0) {
213
216
  const head = summary.parked.slice(0, 8).join(', ')
214
- say({ kind: 'skip', text: `${summary.parked.length} check(s) not in this run (health only, release only at the quick tier, or a skipped group): ${head}${summary.parked.length > 8 ? `, and ${summary.parked.length - 8} more` : ''}` }, o.detail ?? print)
217
+ say({ kind: 'skip', text: `${summary.parked.length} check(s) not in this run (their runs do not name this context, or a skipped group, shipkit when shows each): ${head}${summary.parked.length > 8 ? `, and ${summary.parked.length - 8} more` : ''}` }, o.detail ?? print)
215
218
  }
216
219
  // the survey dies with the run that made it, and only the run that made it
217
220
  if (inherited == null || inherited === '') for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
@@ -225,17 +228,17 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
225
228
  if (o.ledger === undefined && ledger != null) for (const l of ledger.header(ledger.commit())) say(l)
226
229
  // the caller that OPENED the record owns writing it, the same rule
227
230
  if (o.runLog === undefined && log != null) for (const l of log.flush()) say(l)
228
- // a `when` id naming nothing is a switch wired to no check: the id is read
231
+ // an `off` id naming nothing is a switch wired to no check: the id is read
229
232
  // against the WHOLE inventory this run composed, never its narrowed list
230
233
  const rows = composed.map((g) => g.row)
231
- const unknownWhen = unknownWhenIds(rows, c)
234
+ const unknownOff = unknownOffIds(rows, c)
232
235
  const unknownBudgets = unknownBudgetIds(rows.map((r) => r.decl.id), c.budgets)
233
236
  return {
234
237
  lines: [...collected, ...lines],
235
238
  blocked: !summary.ok,
236
239
  advisoryFailures: summary.advisoryFailures,
237
240
  undeclared: summary.undeclared,
238
- unknownWhen,
241
+ unknownOff,
239
242
  unknownBudgets,
240
243
  results: summary.results,
241
244
  interrupted: summary.interrupted,
@@ -247,13 +250,13 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
247
250
  const print = o.print ?? ((l: string) => console.log(l))
248
251
  const c = o.config ?? (await loadRepoConfig(root))
249
252
  const run = await runCheckPass(root, { ...o, config: c })
250
- const code = run.blocked || run.undeclared.length > 0 || run.unknownWhen.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
253
+ const code = run.blocked || run.undeclared.length > 0 || run.unknownOff.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
251
254
  if (o.json === true) { print(JSON.stringify(run)); return code }
252
255
  for (const u of run.undeclared) {
253
256
  print(`FAIL: ${u} does not declare whether it blocks`)
254
257
  print(` fix: add \`blocking: true\` (about this repo) or \`false\` (about the world) to that check`)
255
258
  }
256
- for (const key of [...run.unknownWhen.map((id) => `when.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
259
+ for (const key of [...run.unknownOff.map((id) => `off.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
257
260
  print(`FAIL: ${key} names no check of this repo`)
258
261
  print(` fix: \`shipkit checks\` lists every id; drop the entry or correct it`)
259
262
  }
@@ -32,8 +32,11 @@ export type CiOpts = {
32
32
  /** repair the mechanical slots before gating. default on locally, verify
33
33
  * mode under CI. core/run-fixers.ts owns the scope. */
34
34
  fix?: boolean
35
- /** also apply the structural SK rule fixers during the fix pass */
35
+ /** also apply the repairs of the checks declaring `fix: 'ci'` during the
36
+ * fix pass */
36
37
  fixRules?: boolean
38
+ /** the release preflight: the rows declaring `onRelease` run */
39
+ release?: boolean
37
40
  /** self-arm the git hooks in the fix pass (interactive ci entry only) */
38
41
  armHooks?: boolean
39
42
  /** run only the checks that can block (core/run-checks onlyBlocking) */
@@ -203,6 +206,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
203
206
  composed,
204
207
  audience: 'gate',
205
208
  includeProactive: o.failOnAdvisory === true,
209
+ release: o.release,
206
210
  frozen: o.frozen,
207
211
  onlyBlocking: o.onlyBlockingChecks,
208
212
  only: o.onlyIds,
@@ -226,7 +230,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
226
230
  })
227
231
  const reg = ledger.layer.registration
228
232
  for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
229
- if (registrationFailed) sink(' fix: a check declares `inputs` (content), or `ttl` (world), or `never: <reason>`, and never two of them')
233
+ if (registrationFailed) sink(' fix: each check declares one cache that its body can honour: files, reads, ttl, fact or never (build/shipkit/runs.md)')
230
234
  // both counts, always. Printing only the burn-down made `never:` the
231
235
  // cheap way to shrink the visible number, and that field carries no
232
236
  // verification at all: a growing decided population has to be as
@@ -262,7 +266,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
262
266
  }
263
267
  // a switch wired to no check does nothing, silently, which is the one thing
264
268
  // a toggle must never do
265
- for (const key of [...run.unknownWhen.map((id) => `when.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
269
+ for (const key of [...run.unknownOff.map((id) => `off.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
266
270
  sink(`FAIL: ${key} names no check of this repo`)
267
271
  sink(` fix: \`shipkit checks\` lists every id; drop the entry or correct it`)
268
272
  }
@@ -280,10 +284,15 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
280
284
  // stored under a key that describes the file as it was before the edit.
281
285
  // the counts line folds into the verdict; what else it says (a store
282
286
  // that failed, answers thrown away) is printed as it is
283
- if (ledger != null) for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
287
+ if (ledger != null) {
288
+ for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
289
+ // why every check that did not replay ran, grouped: a re-run nobody
290
+ // can explain costs a reader more than the line does
291
+ for (const { line, quiet } of ledger.explain()) (quiet ? detail : sink)(formatLine(line, false))
292
+ }
284
293
  // one write for the whole gate, after everything has finished
285
294
  for (const l of runLog.flush()) sink(formatLine(l, false))
286
- const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownWhen.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
295
+ const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownOff.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
287
296
  // THE VERDICT, one line and only on a pass: a red run already said so in
288
297
  // its FAIL lines. The whole gate adds the tier and the cache counts, which
289
298
  // a narrowed run asked no question about