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
@@ -1,4 +1,4 @@
1
- // WHAT PROGRAM IS ABOUT TO RUN, as one hash.
1
+ // WHAT PROGRAM IS ABOUT TO RUN, as hashes.
2
2
  //
3
3
  // A cache key built from the repo's git tree answers "what source is being
4
4
  // gated". It cannot answer "what is doing the gating", and those two already
@@ -16,8 +16,12 @@
16
16
  // package.json files of exactly the same byte length, so only the parsed
17
17
  // `version` separates them.
18
18
  //
19
- // So the key has three parts: the runtime, the gate binary, and what is
20
- // actually installed.
19
+ // So there are three parts: the RUNNER (the runtime, the gate code that runs
20
+ // and stores every answer, the data files a check reads), the INSTALL (what is
21
+ // actually in node_modules), and the toolkit SOURCE, which each check is keyed
22
+ // on through its own module closure (LibSource), never as a whole. A whole
23
+ // tree hash made a css edit in the hub, or a new test file, move every key in
24
+ // every repo on the machine, and a repo that links the toolkit paid it twice.
21
25
  //
22
26
  // THE ONE DELIBERATE MISS: a human hand-editing a file inside `node_modules`
23
27
  // is accepted, not caught. Catching it means content-hashing the whole tree,
@@ -26,16 +30,22 @@
26
30
  // cache slower than the gate it caches. No tool produces that state; a human
27
31
  // debugging on purpose can delete the ledger.
28
32
  import { createHash } from 'crypto'
29
- import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'fs'
30
- import { isAbsolute, join, relative, resolve } from 'path'
33
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'fs'
34
+ import { dirname, isAbsolute, join, posix, relative, resolve } from 'path'
35
+ import { ALWAYS_SKIP, type RvtConfig } from '../ts/config.ts'
36
+ import { extractSpecs, resolveSpec } from '../ts/source-graph.ts'
37
+ import { blobOid } from '../core/read-set.ts'
31
38
 
32
39
  export type Toolchain = {
33
- /** the identity, 16 hex chars, stable for identical programs */
40
+ /** the RUNNER, 16 hex chars: the runtime, the gate code that runs and
41
+ * stores every answer, and the data files a check reads. Every key
42
+ * carries it */
34
43
  id: string
35
- /** non-empty means DO NOT CACHE this run: something in the toolchain is
36
- * neither in the repo's git tree nor pinned by a version string, so no
37
- * key can describe it. Each entry says which package and why. */
38
- uncacheable: string[]
44
+ /** what is installed, 16 hex chars. Only a key whose answer runs the
45
+ * repo's own tools carries it: a content or a world check */
46
+ install: string
47
+ /** the toolkit's source, for a check's own module closure */
48
+ lib: LibSource
39
49
  }
40
50
 
