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
@@ -321,6 +321,8 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
321
321
  const base: Instance = {
322
322
  id: `media:${f.asset.id}`,
323
323
  text: f.asset.id,
324
+ label: f.asset.label ?? f.asset.id,
325
+ group: f.kind.category,
324
326
  // the roll count is the one fact a row of assets is read for
325
327
  slot: rolls.length > 0 ? `${rolls.length} roll${rolls.length === 1 ? '' : 's'}` : manual ? 'manual' : undefined,
326
328
  type: 'asset',
@@ -343,11 +345,11 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
343
345
  const said: Partial<Instance> = manual && rolls.length === 0
344
346
  // the brief IS the paste-ready artifact ("I don't even
345
347
  // know what to paste" — the tile hands him the prompt)
346
- ? { text: `${f.asset.id} — manual, nothing uploaded`, label: f.asset.id, verbs: ['provide'], artifact: f.asset.prompt != null ? { copy: f.asset.prompt } : undefined }
348
+ ? { text: `${f.asset.id} — manual, nothing uploaded`, verbs: ['provide'], artifact: f.asset.prompt != null ? { copy: f.asset.prompt } : undefined }
347
349
  : rolls.length > 0 && !picked
348
- ? { text: `${f.asset.id} — ${rolls.length} roll(s), default in use, none picked`, label: f.asset.id, verbs: ['pick'] }
350
+ ? { text: `${f.asset.id} — ${rolls.length} roll(s), default in use, none picked`, verbs: ['pick'] }
349
351
  : stale
350
- ? { text: `${f.asset.id} — stale (a parent was re-picked)`, label: f.asset.id, verbs: ['pick'] }
352
+ ? { text: `${f.asset.id} — stale (a parent was re-picked)`, verbs: ['pick'] }
351
353
  : {}
352
354
  out.push({ ...base, ...said, ...colour })
353
355
  }
