rvlib-shipkit 1.4.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 (161) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/check.ts +68 -95
  5. package/src/checks/SK001-package-json-shape.check.ts +6 -2
  6. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  7. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  8. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  9. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  10. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  11. package/src/checks/SK007-github-visibility.check.ts +4 -2
  12. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  13. package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
  14. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  15. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  16. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  17. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  18. package/src/checks/SK014-prose-ste.check.ts +4 -0
  19. package/src/checks/SK015-nested-repo.check.ts +4 -2
  20. package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
  21. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  22. package/src/checks/SK018-test-files-run.check.ts +6 -2
  23. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  24. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  25. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  26. package/src/checks/SK024-identity-colors.check.ts +5 -3
  27. package/src/checks/SK025-release-drift.check.ts +4 -5
  28. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  29. package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
  30. package/src/checks/SK028-readme.check.ts +5 -1
  31. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  32. package/src/cli/cmd_Asks.ts +5 -5
  33. package/src/cli/cmd_Assets.ts +7 -7
  34. package/src/cli/cmd_Bump.ts +12 -11
  35. package/src/cli/cmd_Check.ts +8 -7
  36. package/src/cli/cmd_Checks.ts +7 -6
  37. package/src/cli/cmd_Ci.ts +12 -11
  38. package/src/cli/cmd_Commands.ts +16 -13
  39. package/src/cli/cmd_ConfigKeys.ts +7 -6
  40. package/src/cli/cmd_Contracts.ts +3 -3
  41. package/src/cli/cmd_CrossRepoActivity.ts +6 -5
  42. package/src/cli/cmd_Fleet.ts +9 -7
  43. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  44. package/src/cli/cmd_Home.ts +23 -17
  45. package/src/cli/cmd_Hooks.ts +6 -5
  46. package/src/cli/cmd_Init.ts +5 -4
  47. package/src/cli/cmd_Keys.ts +8 -7
  48. package/src/cli/cmd_Mv.ts +7 -6
  49. package/src/cli/cmd_Needs.ts +4 -3
  50. package/src/cli/cmd_Overview.ts +4 -3
  51. package/src/cli/cmd_Pb.ts +2 -2
  52. package/src/cli/cmd_Planned.ts +6 -5
  53. package/src/cli/cmd_Prose.ts +5 -5
  54. package/src/cli/cmd_Reflect.ts +7 -7
  55. package/src/cli/cmd_Release.ts +6 -5
  56. package/src/cli/cmd_Releases.ts +5 -4
  57. package/src/cli/cmd_Serve.ts +4 -3
  58. package/src/cli/cmd_Smoke.ts +5 -4
  59. package/src/cli/cmd_Start.ts +7 -6
  60. package/src/cli/cmd_Status.ts +4 -3
  61. package/src/cli/cmd_Tui.ts +3 -2
  62. package/src/cli/cmd_When.ts +71 -0
  63. package/src/cli/cmd_Wiki.ts +4 -4
  64. package/src/cli.ts +7 -2
  65. package/src/collections/checks.ts +2 -0
  66. package/src/collections/collection-types.ts +9 -4
  67. package/src/core/canonical-scripts.ts +1 -1
  68. package/src/core/check-runner.ts +37 -20
  69. package/src/core/config-rewrite.ts +37 -24
  70. package/src/core/discover-checks.ts +23 -2
  71. package/src/core/legacy-shapes.ts +39 -10
  72. package/src/core/one-way-in-policy.ts +2 -2
  73. package/src/core/outcome-words.ts +18 -2
  74. package/src/core/port-check-cli.ts +52 -0
  75. package/src/core/port-check-file.ts +295 -0
  76. package/src/core/port-check-files.ts +31 -0
  77. package/src/core/retention.ts +0 -14
  78. package/src/core/run-check.ts +12 -9
  79. package/src/core/run-ci.ts +8 -4
  80. package/src/core/run-fix.ts +30 -23
  81. package/src/core/run-fixers.ts +42 -5
  82. package/src/core/runs-matrix.ts +179 -0
  83. package/src/core/runs.ts +242 -0
  84. package/src/core/update-policy.ts +20 -0
  85. package/src/core/when-block.ts +29 -30
  86. package/src/core/when.ts +41 -103
  87. package/src/core/wire.ts +10 -14
  88. package/src/fleet/scan-fleet.ts +20 -4
  89. package/src/fleet/update-fleet.ts +6 -6
  90. package/src/home/home-launchd.ts +20 -4
  91. package/src/home/home-routes.ts +12 -1
  92. package/src/home/hubs.ts +5 -5
  93. package/src/home/pm.ts +8 -0
  94. package/src/home/run-pm.ts +1 -0
  95. package/src/home/serve-web.ts +2 -2
  96. package/src/home/supervise.ts +24 -1
  97. package/src/hooks/run-hooks.ts +48 -4
  98. package/src/init-repo.ts +6 -6
  99. package/src/ledger/gate-checks.ts +113 -59
  100. package/src/ledger/git-key.ts +5 -1
  101. package/src/ledger/ledger.ts +5 -5
  102. package/src/ledger/toolchain-identity.ts +45 -27
  103. package/src/plan/repo-source.ts +18 -39
  104. package/src/release/run-release.ts +5 -0
  105. package/src/repo-config.ts +30 -55
  106. package/src/rules/rule.ts +2 -2
  107. package/src/scaffold/files/agent-coding.md +1 -1
  108. package/src/scaffold/files/agent-index.md +1 -1
  109. package/src/scaffold/files/verify-skill.md +2 -2
  110. package/src/serve/hub-app/activity-st.ts +5 -4
  111. package/src/serve/hub-app/activity-view.tsx +1 -1
  112. package/src/serve/hub-app/admin-view.tsx +13 -11
  113. package/src/serve/hub-app/check-view.tsx +66 -46
  114. package/src/serve/hub-app/check-words.ts +1 -11
  115. package/src/serve/hub-app/checks-svc.ts +14 -35
  116. package/src/serve/hub-app/col-tables-view.tsx +13 -6
  117. package/src/serve/hub-app/cols-svc.ts +5 -4
  118. package/src/serve/hub-app/dock-st.ts +3 -2
  119. package/src/serve/hub-app/dock-view.tsx +36 -0
  120. package/src/serve/hub-app/dock.css +6 -0
  121. package/src/serve/hub-app/float-cards.tsx +30 -0
  122. package/src/serve/hub-app/floating.tsx +41 -4
  123. package/src/serve/hub-app/git-st.ts +42 -0
  124. package/src/serve/hub-app/git-view.tsx +61 -0
  125. package/src/serve/hub-app/hash-route.ts +45 -1
  126. package/src/serve/hub-app/header-bar.tsx +62 -72
  127. package/src/serve/hub-app/health-svc.ts +3 -2
  128. package/src/serve/hub-app/home-st.ts +14 -2
  129. package/src/serve/hub-app/home-strip.css +43 -8
  130. package/src/serve/hub-app/home-strip.tsx +111 -47
  131. package/src/serve/hub-app/home.html +1 -0
  132. package/src/serve/hub-app/hub-app.tsx +14 -4
  133. package/src/serve/hub-app/hub-types.ts +5 -2
  134. package/src/serve/hub-app/hub.css +65 -43
  135. package/src/serve/hub-app/icons.tsx +11 -0
  136. package/src/serve/hub-app/index.html +1 -0
  137. package/src/serve/hub-app/keys.ts +5 -2
  138. package/src/serve/hub-app/live-svc.ts +3 -2
  139. package/src/serve/hub-app/machine-st.ts +18 -22
  140. package/src/serve/hub-app/plan-view.tsx +51 -0
  141. package/src/serve/hub-app/planned-st.ts +9 -4
  142. package/src/serve/hub-app/processes-view.tsx +38 -0
  143. package/src/serve/hub-app/projects-svc.ts +15 -10
  144. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  145. package/src/serve/hub-app/reach.ts +36 -0
  146. package/src/serve/hub-app/readme-svc.ts +3 -2
  147. package/src/serve/hub-app/repo-sections.ts +117 -0
  148. package/src/serve/hub-app/repo-st.ts +46 -9
  149. package/src/serve/hub-app/sidebar.tsx +12 -37
  150. package/src/serve/hub-app/timeline-svc.ts +3 -2
  151. package/src/serve/hub-app/tree-build.ts +149 -155
  152. package/src/serve/hub-app/tree-row.tsx +39 -9
  153. package/src/serve/hub-app/tree-svc.ts +70 -49
  154. package/src/serve/hub-app/when-view.tsx +89 -0
  155. package/src/serve/hub-app/when.css +17 -0
  156. package/src/serve/hub-runtime.ts +3 -16
  157. package/src/serve/run-serve.ts +1 -1
  158. package/src/serve/serve-hub.ts +4 -14
  159. package/src/serve/hub-app/processes-svc.ts +0 -63
  160. package/src/serve/hub-app/repo-sections.tsx +0 -61
  161. package/src/serve/repo-processes.ts +0 -89