41
51
  /** the gate binary driving THIS process, which is what the key hashes when a
@@ -62,23 +72,202 @@ function installedNames(nm: string): string[] {
62
72
  return names.sort()
63
73
  }
64
74
 
65
- /** the gate binary's own source, content-hashed. This is what makes editing
66
- * shipkit invalidate every key in every repo, in-repo copy or dependency. */
67
- function hashCliTree(h: ReturnType<typeof createHash>, cli: string): void {
68
- const srcRoot = resolve(cli, '..')
69
- const files: string[] = []
70
- const walk = (d: string): void => {
71
- for (const e of readdirSync(d, { withFileTypes: true })) {
72
- if (e.name === 'node_modules' || e.name.startsWith('.')) continue
73
- const p = join(d, e.name)
74
- if (e.isDirectory()) walk(p)
75
- else files.push(p)
75
+ /** every file under `dir`, hashed as its path, its size and its bytes in
76
+ * path order, so a rename or a line moved between files moves the hash too.
77
+ * Its own installs and dot entries are left out. A symlink is followed (a
78
+ * cycle is walked once), and one that cannot be read THROWS: a tree only
79
+ * partly read must never be keyed as if it were whole */
80
+ function hashTree(h: ReturnType<typeof createHash>, dir: string, tag: string): void {
81
+ const files: { rel: string; abs: string }[] = []
82
+ const seen = new Set<string>()
83
+ const walk = (d: string, rel: string): void => {
84
+ const real = realpathSync(d)
85
+ if (seen.has(real)) return
86
+ seen.add(real)
87
+ for (const name of readdirSync(d)) {
88
+ if (name === 'node_modules' || name.startsWith('.')) continue
89
+ const abs = join(d, name)
90
+ const r = rel === '' ? name : `${rel}/${name}`
91
+ if (statSync(abs).isDirectory()) walk(abs, r)
92
+ else files.push({ rel: r, abs })
76
93
  }
77
94
  }
78
- walk(srcRoot)
79
- files.sort()
80
- for (const f of files) h.update(readFileSync(f))
81
- h.update(`clifiles=${files.length}\n`)
95
+ walk(dir, '')
96
+ files.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0))
97
+ for (const f of files) {
98
+ const bytes = readFileSync(f.abs)
99
+ h.update(`${f.rel}\0${bytes.length}\0`)
100
+ h.update(bytes)
101
+ }
102
+ h.update(`${tag}=${files.length}\n`)
103
+ }
104
+
105
+ /** a path with its links resolved, as given when it cannot be (a file not
106
+ * there yet): /var and /private/var must name one file */
107
+ const real = (p: string): string => { try { return realpathSync(p) } catch { return p } }
108
+
109
+ /** the modules that run a check and store its answer, relative to the
110
+ * toolkit's src. A gate binary without them (a test fixture) is keyed on its
111
+ * own entry's closure */
112
+ export const RUNNER_ENTRIES = ['core/run-ci.ts', 'core/run-check.ts']
113
+
114
+ /** file kinds under src that no check and no runner reads: the page the hub
115
+ * serves and the mac app's source. Every other non code file there is data a
116
+ * check can read (the scaffold templates), so it is in the runner id */
117
+ const NOT_READ_BY_CHECKS = /\.(css|html|swift)$/
118
+
119
+ /**
120
+ * the toolkit's source, keyed one module closure at a time. A check is keyed
121
+ * on the files its own module can reach, the runner on the files the runner
122
+ * can reach, and a file neither reaches (the hub page, a test) moves nothing.
123
+ * Each file is hashed once per process.
124
+ */
125
+ export class LibSource {
126
+ /** the package root, resolved: the dir holding the source dir */
127
+ readonly pkgRoot: string
128
+ /** the source dir, resolved: the gate binary's own dir */
129
+ readonly srcDir: string
130
+ private readonly cfg: RvtConfig
131
+ private readonly oids = new Map<string, string>()
132
+ private readonly closures = new Map<string, string[]>()
133
+ /** file → the files it imports directly, so a file shared by many
134
+ * closures is read and parsed once */
135
+ private readonly direct = new Map<string, string[]>()
136
+ private readonly probed = new Map<string, boolean>()
137
+ /** the package name a repo imports the toolkit by, null for a toolkit
138
+ * with no package.json (a test fixture) */
139
+ readonly name: string | null
140
+
141
+ constructor(cliEntry: string) {
142
+ this.srcDir = dirname(realpathSync(cliEntry))
143
+ this.pkgRoot = dirname(this.srcDir)
144
+ let name: string | null = null
145
+ try { name = (JSON.parse(readFileSync(join(this.pkgRoot, 'package.json'), 'utf8')) as { name?: string }).name ?? null } catch { name = null }
146
+ this.name = name
147
+ // the toolkit imports itself by its package name, and a closure that
148
+ // could not follow those would stop at the first such import
149
+ this.cfg = { root: this.pkgRoot, roots: [relative(this.pkgRoot, this.srcDir)], skip: [...ALWAYS_SKIP], packages: name == null ? [] : [{ name, dir: '.' }] }
150
+ }
151
+
152
+ /** the toolkit file a package specifier names (`rvlib-shipkit/src/x.ts`),
153
+ * or null when it names none */
154
+ fileOf(spec: string): string | null {
155
+ if (this.name == null || !spec.startsWith(`${this.name}/`)) return null
156
+ const abs = join(this.pkgRoot, spec.slice(this.name.length + 1))
157
+ return this.owns(abs) && existsSync(abs) ? abs : null
158
+ }
159
+
160
+ /** is this absolute path a source file of the toolkit? */
161
+ owns(abs: string): boolean {
162
+ const r = relative(this.srcDir, real(abs))
163
+ return r !== '' && !r.startsWith('..') && !isAbsolute(r) && /\.tsx?$/.test(abs)
164
+ }
165
+
166
+ /** package-relative files one entry can reach, dynamic imports included.
167
+ * The same walk as `importClosure`, with each file's imports kept */
168
+ closureOf(abs: string): string[] {
169
+ const rel = relative(this.pkgRoot, real(abs)).replace(/\\/g, '/')
170
+ let c = this.closures.get(rel)
171
+ if (c == null) {
172
+ const seen = new Set<string>([rel])
173
+ const queue = [rel]
174
+ while (queue.length > 0) for (const t of this.importsOf(queue.shift()!)) if (!seen.has(t)) { seen.add(t); queue.push(t) }
175
+ c = [...seen].sort()
176
+ this.closures.set(rel, c)
177
+ }
178
+ return c
179
+ }
180
+
181
+ private importsOf(file: string): string[] {
182
+ let d = this.direct.get(file)
183
+ if (d == null) {
184
+ const has = (p: string): boolean => {
185
+ let r = this.probed.get(p)
186
+ if (r == null) { r = /\.tsx?$/.test(p) && existsSync(join(this.pkgRoot, p)); this.probed.set(p, r) }
187
+ return r
188
+ }
189
+ let text = ''
190
+ // the entry itself must exist: a missing one throws below, when it is hashed
191
+ try { text = readFileSync(join(this.pkgRoot, file), 'utf8') } catch { text = '' }
192
+ const resolveHere = (spec: string): string | null => resolveSpec(this.cfg, file, spec, has)
193
+ const targets = [...extractSpecs(text, { dynamic: true }).map(resolveHere), ...pathLiterals(text, file, resolveHere)]
194
+ d = [...new Set(targets.filter((t): t is string => t != null).map((t) => posix.normalize(t)))]
195
+ this.direct.set(file, d)
196
+ }
197
+ return d
198
+ }
199
+
200
+ /** blob id of one package-relative file. A file that cannot be read
201
+ * THROWS: a closure only partly read must never be keyed */
202
+ private oid(rel: string): string {
203
+ let o = this.oids.get(rel)
204
+ if (o == null) { o = blobOid(readFileSync(join(this.pkgRoot, rel))); this.oids.set(rel, o) }
205
+ return o
206
+ }
207
+
208
+ /** the identity of what these entries can reach, 16 hex chars */
209
+ closureKey(absEntries: string[]): string {
210
+ const files = [...new Set(absEntries.flatMap((e) => this.closureOf(e)))].sort()
211
+ const h = createHash('sha256')
212
+ for (const f of files) h.update(`${f}=${this.oid(f)}\n`)
213
+ h.update(`n=${files.length}\n`)
214
+ return h.digest('hex').slice(0, 16)
215
+ }
216
+
217
+ /** the data files under src a check can read, as lines */
218
+ dataLines(): string[] {
219
+ const out: string[] = []
220
+ const walk = (dir: string, rel: string): void => {
221
+ for (const name of readdirSync(dir).sort()) {
222
+ if (name === 'node_modules' || name.startsWith('.')) continue
223
+ const abs = join(dir, name)
224
+ const r = `${rel}/${name}`
225
+ if (statSync(abs).isDirectory()) walk(abs, r)
226
+ else if (!/\.tsx?$/.test(name) && !NOT_READ_BY_CHECKS.test(name)) out.push(`data:${r}=${this.oid(r)}`)
227
+ }
228
+ }
229
+ walk(this.srcDir, relative(this.pkgRoot, this.srcDir))
230
+ // the version a check compares a repo's pin against
231
+ if (existsSync(join(this.pkgRoot, 'package.json'))) out.push(`data:package.json=${this.oid('package.json')}`)
232
+ return out
233
+ }
234
+ }
235
+
236
+ /** toolkit modules a file RUNS by path rather than imports: a preload handed
237
+ * to bun, a cli spawned through `c.exec`. `join(import.meta.dir, 'x.ts')`,
238
+ * `new URL('x.ts', import.meta.url)` and `import.meta.resolve('pkg/x.ts')`,
239
+ * resolved like an import. The gate's own `cli.ts` is left out: a module
240
+ * that names it spawns the whole program, and what of it produces an answer
241
+ * is the runner every key already carries */
242
+ function pathLiterals(text: string, file: string, resolveSpecifier: (spec: string) => string | null): (string | null)[] {
243
+ const out: (string | null)[] = []
244
+ const str = /'([^'\n]+)'|"([^"\n]+)"/g
245
+ const strings = (s: string): string[] => [...s.matchAll(str)].map((x) => x[1] ?? x[2]!)
246
+ const rel = (parts: string[]): string | null => {
247
+ const target = posix.normalize(posix.join(posix.dirname(file), ...parts))
248
+ if (target.endsWith('/cli.ts') || target === 'cli.ts') return null
249
+ return resolveSpecifier(`./${posix.relative(posix.dirname(file), target)}`)
250
+ }
251
+ for (const m of text.matchAll(/join\(\s*import\.meta\.dir\s*,((?:\s*(?:'[^'\n]*'|"[^"\n]*")\s*,?)+)\)/g)) {
252
+ const parts = strings(m[1]!)
253
+ if (parts.length > 0 && /\.tsx?$/.test(parts.at(-1)!)) out.push(rel(parts))
254
+ }
255
+ for (const m of text.matchAll(/new URL\(\s*('[^'\n]+'|"[^"\n]+")\s*,\s*import\.meta\.url\s*\)/g)) {
256
+ const p = strings(m[1]!)[0]!
257
+ if (/\.tsx?$/.test(p)) out.push(rel([p]))
258
+ }
259
+ for (const m of text.matchAll(/import\.meta\.resolve\(\s*('[^'\n]+'|"[^"\n]+")\s*\)/g)) {
260
+ const p = strings(m[1]!)[0]!
261
+ if (!p.endsWith('/cli.ts')) out.push(resolveSpecifier(p))
262
+ }
263
+ return out
264
+ }
265
+
266
+ /** the runner: the modules that run a check and store its answer, plus the
267
+ * data files a check reads */
268
+ function runnerLines(lib: LibSource, cliEntry: string): string[] {
269
+ const entries = RUNNER_ENTRIES.map((e) => join(lib.srcDir, e)).filter((e) => existsSync(e))
270
+ return [`runner=${lib.closureKey(entries.length > 0 ? entries : [realpathSync(cliEntry)])}`, ...lib.dataLines()]
82
271
  }
83
272
 
84
273
  /**
@@ -118,19 +307,14 @@ export function installIdentity(nodeModules: string): string {
118
307
  * moves an mtime without changing content produces no miss.
119
308
  */
120
309
  export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain {
121
- const h = createHash('sha256')
122
- const uncacheable: string[] = []
123
310
  // BOTH sides resolved, or the comparison is wrong on macOS: /var is a
124
311
  // symlink to /private/var, so an in-repo cli under a resolved path reads
125
312
  // as outside an unresolved root. That mistake is silent and expensive: it
126
- // content-hashes the gate binary on every single run, and folds absolute
127
- // paths into the id, which is what would stop two worktrees agreeing.
313
+ // folds absolute paths into the id, which is what would stop two
314
+ // worktrees agreeing.
128
315
  const root = ((): string => { try { return realpathSync(repoRoot) } catch { return resolve(repoRoot) } })()
129
316
 
130
- // the runtime. Free, and a bun upgrade genuinely changes behaviour
131
- h.update(`bun=${Bun.version}\n`)
132
-
133
- // the gate binary, ALWAYS content-hashed, and its path never folded in.
317
+ // the gate code, ALWAYS content-hashed, and its path never folded in.
134
318
  //
135
319
  // The earlier shape wrote `cli=in-repo` for a binary inside the repo and
136
320
  // leaned on the git tree key to cover it. Both halves were wrong, and both
@@ -139,39 +323,58 @@ export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain
139
323
  // covered none of it. And between a checkout that HOSTS the binary and a
140
324
  // worktree that runs the same one through the global shim, one wrote
141
325
  // `in-repo` and the other wrote an absolute path, so two checkouts running
142
- // one identical program could never agree on a key. Content-hashing costs
143
- // 3.5 to 9.2ms on a 163-file tree, and it is the only form that is the
144
- // same value wherever the caller stands.
145
- hashCliTree(h, realpathSync(cliEntry))
326
+ // one identical program could never agree on a key. Package-relative
327
+ // content is the only form that is the same value wherever the caller
328
+ // stands.
329
+ const lib = new LibSource(cliEntry)
330
+ const r = createHash('sha256')
331
+ // the runtime. Free, and a bun upgrade genuinely changes behaviour
332
+ r.update(`bun=${Bun.version}\n`)
333
+ for (const l of runnerLines(lib, cliEntry)) r.update(`${l}\n`)
334
+ return { id: r.digest('hex').slice(0, 16), install: installOf(root, lib), lib }
335
+ }
146
336
 
147
- // what is installed. The lockfile states the intent; the tree states the fact
337
+ /** what is installed. The lockfile states the intent; the tree states the fact */
338
+ function installOf(root: string, lib: LibSource): string {
339
+ const h = createHash('sha256')
148
340
  const lock = join(root, 'bun.lock')
149
341
  h.update(`lock=${existsSync(lock) ? createHash('sha256').update(readFileSync(lock)).digest('hex') : 'none'}\n`)
150
342
  const nm = join(root, 'node_modules')
151
343
  if (!existsSync(nm)) {
152
344
  // a worktree nobody installed is a REAL difference, not a missing input
153
345
  h.update('nm=absent\n')
154
- return { id: h.digest('hex').slice(0, 16), uncacheable }
346
+ return h.digest('hex').slice(0, 16)
155
347
  }
156
348
  const names = installedNames(nm)
157
349
  for (const n of names) {
158
350
  const dir = join(nm, n)
159
351
  let link = ''
352
+ let outside: string | null = null
160
353
  try {
161
354
  if (lstatSync(dir).isSymbolicLink()) {
162
355
  const target = realpathSync(dir)
163
356
  link = `->${target}`
164
- // a `bun link`ed package is live source under someone else's
165
- // edit: its content is in neither this repo's git tree nor its
166
- // version string, so nothing here can describe it
167
- if (!inRepo(root, target)) uncacheable.push(`${n} links outside the repo (${target})`)
357
+ if (!inRepo(root, target)) outside = target
168
358
  }
169
359
  } catch {}
360
+ // a `bun link`ed package is live source under someone else's edit:
361
+ // neither this repo's git tree nor its version string describes it, so
362
+ // its files do, and so does what it has installed itself, since its
363
+ // own imports resolve there. Outside the try on purpose: a linked tree
364
+ // that cannot be read throws, and the key layer then keys nothing.
365
+ //
366
+ // The toolkit running this gate is the one exception: each check is
367
+ // keyed on its own closure of it, and hashing it whole here put a css
368
+ // edit of the hub into every content key of every repo that links it
369
+ if (outside != null && outside !== lib.pkgRoot) {
370
+ hashTree(h, outside, `linked:${n}`)
371
+ h.update(`${installIdentity(join(outside, 'node_modules'))}\n`)
372
+ }
170
373
  let version = 'MISSING'
171
374
  try { version = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { version?: string }).version ?? '?' } catch {}
172
- h.update(`${n}@${version}${link}\n`)
375
+ h.update(`${n}@${version}${link === '' || outside === lib.pkgRoot ? '' : link}\n`)
173
376
  }
174
377
  // the count catches a removal that leaves every survivor identical
175
378
  h.update(`n=${names.length}\n`)
176
- return { id: h.digest('hex').slice(0, 16), uncacheable }
379
+ return h.digest('hex').slice(0, 16)
177
380
  }
@@ -12,6 +12,7 @@ import { Ledger } from '../ledger/ledger.ts'
12
12
  import { answersDb } from '../ledger/store.ts'
13
13
  import { hasWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
14
14
  import { FOREVER, FOREVER_MS } from '../core/wire.ts'
15
+ import { planned } from '../core/runs.ts'
15
16
  import { ONCE, type LastResult } from './cadence.ts'
16
17
  import { openDb } from '../ledger/db.ts'
17
18
  import { readLastResults } from '../ledger/run-log.ts'
@@ -21,57 +22,33 @@ import { movedSince } from '../serve/tracked-watch.ts'
21
22
  import type { RepoConfig } from '../repo-config.ts'
22
23
  import type { PlanCheck, PlanReason, PlanSource } from './planner.ts'
23
24
 
24
- /** the rows the plan covers: what a gate or a health pass runs. A release
25
- * check, an off one and an opt in build step wait for whoever names them */
25
+ /** the rows the plan covers: every live row declaring `onSave` or
26
+ * `onClock` (core/runs.ts). Nothing else is ever planned */
26
27
  export function plannable(row: CheckRow): boolean {
27
- if (row.when.at !== 'always' && row.when.at !== 'health') return false
28
- // nothing to do in this repo on a clock (check.ts planOff)
29
- if (row.planOff != null) return false
30
- if (row.optIn) return false
31
- return !(row.build && row.when.at === 'health')
28
+ return planned(row.runs)
32
29
  }
33
30
 
34
- /** a REPO check nothing can key and that declares no cadence runs about
35
- * once a day, never in step, until it declares what it depends on. No
36
- * built-in may land here (tests/plan-default-range.test.ts) */
37
- const DEFAULT_EVERY: [string, string] = ['20h', '28h']
38
-
39
31
  /** why the plan cannot tell when this check's answer moves, or null when it
40
- * can: a key, a ttl, a build step or a declared cadence says it */
32
+ * can. With every axis explicit, a planned row always says: kept as the
33
+ * burn-down reader for rows declaring a save trigger on nothing keyed */
41
34
  export function planGap(row: CheckRow): string | null {
35
+ if (row.runs.onSave !== 'moved') return null
42
36
  const pop = populationOf(row.decl)
43
- if (pop === 'content' || pop === 'reads' || pop === 'world' || row.build) return null
44
- if (row.plan?.every !== undefined || row.plan?.cron != null) return null
45
- const why = row.decl.never != null && row.decl.never !== '' ? `never cached (${row.decl.never})` : (row.decl.uncacheable ?? 'declares neither inputs nor ttl')
46
- return `${why}, and no every: nothing says when its answer moves`
37
+ if (pop === 'content' || pop === 'reads' || isForever(row)) return null
38
+ return `onSave 'moved' but nothing keys it (${row.decl.uncacheable ?? row.decl.declError ?? 'no key'}), so every save moves it`
47
39
  }
48
40
 
49
- /** one inventory row as a plan check. A world check keeps its ttl as its
50
- * cadence; a build step and a check keyed on its files wait for a save (its
51
- * answer replays until a file moves, so a clock re-asks nothing); a repo
52
- * check with no cadence runs at its own stable point of the default range,
53
- * and a built-in with none waits for a save and shows on the burn-down */
41
+ /** one inventory row as a plan check, straight from its declared axes:
42
+ * `onClock` is the cadence, `onSave` whether a save can make it due */
54
43
  export function planCheckOf(row: CheckRow): PlanCheck {
55
- const pop = populationOf(row.decl)
56
- const keyed = pop === 'content' || pop === 'reads'
57
- const ttl = row.decl.ttl != null && row.decl.ttl !== '' ? row.decl.ttl : null
58
- const fallback =
59
- ttl === FOREVER ? { value: ONCE, why: 'a decided fact: ttl forever' }
60
- : ttl != null ? { value: ttl, why: 'its ttl' }
61
- : row.build ? { value: null, why: 'a build step' }
62
- : keyed ? { value: null, why: 'keyed on its files' }
63
- : row.from === 'repo' ? { value: DEFAULT_EVERY, why: 'the default range' }
64
- : { value: null, why: 'a built-in declaring nothing, see shipkit keys --burn-down' }
65
- // a gate check nothing can key waits for a save only when no cadence
66
- // says when to run it: a range or an every it declares is the rule
67
- const clocked = fallback.value != null || row.plan?.every !== undefined || row.plan?.cron != null
44
+ const clock = row.runs.onClock
45
+ const plan = clock === false ? {} : clock === ONCE ? { every: ONCE } : { every: clock.every, whenInactive: clock.inactive }
68
46
  return {
69
47
  id: row.decl.id,
70
48
  title: row.title,
71
- plan: row.plan ?? {},
72
- // a decided fact is keyed on its declaration, so a save can move it
73
- onChange: isForever(row) || (pop !== 'world' && (keyed || (row.gate && !clocked))),
74
- fallback,
49
+ plan,
50
+ onChange: row.runs.onSave !== false,
51
+ fallback: { value: null, why: 'onClock false: only a save runs it' },
75
52
  }
76
53
  }
77
54
 
@@ -182,6 +159,8 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
182
159
  moved.delete(c.id)
183
160
  continue
184
161
  }
162
+ // 'burst': every quiet save burst, whatever moved
163
+ if (r.runs.onSave === 'burst') { out.push(c.id); continue }
185
164
  const k = ledger.layer.keyFor(c.id)
186
165
  if (k.key == null) {
187
166
  out.push(c.id)
@@ -10,6 +10,7 @@ import { existsSync } from 'fs'
10
10
  import { isAbsolute, relative, resolve } from 'path'
11
11
  import type { Line } from '../core/wire.ts'
12
12
  import type { Problem, Severity } from './problem-line.ts'
13
+ import { BUN_RECAP } from '../suite/judge-suites.ts'
13
14
 
14
15
  export type ExtractCtx = {
15
16
  /** the repo root every file is made relative to */
@@ -124,6 +125,8 @@ export function fromBunTest(text: string, ctx: ExtractCtx): Problem[] {
124
125
  for (const raw of stripAnsi(text).split('\n')) {
125
126
  const s = BUN_SUITE.exec(raw)
126
127
  if (s != null) { suite = repoFile(ctx, s[1]!, false); error = null; at = null; continue }
128
+ // every line after it repeats a failure already read above
129
+ if (BUN_RECAP.test(raw)) break
127
130
  const e = BUN_ERROR.exec(raw)
128
131
  if (e != null) { error = e[1]!.trim(); at = null; continue }
129
132
  const f = BUN_FRAME.exec(raw)
@@ -112,6 +112,11 @@ export type ReleaseOpts = {
112
112
  export async function runReleaseGate(root: string, config: RepoConfig, print: (l: string) => void, o: { fix?: boolean; units?: ReleaseUnit[] } = {}): Promise<number> {
113
113
  return await runCi(root, {
114
114
  fix: o.fix ?? false,
115
+ // a check's repair never runs on the release path (runs.onRelease):
116
+ // only the mechanical slots (the formatter) may, when bump asks
117
+ fixRules: false,
118
+ // the rows declaring onRelease, whatever the tier
119
+ release: true,
115
120
  frozen: true,
116
121
  status: false,
117
122
  config,