@@ -0,0 +1,154 @@
1
+ // a repo's app icon, drawn from its mark: the mark's transparent margin is
2
+ // cut away, and what is left sits on a rounded plate in the repo's identity
3
+ // colour, on the macOS icon grid (an 824 plate on a 1024 canvas). A mark that
4
+ // already fills its box (a photo, a square tile) becomes the plate itself.
5
+ // The app build and the page's preview both draw through appIconPngs.
6
+ import { createCanvas, Image, type Canvas } from '@napi-rs/canvas'
7
+ import { existsSync, readFileSync } from 'fs'
8
+ import { join } from 'path'
9
+ import { identityOfName, identityOfText, type Identity } from './identity-color.ts'
10
+ import { configName, CONFIG_FILE } from './identity.ts'
11
+ import { LAYOUT } from './layout.ts'
12
+
13
+ /** the mark an app wears: the repo's own, largest first. Rasters only */
14
+ export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
15
+
16
+ export function appIconOf(root: string): string | null {
17
+ for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
18
+ return null
19
+ }
20
+
21
+ export const ICON_PX = 1024
22
+ const PLATE = 824
23
+ const RADIUS = 185
24
+ /** the mark's longest side, as a share of the plate */
25
+ const MARK = 0.7
26
+ /** an alpha at or under this is margin, and is dropped: a generated image
27
+ * often carries faint noise over its whole canvas, which would widen the box
28
+ * to the full image and haze the plate */
29
+ const NOISE = 48
30
+ /** a mark whose box is this full is a tile already: it fills the plate */
31
+ const TILE = 0.85
32
+
33
+ export type Box = { x: number; y: number; w: number; h: number; fill: number }
34
+
35
+ /** the box of the pixels that are not margin, and how full it is; null for a
36
+ * mark with no such pixel */
37
+ export function markBox(rgba: Uint8Array | Uint8ClampedArray, w: number, h: number): Box | null {
38
+ let x0 = w, y0 = h, x1 = -1, y1 = -1
39
+ for (let y = 0; y < h; y++)
40
+ for (let x = 0; x < w; x++)
41
+ if (rgba[(y * w + x) * 4 + 3]! > NOISE) {
42
+ if (x < x0) x0 = x
43
+ if (x > x1) x1 = x
44
+ if (y < y0) y0 = y
45
+ if (y > y1) y1 = y
46
+ }
47
+ if (x1 < 0) return null
48
+ let solid = 0
49
+ for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) if (rgba[(y * w + x) * 4 + 3]! > 250) solid++
50
+ const bw = x1 - x0 + 1
51
+ const bh = y1 - y0 + 1
52
+ return { x: x0, y: y0, w: bw, h: bh, fill: solid / (bw * bh) }
53
+ }
54
+
55
+ const lum = (r: number, g: number, b: number): number => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
56
+
57
+ /** the mean lightness of the mark's own pixels, alpha weighted */
58
+ function markLum(rgba: Uint8Array | Uint8ClampedArray): number {
59
+ let sum = 0, weight = 0
60
+ for (let i = 0; i < rgba.length; i += 4) {
61
+ const a = rgba[i + 3]! / 255
62
+ sum += a * lum(rgba[i]!, rgba[i + 1]!, rgba[i + 2]!)
63
+ weight += a
64
+ }
65
+ return weight === 0 ? 0.5 : sum / weight
66
+ }
67
+
68
+ const hexLum = (hex: string): number => {
69
+ const n = Number.parseInt(hex.slice(1), 16)
70
+ return lum((n >> 16) & 255, (n >> 8) & 255, n & 255)
71
+ }
72
+
73
+ /** of the two identity colours, the one the mark stands out on most */
74
+ export function plateOf(id: Identity, markLightness: number): string {
75
+ return Math.abs(hexLum(id.colorLight) - markLightness) >= Math.abs(hexLum(id.colorDark) - markLightness) ? id.colorLight : id.colorDark
76
+ }
77
+
78
+ const towardWhite = (hex: string, t: number): string => {
79
+ const n = Number.parseInt(hex.slice(1), 16)
80
+ const c = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => Math.round(v + (255 - v) * t))
81
+ return `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`
82
+ }
83
+
84
+ async function decode(bytes: Uint8Array): Promise<Image> {
85
+ const img = new Image()
86
+ img.src = Buffer.from(bytes)
87
+ // the pixels land on decode: drawn before it, the image is empty
88
+ await img.decode().catch(() => {})
89
+ if (img.width === 0) throw new Error('not an image the icon renderer reads')
90
+ return img
91
+ }
92
+
93
+ function plate(ctx: ReturnType<Canvas['getContext']>, o: number): void {
94
+ ctx.beginPath()
95
+ ctx.roundRect(o, o, PLATE, PLATE, RADIUS)
96
+ }
97
+
98
+ /** the icon at 1024px, and the plate colour it used (null: the mark is the plate) */
99
+ export async function drawAppIcon(bytes: Uint8Array, id: Identity): Promise<{ canvas: Canvas; plate: string | null }> {
100
+ const img = await decode(bytes)
101
+ const src = createCanvas(img.width, img.height)
102
+ const sctx = src.getContext('2d')
103
+ sctx.drawImage(img, 0, 0)
104
+ const data = sctx.getImageData(0, 0, img.width, img.height)
105
+ const px = data.data
106
+ for (let i = 3; i < px.length; i += 4) if (px[i]! <= NOISE) px[i] = 0
107
+ sctx.putImageData(data, 0, 0)
108
+ const box = markBox(px, img.width, img.height) ?? { x: 0, y: 0, w: img.width, h: img.height, fill: 0 }
109
+ const out = createCanvas(ICON_PX, ICON_PX)
110
+ const ctx = out.getContext('2d')
111
+ ctx.imageSmoothingQuality = 'high'
112
+ const o = (ICON_PX - PLATE) / 2
113
+ if (box.fill >= TILE) {
114
+ // a tile: cover the plate with it, clipped to the plate's corners
115
+ const s = Math.max(PLATE / box.w, PLATE / box.h)
116
+ ctx.save()
117
+ plate(ctx, o)
118
+ ctx.clip()
119
+ ctx.drawImage(src, box.x, box.y, box.w, box.h, ICON_PX / 2 - (box.w * s) / 2, ICON_PX / 2 - (box.h * s) / 2, box.w * s, box.h * s)
120
+ ctx.restore()
121
+ return { canvas: out, plate: null }
122
+ }
123
+ const colour = plateOf(id, markLum(px))
124
+ const g = ctx.createLinearGradient(0, o, 0, o + PLATE)
125
+ // a lighter top edge, the way a native icon catches light
126
+ g.addColorStop(0, towardWhite(colour, 0.14))
127
+ g.addColorStop(1, colour)
128
+ ctx.fillStyle = g
129
+ plate(ctx, o)
130
+ ctx.fill()
131
+ const s = (PLATE * MARK) / Math.max(box.w, box.h)
132
+ ctx.drawImage(src, box.x, box.y, box.w, box.h, ICON_PX / 2 - (box.w * s) / 2, ICON_PX / 2 - (box.h * s) / 2, box.w * s, box.h * s)
133
+ return { canvas: out, plate: colour }
134
+ }
135
+
136
+ /** the icon as pngs, one per size asked, drawn once */
137
+ export async function appIconPngs(bytes: Uint8Array, id: Identity, sizes: number[] = [ICON_PX]): Promise<Buffer[]> {
138
+ const { canvas } = await drawAppIcon(bytes, id)
139
+ return sizes.map((px) => {
140
+ if (px === ICON_PX) return canvas.toBuffer('image/png')
141
+ const small = createCanvas(px, px)
142
+ const ctx = small.getContext('2d')
143
+ ctx.imageSmoothingQuality = 'high'
144
+ ctx.drawImage(canvas, 0, 0, px, px)
145
+ return small.toBuffer('image/png')
146
+ })
147
+ }
148
+
149
+ /** the identity a repo's config declares, else the one its name hashes to */
150
+ export function identityOfRoot(root: string): Identity {
151
+ const path = join(root, CONFIG_FILE)
152
+ const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
153
+ return identityOfText(text) ?? identityOfName(configName(text, root))
154
+ }
@@ -0,0 +1,21 @@
1
+ // THE KEPT AUDIT: what the last traced audit (core/trace-audit.ts) found, per
2
+ // task. It lives under the git common dir, beside the ledger, so every
3
+ // worktree of the repo on this machine reads the same one. The keys read it
4
+ // (ledger/gate-checks.ts): a finding covers the task that read it before any
5
+ // config write lands. The shape and the pure helpers: core/ignored-inputs.ts.
6
+ import { readFileSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { shipkitRoot } from './git-root.ts'
9
+ import type { AuditReport } from './ignored-inputs.ts'
10
+
11
+ export type { AuditReport, AuditTask, IgnoredInputs } from './ignored-inputs.ts'
12
+ export { auditedExtras, describeByTask, ignoredFor, pendingByTask } from './ignored-inputs.ts'
13
+
14
+ /** its name under the repo's shipkit state dir (CheckCtx.state reads it) */
15
+ export const AUDIT_FILE = 'trace-audit.json'
16
+
17
+ export const auditReportPath = (root: string): string => join(shipkitRoot(root), AUDIT_FILE)
18
+
19
+ export function readAuditReport(root: string): AuditReport | null {
20
+ try { return JSON.parse(readFileSync(auditReportPath(root), 'utf8')) as AuditReport } catch { return null }
21
+ }
@@ -0,0 +1,72 @@
1
+ // THE CACHE AUDIT: is a cached green still what a fresh run says? A check
2
+ // keyed on files is only as honest as the paths it declares: a path it reads
3
+ // and never declared cannot move its key, so an edit there serves the old
4
+ // green. Nothing sees that while it happens. This re-runs a small sample of
5
+ // the checks the cache would answer right now, fresh, and compares.
6
+ //
7
+ // the one constraint a reader breaks first: only a green is ever stored, so
8
+ // a fresh FAIL under a key that replays a green is the whole finding. A
9
+ // fresh green where the stored one warned, or the reverse, is reported too.
10
+ import { composeChecks } from '../ledger/gate-checks.ts'
11
+ import { Ledger } from '../ledger/ledger.ts'
12
+ import { gateBinary } from '../ledger/toolchain-identity.ts'
13
+ import { populationOf } from '../problems/problem-line.ts'
14
+ import { runCheckPass } from './run-check.ts'
15
+ import type { RepoConfig } from '../repo-config.ts'
16
+
17
+ export type AuditRow = {
18
+ id: string
19
+ /** what the cache holds for the current key */
20
+ cached: 'pass' | 'warn'
21
+ /** what a fresh run said */
22
+ fresh: 'pass' | 'warn' | 'fail' | 'skip'
23
+ /** how the check is keyed: files (declared paths) or reads (recorded) */
24
+ keyedOn: 'files' | 'reads'
25
+ ms: number
26
+ }
27
+
28
+ export type AuditResult = {
29
+ /** checks the cache would answer now, the pool the sample came from */
30
+ pool: number
31
+ rows: AuditRow[]
32
+ /** the rows whose fresh verdict is not the cached one */
33
+ stale: AuditRow[]
34
+ }
35
+
36
+ /** the sample: `n` checks of the pool, a different stable set each day, so
37
+ * every check is audited over time and two runs on one day agree */
38
+ export function sampleOf(ids: string[], n: number, day: number): string[] {
39
+ const rank = (id: string): bigint => BigInt(Bun.hash(`${day}:${id}`))
40
+ return [...ids].sort((a, b) => (rank(a) < rank(b) ? -1 : rank(a) > rank(b) ? 1 : 0)).slice(0, n)
41
+ }
42
+
43
+ export async function auditCache(root: string, config: RepoConfig, o: { sample: number; only?: string[]; now?: number }): Promise<AuditResult> {
44
+ const composed = await composeChecks(root, config, { tier: 'quick' })
45
+ const ledger = Ledger.open(root, composed.map((g) => g.row.decl), { cliEntry: gateBinary(), read: true })
46
+ // what the cache would answer right now, keyed on the repo's own files
47
+ const pool: { id: string; cached: 'pass' | 'warn'; keyedOn: 'files' | 'reads' }[] = []
48
+ for (const g of composed) {
49
+ if (g.row.runs.off != null) continue
50
+ const pop = populationOf(g.row.decl)
51
+ if (pop !== 'content' && pop !== 'reads') continue
52
+ if (o.only != null && !o.only.includes(g.row.decl.id)) continue
53
+ const hit = ledger.replay(g.row.decl.id)
54
+ if (hit != null) pool.push({ id: g.row.decl.id, cached: hit.status, keyedOn: pop === 'content' ? 'files' : 'reads' })
55
+ }
56
+ const day = Math.floor((o.now ?? Date.now()) / 86_400_000)
57
+ const picked = new Set(o.only != null ? pool.map((p) => p.id) : sampleOf(pool.map((p) => p.id), o.sample, day))
58
+ const rows: AuditRow[] = []
59
+ for (const p of pool.filter((x) => picked.has(x.id))) {
60
+ const t = performance.now()
61
+ // fresh: nothing comes from the cache. A green it finds is stored, a
62
+ // failure is not, which is why a stale entry is forgotten below
63
+ const run = await runCheckPass(root, { config, composed, audience: 'health', only: [p.id], fresh: true, frozen: true, status: false, print: () => {}, detail: () => {} })
64
+ const r = run.results.find((x) => x.id === p.id)
65
+ rows.push({ id: p.id, cached: p.cached, fresh: r?.status ?? 'skip', keyedOn: p.keyedOn, ms: Math.round(performance.now() - t) })
66
+ }
67
+ const stale = rows.filter((r) => r.fresh !== 'skip' && r.fresh !== r.cached)
68
+ // a fresh failure is never stored, so without this the stale green would
69
+ // keep being served under the same key
70
+ for (const r of stale) ledger.forget(r.id)
71
+ return { pool: pool.length, rows, stale }
72
+ }
@@ -22,7 +22,7 @@ export function canonicalBodies(opts: ScriptStyle = {}): Record<string, string>
22
22
  }