@@ -0,0 +1,52 @@
1
+ // THE WRITE HALF of the check file port (core/port-check-file.ts), in its own
2
+ // process: whether an undeclared `run` body can be keyed on its reads is a walk
3
+ // of its import closure on disk (core/raw-access.ts), which no check context
4
+ // read can stand for, so SK029's repair reaches it through `c.exec`.
5
+ //
6
+ // bun port-check-cli.ts <root> <rel> [<rel> …] → one json line on stdout
7
+ import { readFileSync } from 'fs'
8
+ import { join } from 'path'
9
+ import { atomicWrite } from './locked-write.ts'
10
+ import { LIB_ROOT } from './lib-root.ts'
11
+ import { rawAccessInClosure } from './raw-access.ts'
12
+ import { findCheckObject, portCheckSource, retiredKeysIn } from './port-check-file.ts'
13
+ import { plainDefaultFor } from './port-check-files.ts'
14
+
15
+ export type PortRun = { did: string[]; hand: string[] }
16
+
17
+ /** why an undeclared `run` body cannot be keyed on its reads, or null */
18
+ function rawAccessOf(root: string, rel: string, src: string): string | null {
19
+ const obj = findCheckObject(src, { plainDefault: plainDefaultFor(rel) })
20
+ const keys = new Set(obj?.props.map((p) => p.key) ?? [])
21
+ if (!keys.has('run') || keys.has('inputs') || keys.has('ttl') || keys.has('never')) return null
22
+ const r = rawAccessInClosure(root, join(root, rel), { skip: LIB_ROOT })
23
+ return r == null ? null : `its imports reach the disk around c (${r.file}:${r.line}, ${r.what})`
24
+ }
25
+
26
+ export function portFiles(root: string, rels: string[]): PortRun {
27
+ const did: string[] = []
28
+ const hand: string[] = []
29
+ for (const rel of rels) {
30
+ const abs = join(root, rel)
31
+ let src: string
32
+ try { src = readFileSync(abs, 'utf8') } catch (e) { hand.push(`${rel}: unreadable (${(e as Error).message})`); continue }
33
+ const plainDefault = plainDefaultFor(rel)
34
+ const keys = retiredKeysIn(src, { plainDefault })
35
+ if (keys.length === 0) continue
36
+ const r = portCheckSource(src, { plainDefault, rawAccess: rawAccessOf(root, rel, src) })
37
+ if ('error' in r) { hand.push(`${rel}: ${r.error}`); continue }
38
+ if (!r.changed) continue
39
+ atomicWrite(abs, r.source)
40
+ did.push(`${rel} declares runs, cache, fix and applies (was: ${keys.join(', ')})${r.notes.length > 0 ? `. Note: ${r.notes.join('; ')}` : ''}`)
41
+ }
42
+ return { did, hand }
43
+ }
44
+
45
+ if (import.meta.main) {
46
+ const [root, ...rels] = process.argv.slice(2)
47
+ if (root == null) {
48
+ console.error('usage: bun port-check-cli.ts <root> <rel> [<rel> …]')
49
+ process.exit(2)
50
+ }
51
+ console.log(JSON.stringify(portFiles(root, rels)))
52
+ }
@@ -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
+ }
@@ -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
  }