23
23
 
24
24
  // conditional canon (bodies stated by the vocabulary page) — keyed by the
25
- // SK001 requirement name; a requirement with no canonical body (comfy:up) is
25
+ // SK001 requirement name; a requirement with no canonical body is
26
26
  // deliberately absent: a fixer must not invent one
27
27
  export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, string> {
28
28
  return {
@@ -30,7 +30,7 @@ export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, strin
30
30
  }
31
31
  }
32
32
 
33
- // ONE WAY IN (build/shipkit.md): the verification verbs an agent types from
33
+ // ONE WAY IN (build/shipkit/one-way-in.md): the verification verbs an agent types from
34
34
  // muscle memory ROUTE to shipkit, so `bun run test` gets the cache and the
35
35
  // job attachment without anyone having to remember a rule. The tool body they
36
36
  // used to hold moves to `<name>:raw`, which is what shipkit itself runs and
@@ -18,12 +18,13 @@
18
18
  // A context with no pass reports and reaches the world, and a repo read there
19
19
  // throws rather than guess a root.
20
20
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
21
- import { dirname, join } from 'path'
21
+ import { basename, dirname, isAbsolute, join } from 'path'
22
22
  import { atomicWrite } from './locked-write.ts'
23
23
  import { spawnWithTimeout } from './spawn-timeout.ts'