@@ -283,7 +287,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
283
287
  if (ledger != null) for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
284
288
  // one write for the whole gate, after everything has finished
285
289
  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
290
+ const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownOff.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
287
291
  // THE VERDICT, one line and only on a pass: a red run already said so in
288
292
  // its FAIL lines. The whole gate adds the tier and the cache counts, which
289
293
  // a narrowed run asked no question about
@@ -1,11 +1,13 @@
1
- // `shipkit check --fix [<id> …]` — apply check autofixes, then re-run and
2
- // show the real end state. Never runs under ci (the gate stays pure), never
3
- // caches (a fixer must see reality). No ids = every fixable check; a
4
- // requested id that is unknown or unfixable fails loud instead of silently
5
- // narrowing.
1
+ // `shipkit check --fix [<id> …]` — apply check repairs, then re-run and
2
+ // show the real end state. Never caches (a repair must see reality). No ids =
3
+ // every check declaring `fix: 'ci'`, which is also what the gate's fix pass
4
+ // applies; a `fix: 'manual'` repair runs only when its id is named. A
5
+ // requested id that is unknown or has no repair fails loud instead of
6
+ // silently narrowing.
6
7
  import { CheckCtx } from './check-ctx.ts'
7
8
  import { formatLine } from './wire.ts'
8
- import { whenOfRule, whenOfCheck, type WhenDecl } from './when.ts'
9
+ import type { FixWhen } from './runs.ts'
10
+ import { gateInventory } from '../ledger/gate-checks.ts'
9
11
  import { loadRepoConfig } from '../repo-config.ts'
10
12
  import { discoverChecks } from './discover-checks.ts'
11
13
  import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
@@ -34,19 +36,25 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
34
36
  const ctx = (c: RepoConfig): CheckCtx => CheckCtx.open({ root, config: c })