24
24
  import { blobOid, dirEntries, entriesHash, isLinkAt, kindOf, listingHash, Reads, type DirEntry } from './read-set.ts'
25
25
  import { CheckPass } from './check-pass.ts'
26
26
  import { normalizeInput, WHOLE_TREE } from '../ledger/git-scan.ts'
27
+ import { shipkitRoot } from './git-root.ts'
27
28
  import type { RepoConfig } from '../repo-config.ts'
28
29
  import type { Scan } from '../scan.ts'
29
30
  import type { Line } from './wire.ts'
@@ -123,17 +124,25 @@ export class CheckCtx {
123
124
  if (p === WHOLE_TREE) throw new Error(`'${path}' is the repo root, not a file`)
124
125
  return p
125
126
  }
126
- /** the text of a repo-relative file, null when there is none */
127
+ /** the text of a repo-relative file, null when there is none. An
128
+ * absolute path reads a file outside the work tree (the kept audit under
129
+ * the git common dir), recorded by that path */
127
130
  read(path: string): string | null {
128
131
  return this.bytes(path)?.toString('utf8') ?? null
129
132
  }
133
+ /** the text of a file under the repo's shipkit state dir (the git common
134
+ * dir, shared by every worktree), null when there is none. Recorded by
135
+ * its absolute path, like any read */
136
+ state(name: string): string | null {
137
+ return this.read(join(shipkitRoot(this.root), name))
138
+ }
130
139
  /** the raw bytes of a repo-relative file (an image, a binary), null when
131
140
  * there is none. Recorded exactly like `read` */