35
37
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
36
38
  /** one fixable check, whatever declared it */
37
- type Target = { id: string; title: string; owner: string; run: CheckDef['run']; fix?: CheckDef['fix']; when: WhenDecl }
39
+ type Target = { id: string; title: string; owner: string; run: CheckDef['run']; fix?: CheckDef['repair']; when: FixWhen; off: string | null }
40
+ // what each row resolved to: its declared `fix` and whether it is off
41
+ const resolved = new Map((await gateInventory(root, config)).map((r) => [r.decl.id, r.runs]))
38
42
  // a rule the repo still registers fixes exactly like a check file, one
39
- // code path. Only a check that declared a `fix` is a target at all
43
+ // code path, with the fix word its derived row carries
40
44
  const all: Target[] = [
41
- ...(await discoverChecks(root)).map((u): Target => ({ id: u.id, title: u.title, owner: u.owner, run: u.def.run, fix: u.def.fix, when: whenOfCheck(config, u) })),
42
- ...(config.rules ?? []).map((r): Target => ({
43
- id: r.id, title: r.title, owner: r.owner, when: whenOfRule(config, r.id),
44
- run: (c) => r.lint(c, { root: c.root, config: c.config }),
45
- fix: r.fix == null ? undefined : (c) => r.fix!({ root: c.root, config: c.config }),
46
- })),
45
+ ...(await discoverChecks(root)).map((u): Target => ({ id: u.id, title: u.title, owner: u.owner, run: u.def.run, fix: u.def.repair, when: resolved.get(u.id)?.fix ?? u.def.fix, off: resolved.get(u.id)?.off ?? null })),
46
+ ...(config.rules ?? []).map((r): Target => {
47
+ const runs = resolved.get(r.id)
48
+ return {
49
+ id: r.id, title: r.title, owner: r.owner, when: runs?.fix ?? 'none', off: runs?.off ?? null,
50
+ run: (c) => r.lint(c, { root: c.root, config: c.config }),
51
+ fix: r.fix == null ? undefined : (c) => r.fix!({ root: c.root, config: c.config }),
52
+ }
53
+ }),
47
54
  ]
48
55
 
49
- let targets = all.filter((r) => r.fix != null)
56
+ // no ids: the repairs declared for the gate. A manual one runs by name
57
+ let targets = all.filter((r) => r.fix != null && r.when === 'ci')
50
58
  if (o.ids != null && o.ids.length > 0) {
51
59
  for (const id of o.ids) {
52
60
  const r = all.find((x) => x.id === id)
@@ -54,17 +62,16 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
54
62
  if (r.fix == null) { print(`FAIL: [${id}] has no autofix — fix by hand: ${config.docsBase ?? ''}${r.owner}`); return 1 }
55
63
  }
56
64
  const wanted = new Set(o.ids)
57
- targets = targets.filter((r) => wanted.has(r.id))
65
+ targets = all.filter((r) => r.fix != null && wanted.has(r.id))
58
66
  }
59
67
 
60
68
  let exit = 0
61
69
  for (const rule of targets) {
62
70
  const before = ctx(env.config)
63
- const off = rule.when
64
- await runCheckBody(rule, before, off.at === 'off' ? { reason: off.reason } : undefined)
71
+ await runCheckBody(rule, before, rule.off != null ? { reason: rule.off } : undefined)
65
72
  // a check that is OFF never autofixes: the repo broke that invariant on purpose
66
- if (off.at === 'off') {
67
- if (o.quiet !== true) print(`skip: [${rule.id}] ${rule.id} exempt: ${off.reason ?? ''}`)
73
+ if (rule.off != null) {
74
+ if (o.quiet !== true) print(`skip: [${rule.id}] ${rule.id} exempt: ${rule.off}`)
68
75
  continue
69
76
  }
70
77
  if (!before.failed && !before.warned) {
@@ -97,8 +104,8 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
97
104
  for (const l of after.lines) (o.quiet === true ? o.detail ?? print : say)(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix \S+$/, '') }, color))
98
105
  if (after.failed) exit = 1
99
106
  }
100
- const unfixable = all.filter((r) => r.fix == null).length
101
- if (unfixable > 0 && o.quiet !== true && (o.ids == null || o.ids.length === 0))
102
- print(`${unfixable} check(s) have no autofix — they report in the run below`)
107
+ const manual = all.filter((r) => r.fix != null && r.when === 'manual').map((r) => r.id)
108
+ if (manual.length > 0 && o.quiet !== true && (o.ids == null || o.ids.length === 0))
109
+ print(`${manual.length} repair(s) run only by name: shipkit check --fix ${manual.join(' ')}`)
103
110
  return exit
104
111
  }