132
141
  bytes(path: string): Buffer | null {
133
- const p = this.rel(path)
142
+ const p = isAbsolute(path) ? path : this.rel(path)
134
143
  let bytes: Buffer
135
144
  try {
136
- bytes = readFileSync(join(this.repo('read').root, p))
145
+ bytes = readFileSync(isAbsolute(p) ? p : join(this.repo('read').root, p))
137
146
  } catch (e) {
138
147
  const code = (e as { code?: string }).code
139
148
  if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'EISDIR') throw e
@@ -245,7 +254,7 @@ export class CheckCtx {
245
254
  // spawn without the Bun.spawn throw-on-missing-binary gotcha; SIGTERM at
246
255
  // timeoutMs, SIGKILL 2s later (one code path: core/spawn-timeout.ts)
247
256
  async exec(cmd: string[], opts?: { env?: Record<string, string | undefined>; cwd?: string; timeoutMs?: number }): Promise<{ code: number; out: string; timedOut: boolean }> {
248
- this.world('exec')
257
+ this.world(`\`${cmd.slice(0, 3).map((a) => basename(a)).join(' ')}\``)
249
258
  // the environment as it stands NOW: left out, Bun hands the child the
250
259
  // one it started with, and a variable set since (a test seam, a flag
251
260
  // the gate set) never reaches it
@@ -40,15 +40,19 @@ import { machinePool, SLOT_HELD_ENV, takeSlot, type SlotPool } from './slots.ts'
40
40
  import { budgetLine, machineLoad } from './budget.ts'
41
41
  import { killTree, spawnWithTimeout } from './spawn-timeout.ts'
42
42
  import { FOREVER_MS, formatLine, parseWireOutput, type Line } from './wire.ts'
43
- import { offLine, type WhenDecl } from './when.ts'
43
+ import { offLine, runsIn, type ResolvedRuns, type RunContext } from './runs.ts'
44
44
  import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
45
45
  import { Ledger, stampCached } from '../ledger/ledger.ts'
46
- import { attachedStamp } from './outcome-words.ts'
46
+ import { attachedStamp, ranStamp } from './outcome-words.ts'
47
47
  import type { RunLog, RunStatus } from '../ledger/run-log.ts'
48
48
  import { liveStateOf } from '../ledger/live-runs.ts'
49
49
  import { CHECK_ID_ENV } from '../ledger/suite-log.ts'
50
50
  import { rememberWorld, replayWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
51
51
  import { extractProblems } from '../problems/extract.ts'
52
+
53
+ /** the first line of a check that ran in this run, stamped with its cost. A
54
+ * world answer carries its keep in a stamp already, so the cost joins it */
55
+ const ranHere = (l: Line, ms: number): Line => (l.stamp != null && l.stamp.cached !== true ? { ...l, stamp: { ...l.stamp, ranMs: ms } } : { ...l, text: `${l.text} ${ranStamp(ms)}` })
52
56
  import { feedsProblemsPane, type Population, type Problem } from '../problems/problem-line.ts'
53
57
 
54
58
  /** the ceiling a check takes when it names none. A build step's floor
@@ -82,7 +86,8 @@ export type RunnableCheck = {
82
86
  blocking: boolean | null
83
87
  /** a build step: the gate runs it, the health pass does not */
84
88
  build: boolean
85
- when: WhenDecl
89
+ /** every context it runs in, resolved (core/runs.ts) */
90
+ runs: ResolvedRuns
86
91
  deps: string[]
87
92
  group: string | null
88
93
  timeoutMs: number
@@ -159,8 +164,10 @@ export type RunChecksOpts = {
159
164
  root: string
160
165
  config: CheckEnv['config']
161
166
  audience: Audience
162
- /** `release` checks are in at the full tier and out at the quick one */
167
+ /** the full tests tier: `onFull` decides instead of `onCi` */
163
168
  fullTier: boolean
169
+ /** the release preflight: `onRelease` decides, whatever the tier */
170
+ release?: boolean
164
171
  /** where a `cmd` check's whole output is streamed */
165
172
  logDir: string
166
173
  /** the world answers' store, null = nothing is remembered by TTL */
@@ -256,10 +263,13 @@ export type Selection = {
256
263
  errors: string[]
257
264
  }
258
265
 
266
+ /** the axis each context reads, as a parked line names it */
267
+ const CTX_AXIS: Record<RunContext, string> = { preCommit: 'onPreCommit', ci: 'onCi', full: 'onFull', release: 'onRelease', health: 'onHealth' }
268
+
259
269
  /** what this run asks about. The audience decides the shape of the question,
260
270
  * `--only` and `--with` name checks past any filter, and OFF is the one word
261
271
  * no flag lifts. */
262
- export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'fullTier' | 'skipGroups' | 'onlyIds' | 'withIds' | 'onlyBlocking' | 'buildOnly' | 'exclude' | 'includeProactive'>): Selection {
272
+ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'fullTier' | 'release' | 'skipGroups' | 'onlyIds' | 'withIds' | 'onlyBlocking' | 'buildOnly' | 'exclude' | 'includeProactive'>): Selection {
263
273
  const errors: string[] = []
264
274
  const ids = new Set(o.checks.map((u) => u.id))
265
275
  if (ids.size !== o.checks.length) errors.push('duplicate check ids')
@@ -318,26 +328,28 @@ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'ful
318
328
  if (u.blocking == null) undeclared.push(u.id)
319
329
  selected.push(u)
320
330
  }
331
+ // the one context this run asks about: every row declares whether it
332
+ // runs there (core/runs.ts), and nothing is guessed from its kind
333
+ const ctx: RunContext = o.audience === 'health' ? 'health' : o.release === true ? 'release' : o.fullTier ? 'full' : 'ci'
321
334
  for (const u of o.checks) {
322
- // IS IT THIS RUN'S BUSINESS AT ALL? The health pass never builds (a
323
- // repo's test suite and its type checker answer "is my change good",
324
- // which is the gate's question), the TUI's build panel asks for
325
- // nothing else, and a sub-unit release skips what its own gate ran.
326
- // A check out of scope here is not even reported as off: it is not part
327
- // of this question.
335
+ // IS IT THIS RUN'S BUSINESS AT ALL? The TUI's build panel asks for
336
+ // nothing but build steps, and a sub-unit release skips what its own
337
+ // gate ran. A check out of scope here is not even reported as off: it
338
+ // is not part of this question.
328
339
  if (!named.has(u.id)) {
329
- if (o.audience === 'health' && u.build) continue
330
340
  if (o.buildOnly === true && !u.build) continue
331
341
  if (excluded.has(u.id)) { parked.push({ id: u.id, text: `${u.id} (not part of this release, its own gate covers it)` }); continue }
332
342
  }
333
343
  // OFF is the one word that means never, past every other flag: a
334
344
  // switch a filter can lift is a suggestion
335
- if (u.when.at === 'off') { off.push(u); continue }
345
+ if (u.runs.off != null) { off.push(u); continue }
336
346
  if (named.has(u.id)) { take(u); continue }
337
347
  if (u.group != null && skipGroups.has(u.group)) { parked.push({ id: u.id, text: `${u.id} (--skip ${u.group})` }); continue }
338
- if (o.audience === 'gate') {
339
- if (u.when.at === 'health' && o.includeProactive !== true) { parked.push({ id: u.id, text: `${u.id} (health)` }); continue }
340
- if (u.when.at === 'release' && !o.fullTier) { parked.push({ id: u.id, text: `${u.id} (release)` }); continue }
348
+ if (!runsIn(u.runs, ctx)) {
349
+ // the health pass answers its own question and lists nothing else
350
+ if (ctx === 'health') continue
351
+ // release.requireHealthy asks the gate for the health probes too
352
+ if (!(o.includeProactive === true && u.runs.onHealth && !u.build)) { parked.push({ id: u.id, text: `${u.id} (not ${CTX_AXIS[ctx]})` }); continue }
341
353
  }
342
354
  if (o.onlyBlocking === true && u.blocking !== true) { if (u.blocking == null) undeclared.push(u.id); nonBlocking.set(u.id, u); continue }
343
355
  take(u)
@@ -430,6 +442,8 @@ type Live = RunnableCheck & CheckResult & {
430
442
  /** what an in process body read, null when it reached the world or ran
431
443
  * as a subprocess */
432
444
  reads: ReadSet | null
445
+ /** why its answer cannot be stored on its reads, in its own words */
446
+ untracked: string | null
433
447
  /** it printed nothing of its own, so its one line is the headline below */
434
448
  synthetic: boolean
435
449
  /** what the headline carries after the id: a failure's last line, or the
@@ -471,7 +485,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
471
485
  // OFF, in ONE place: skipped by name, never run, never stored, and the
472
486
  // reason printed where the verdict would have been
473
487
  for (const u of [...sel.off, ...(sel.offElsewhere ?? [])]) {
474
- const line: Line = { kind: 'skip', text: offLine(u.id, u.when) }
488
+ const line: Line = { kind: 'skip', text: offLine(u.id, u.runs) }
475
489
  if (sel.off.includes(u)) emit(line); else (o.detail ?? print)(formatLine(line, color))
476
490
  report(u, { id: u.id, status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [line], problems: [], lastLine: line.text, note: 'off', logPath: null })
477
491
  }
@@ -486,7 +500,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
486
500
  const runDir = openRunDir(o.logDir)
487
501
  const checks: Live[] = sel.selected.map((u) => ({
488
502
  ...u,
489
- status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, synthetic: false, tail: '', extra: [],
503
+ status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, untracked: null, synthetic: false, tail: '', extra: [],
490
504
  logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
491
505
  }))
492
506
  const selectedIds = new Set(checks.map((u) => u.id))
@@ -566,9 +580,14 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
566
580
  const ran = u.status === 'pass' || u.status === 'warn' ? testsRan(u.lastLine) : null
567
581
  const counted = ran != null && head != null && head.kind === 'ok' && (head.text === u.id || head.text.startsWith(`${u.id} (`))
568
582
  const id = counted ? `${u.id} ${ran} test${ran === 1 ? '' : 's'}` : u.id
569
- const shown = u.synthetic && !u.replayed && head != null
570
- ? [{ ...head, text: `${id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
583
+ const composed = u.synthetic && !u.replayed && head != null
584
+ ? [{ ...head, text: `${id} ${ranStamp(u.ms)}${u.tail}` }, ...u.lines.slice(1)]
571
585
  : counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}` }, ...u.lines.slice(1)] : u.lines
586
+ // every line says where its answer came from: a replay carries its
587
+ // cached stamp already, so one that ran here says so and what it cost
588
+ const shown = u.synthetic || u.replayed || u.attached || u.startedAt === 0 || composed[0] == null
589
+ ? composed
590
+ : [ranHere(composed[0], u.ms), ...composed.slice(1)]
572
591
  // a caller printing its own verdict wants one line per green check and
573
592
  // only the warnings of a warned one; a failure prints whole
574
593
  const quiet = (l: Line, i: number): boolean =>
@@ -585,7 +604,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
585
604
  const hit = o.ledger?.replay(u.id)
586
605
  if (hit != null) {
587
606
  u.replayed = true
588
- u.lines = stampCached(hit.lines, hit.measuredMs)
607
+ u.lines = stampCached(hit.lines, hit.measuredMs, hit.at, clock())
589
608
  u.lastLine = hit.lastLine ?? ''
590
609
  u.status = hit.status
591
610
  // a replay stands for the verdict it replays, findings included:
@@ -612,6 +631,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
612
631
  // id, every line is tagged, and a failure carries a fix pointer
613
632
  await runCheckBody({ id: u.id, title: u.title, owner: u.owner, run: u.body.run, fix: u.fix }, t)
614
633
  u.reads = t.untracked == null ? t.reads.snapshot() : null
634
+ u.untracked = t.untracked
615
635
  u.lines = t.lines
616
636
  u.problems = findings(u, t.lines)
617
637
  u.unmeasured = t.unmeasured
@@ -768,7 +788,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
768
788
  await measureAndKeep(u)
769
789
  return { value: answerOf(u), share: !killed.has(u.id) && !u.unmeasured }
770
790
  })
771
- if (r.kind === 'attached') { o.ledger?.noteAttached(); adopt(u, r.value, r.pid) }
791
+ if (r.kind === 'attached') { o.ledger?.noteAttached(u.id); adopt(u, r.value, r.pid) }
772
792
  else if (r.kind === 'aborted') { u.status = 'skip'; u.note = bailed ? 'bailed' : 'interrupted'; u.lines = [] }
773
793
  else if (r.kind === 'overlapped') emit({ kind: 'warn', text: `${u.id} ran again beside another run: ${r.warn}` })
774
794
  }
@@ -811,7 +831,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
811
831
  })
812
832
  }
813
833
  if (o.ledger != null && keep)
814
- o.ledger.record(u.id, { status: u.status as 'pass' | 'warn', lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads)
834
+ o.ledger.record(u.id, { status: u.status as 'pass' | 'warn', lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads, u.untracked)
815
835
  }
816
836
 
817
837
  const start = (u: Live): void => {
@@ -1,14 +1,15 @@
1
- // THE CONFIG HALF of the SK016 migration, in its own process. Writing the
1
+ // THE CONFIG HALF of the SK016 and SK029 migrations, in its own process. Writing the
2
2
  // managed block and reading the result back both LOAD repo.config.ts as a
3
3
  // module, which no check context read can stand for, so the fixer reaches it
4
4
  // through `c.exec` and the check file itself never imports the loader.
5
5
  //
6
6
  // bun config-rewrite.ts <root> '<plan json>' → one json line on stdout
7
7
  //
8
- // The order is the migration's: the `when` entries first, then each key is
9
- // dropped only once what leaned on it has a home, then the result is read
10
- // back through the loader and the previous bytes go back when it does not
11
- // survive.
8
+ // The order is the migration's: a key the managed block holds (the retired
9
+ // `when`) is dropped first, so the block describes the config again, then the
10
+ // `off` entries are written into the block, then each other key is dropped
11
+ // only once what leaned on it has a home. The result is read back through the
12
+ // loader and the previous bytes go back when it does not survive.
12
13
  import { readFileSync } from 'fs'
13
14
  import { join } from 'path'
14
15
  import { atomicWrite } from './locked-write.ts'
@@ -17,10 +18,11 @@ import { writeGateBlock } from './when-block.ts'
17
18
  import { loadRepoConfig } from '../repo-config.ts'
18
19
 
19
20
  export type ConfigPlan = {
20
- /** check id → `when` value, written into the managed block */
21
- when: Record<string, string>
22
- /** keys to drop, in order. `afterWhen` drops only once the block took the entries */
23
- drop: { key: string; why: string; afterWhen?: boolean }[]
21
+ /** check id → why it is off, written into the managed block */
22
+ off: Record<string, string>
23
+ /** keys to drop, in order. `first` drops before the block is written (a
24
+ * key inside it), `afterOff` only once the block took the entries */
25
+ drop: { key: string; why: string; phase?: 'first' | 'afterOff' }[]
24
26
  /** the config's `name` before the rewrite: what the read back must still say */
25
27
  name: string
26
28
  }
@@ -35,27 +37,38 @@ export type ConfigRewrite = {
35
37
  export async function rewriteConfig(root: string, plan: ConfigPlan): Promise<ConfigRewrite> {
36
38
  const did: string[] = []
37
39
  const hand: string[] = []
38
- let whenWritten = false
39
- if (Object.keys(plan.when).length > 0) {
40
+ const path = join(root, 'repo.config.ts')
41
+ const before = readFileSync(path, 'utf8')
42
+ const drop = (text: string, d: ConfigPlan['drop'][number]): string => {
43
+ const next = dropConfigKey(text, d.key)
44
+ if (next == null) { hand.push(`repo.config.ts \`${d.key}\`: ${d.why}, and the key could not be read mechanically — delete it by hand`); return text }
45
+ did.push(`repo.config.ts drops \`${d.key}\``)
46
+ return next
47
+ }
48
+ let first = before
49
+ for (const d of plan.drop.filter((x) => x.phase === 'first')) first = drop(first, d)
50
+ if (first !== before) atomicWrite(path, first)
51
+ let offWritten = false
52
+ if (Object.keys(plan.off).length > 0) {
40
53
  try {
41
- await writeGateBlock(root, { when: plan.when })
42
- whenWritten = true
43
- did.push(`${Object.keys(plan.when).length} when entr(ies) written into the managed block`)
54
+ await writeGateBlock(root, { off: plan.off })
55
+ offWritten = true
56
+ did.push(`${Object.keys(plan.off).length} off entr(ies) written into the managed block`)
44
57
  } catch (e) {
45
- hand.push(`the \`when\` block refused the rewrite (${String(e).slice(0, 160)}) — write ${JSON.stringify(plan.when)} into it by hand`)
58
+ // a key dropped first held these entries: without the block they
59
+ // would be gone, so the file goes back as it was
60
+ if (first !== before) { atomicWrite(path, before); return { did: [], hand, restored: `the managed block refused off ${JSON.stringify(plan.off)}: ${String(e).slice(0, 160)}` } }
61
+ hand.push(`the managed block refused the rewrite (${String(e).slice(0, 160)}) — write off: ${JSON.stringify(plan.off)} into it by hand`)
46
62
  }
47
63
  }
48
- const path = join(root, 'repo.config.ts')
49
- const before = readFileSync(path, 'utf8')
50
- let text = before
51
- for (const d of plan.drop) {
52
- if (d.afterWhen === true && !whenWritten) continue
53
- const next = dropConfigKey(text, d.key)
54
- if (next == null) hand.push(`repo.config.ts \`${d.key}\`: ${d.why}, and the key could not be read mechanically — delete it by hand`)
55
- else { text = next; did.push(`repo.config.ts drops \`${d.key}\``) }
64
+ const mid = readFileSync(path, 'utf8')
65
+ let text = mid
66
+ for (const d of plan.drop.filter((x) => x.phase !== 'first')) {
67
+ if (d.phase === 'afterOff' && !offWritten) continue
68
+ text = drop(text, d)
56
69
  }
70
+ if (text !== mid) atomicWrite(path, text)
57
71
  if (text !== before) {
58
- atomicWrite(path, text)
59
72
  try {
60
73
  const after = await loadRepoConfig(root, { fresh: true })
61
74
  if (after.name !== plan.name) throw new Error(`the config reads as '${String(after.name)}'`)