rvlib-shipkit 0.30.0 → 0.32.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 (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -0
  3. package/package.json +1 -1
  4. package/src/cli/cmd_Bump.ts +2 -0
  5. package/src/cli/cmd_Ci.ts +3 -1
  6. package/src/cli/cmd_Pb.ts +27 -0
  7. package/src/cli/cmd_Release.ts +1 -1
  8. package/src/cli/cmd_Wiki.ts +2 -2
  9. package/src/cli.ts +49 -39
  10. package/src/collections/graphs.ts +17 -19
  11. package/src/collections/providers.ts +7 -2
  12. package/src/contracts/contracts-once.ts +43 -0
  13. package/src/core/canonical-scripts.ts +33 -0
  14. package/src/core/check-ctx.ts +13 -0
  15. package/src/core/check-suite.ts +121 -53
  16. package/src/core/floor.ts +82 -0
  17. package/src/core/motherhome.ts +14 -1
  18. package/src/core/run-check.ts +34 -0
  19. package/src/core/run-ci.ts +63 -2
  20. package/src/core/run-fixers.ts +6 -1
  21. package/src/ledger/toolchain-identity.ts +134 -0
  22. package/src/pb/collection-scaffold.ts +299 -0
  23. package/src/pb/pb-types.ts +233 -0
  24. package/src/pb/run-pb.ts +157 -0
  25. package/src/release/git-facts.ts +3 -2
  26. package/src/release/release-scope.ts +52 -0
  27. package/src/release/releases-share.ts +85 -0
  28. package/src/release/run-bump.ts +31 -8
  29. package/src/release/run-release.ts +8 -1
  30. package/src/release/run-releases.ts +20 -6
  31. package/src/repo-config.ts +22 -65
  32. package/src/rules/SK001-package-json-shape.ts +65 -2
  33. package/src/rules/SK002-contracts-fulfillness.ts +2 -2
  34. package/src/rules/SK004-gitignore-policy.ts +27 -3
  35. package/src/rules/SK012-config-barrel-import.ts +60 -0
  36. package/src/rules/rule-registry.ts +2 -0
  37. package/src/rules/untrack-generated.ts +117 -0
  38. package/src/scaffold/project-scaffold.ts +1 -1
  39. package/src/serve/hub-port.ts +17 -0
  40. package/src/serve/run-serve.ts +54 -20
  41. package/src/serve/serve-hub.ts +13 -15
  42. package/src/shipkit.ts +3 -2
  43. package/src/wiki/graph-mount.ts +21 -0
  44. package/src/wiki/run-wiki.ts +18 -8
@@ -15,8 +15,9 @@ import { fmtStamp, formatLine, parseDuration, type Line } from './wire.ts'
15
15
 
16
16
  type Logic = (t: CheckCtx) => unknown
17
17
  type Prep = { skip?: string; cacheExtra?: string }
18
- type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic }
19
- type CacheFile = Record<string, { version: number; key: string; at: number; lines: Line[] }>
18
+ type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic; blocking: boolean }
19
+ type CacheEntry = { version: number; key: string; at: number; lines: Line[] }
20
+ type CacheFile = Record<string, CacheEntry>
20
21
 
21
22
  export type GetSecret = (name: string, field: 'get' | 'account') => string | null
22
23
 
@@ -61,8 +62,12 @@ export class CheckSuite {
61
62
  this.onResult = opts.onResult
62
63
  }
63
64
 
64
- addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; cacheExtra?: () => string; logic: Logic }): void {
65
- this.runners.push({ name: spec.name, ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0, version: spec.version ?? 1, prep: spec.cacheExtra != null ? () => ({ cacheExtra: spec.cacheExtra!() }) : undefined, logic: spec.logic })
65
+ /** `blocking` decides what a NEGATIVE result may be cached as: see the
66
+ * measured-negative rule in execute(). Default true, the safe side: a
67
+ * check that never declared itself is treated as being about this repo,
68
+ * so its failures are never replayed. */
69
+ addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; cacheExtra?: () => string; logic: Logic; blocking?: boolean }): void {
70
+ this.runners.push({ name: spec.name, ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0, version: spec.version ?? 1, prep: spec.cacheExtra != null ? () => ({ cacheExtra: spec.cacheExtra!() }) : undefined, logic: spec.logic, blocking: spec.blocking ?? true })
66
71
  }
67
72
 
68
73
  defineRequiredTool(name: string, opts: { candidates?: string[]; probe?: { args: string[]; expect?: RegExp }; fix: string }): void {
@@ -97,6 +102,12 @@ export class CheckSuite {
97
102
  const validate = spec.validate as (v: unknown, t: CheckCtx) => unknown
98
103
  let payload: unknown
99
104
  this.runners.push({
105
+ // conservative on purpose: a secret probe keeps today's behaviour,
106
+ // where no negative is remembered. Its cache key already hashes the
107
+ // secret VALUE, so a rotation busts it, which would make caching a
108
+ // measured 403 sound. That is a severity call for whoever owns the
109
+ // secrets surface, not a side effect of this change.
110
+ blocking: true,
100
111
  // bare secret name — this shows up in human-facing WARN/FAIL lines
101
112
  name: names.length === 1 ? primary : primary.split('/').slice(0, 2).join('/') + '/*',
102
113
  ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0,
@@ -118,65 +129,122 @@ export class CheckSuite {
118
129
  }
119
130
 
120
131
  // test seam — run() prints + exits; execute() returns the structured result
132
+ /** ONE check: cache replay, run, classify, and what it wants remembered.
133
+ * Pure with respect to its neighbours, which is what lets execute() run
134
+ * them together. */
135
+ private async runOne(r: Runner, cache: CacheFile): Promise<{ lines: Line[]; skip: string | null; ran: boolean; failed: boolean; store: CacheEntry | null; evict: boolean }> {
136
+ const prep = r.prep?.() ?? {}
137
+ if (prep.skip != null) {
138
+ this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false })
139
+ return { lines: [], skip: prep.skip, ran: false, failed: false, store: null, evict: false }
140
+ }
141
+ const key = prep.cacheExtra ?? ''
142
+ const entry = cache[r.name]
143
+ const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
144
+ && entry != null && entry.version === r.version && entry.key === key
145
+ && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
146
+ if (fresh) {
147
+ // the stamp was baked in when the check RAN (below): a replay
148
+ // prints the stored lines VERBATIM, so the live run and every
149
+ // later replay render the same string, and the line changes
150
+ // only when a re-validation happens. Annotating replays but
151
+ // not live runs still diffed on every fresh-vs-cached
152
+ // alternation, so neither side annotates
153
+ // a replayed WORLD negative keeps its severity: it is still the
154
+ // answer, and demoting it to a pass would hide a real finding
155
+ const replayedWarn = entry.lines.some((l) => l.kind === 'warn' || l.kind === 'fail')
156
+ this.onResult?.(r.name, { lines: entry.lines, status: replayedWarn ? 'warn' : 'pass', cached: true })
157
+ return { lines: entry.lines, skip: null, ran: true, failed: false, store: null, evict: false }
158
+ }
159
+ const t = new CheckCtx(this.toolPaths)
160
+ try {
161
+ await r.logic(t)
162
+ } catch (e) {
163
+ // both of these mean the probe never reached its subject, so the
164
+ // run has no answer to remember
165
+ if (e instanceof RateLimited) t.unreachable(`${r.name} not validated this run (${e.message}) — retry later`)
166
+ else if (e instanceof ToolUnresolved) { t.unmeasured = true; t.note(`${r.name} skipped (${e.message})`) }
167
+ else t.fail(`${r.name} errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
168
+ }
169
+ // AN ANSWER CACHES. THE ABSENCE OF ONE DOES NOT. That distinction, not
170
+ // pass-versus-fail, is what decides cacheability here, and a future
171
+ // reader is warned off "fixing" the asymmetry:
172
+ //
173
+ // measured negative a probe that RAN and came back bad ("the box is
174
+ // off", "the token answered 403", "five repos are
175
+ // uncatalogued") is a real answer about the world.
176
+ // Replaying it for its TTL is CORRECT: the world
177
+ // does not fix itself between two gate runs, and
178
+ // re-asking every 30 seconds spends a third-party
179
+ // API call to be told the same thing.
180
+ // unmeasured a probe that could not run at all ("registry
181
+ // unreachable", "no cred", "rate limited", tool
182
+ // missing) produced no answer. Caching it asserts
183
+ // a dead network as fact long after it recovered.
184
+ // `skip` lines and the unmeasured flag mark these.
185
+ //
186
+ // A measured negative caches ONLY for a check about the WORLD. A
187
+ // blocking check reports THIS REPO, where the whole point is that a
188
+ // human fixes it and the next run goes green: replaying its red for a
189
+ // TTL would be the cache lying about work already done.
190
+ const unmeasured = t.unmeasured || t.lines.some((l) => l.kind === 'skip')
191
+ const clean = !t.failed && !t.warned
192
+ const cacheable = r.ttlMs > 0 && !unmeasured && (clean || !r.blocking)
193
+ const lines = cacheable
194
+ ? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (${clean ? 'cached' : 'measured'} at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + r.ttlMs)})` } : l))
195
+ : t.lines
196
+ this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
197
+ return {
198
+ lines,
199
+ skip: null,
200
+ ran: true,
201
+ failed: t.failed,
202
+ store: cacheable && r.ttlMs > 0 ? { version: r.version, key, at: this.now(), lines } : null,
203
+ // an unmeasured run evicts whatever was stored: a later run must
204
+ // re-probe rather than replay an answer recorded before the outage
205
+ evict: r.ttlMs > 0 && !cacheable && entry != null,
206
+ }
207
+ }
208
+
121
209
  async execute(): Promise<{ lines: Line[]; exitCode: number }> {
122
210
  const cachePath = join(this.cacheDir, `${this.name}.json`)
123
211
  let cache: CacheFile = {}
124
212
  try { cache = JSON.parse(readFileSync(cachePath, 'utf8')) } catch {}
125
213
  let cacheDirty = false
126
214
 
215
+ // CONCURRENT, bounded. Checks are independent by contract: each answers
216
+ // one question about the world or the repo and shares nothing with its
217
+ // neighbours, and most of the wall time is a network round trip or a
218
+ // cold `bun` start. In series, twenty checks cost the SUM; together
219
+ // they cost the slowest one. Measured on this repo: 18.4s to 6.0s.
220
+ // Order is preserved by collecting per runner and concatenating in
221
+ // declaration order, so the wire output is byte-identical to the
222
+ // sequential run and the frozen contract still holds.
223
+ const cap = Math.max(2, Math.min(8, navigator.hardwareConcurrency || 4))
224
+ const results = new Array<Awaited<ReturnType<CheckSuite['runOne']>>>(this.runners.length)
225
+ let next = 0
226
+ const worker = async (): Promise<void> => {
227
+ for (;;) {
228
+ const i = next++
229
+ if (i >= this.runners.length) return
230
+ results[i] = await this.runOne(this.runners[i]!, cache)
231
+ }
232
+ }
233
+ await Promise.all(Array.from({ length: Math.min(cap, this.runners.length) }, () => worker()))
234
+
127
235
  const out: Line[] = []
128
236
  const skipped: string[] = []
129
237
  let ran = 0
130
238
  let anyFail = false
131
-
132
- for (const r of this.runners) {
133
- const prep = r.prep?.() ?? {}
134
- if (prep.skip != null) {
135
- skipped.push(prep.skip)
136
- this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false })
137
- continue
138
- }
139
- ran++
140
- const key = prep.cacheExtra ?? ''
141
- const entry = cache[r.name]
142
- const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
143
- && entry != null && entry.version === r.version && entry.key === key
144
- && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
145
- if (fresh) {
146
- // the stamp was baked in when the check RAN (below): a replay
147
- // prints the stored lines VERBATIM, so the live run and every
148
- // later replay render the same string, and the line changes
149
- // only when a re-validation happens. Annotating replays but
150
- // not live runs still diffed on every fresh-vs-cached
151
- // alternation, so neither side annotates
152
- out.push(...entry.lines)
153
- this.onResult?.(r.name, { lines: entry.lines, status: 'pass', cached: true }) // only clean passes are cached
154
- continue
155
- }
156
- const t = new CheckCtx(this.toolPaths)
157
- try {
158
- await r.logic(t)
159
- } catch (e) {
160
- if (e instanceof RateLimited) t.warn(`${r.name} not validated this run (${e.message}) — retry later`)
161
- else if (e instanceof ToolUnresolved) t.note(`${r.name} skipped (${e.message})`)
162
- else t.fail(`${r.name} errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
163
- }
164
- // a skip line is an UNKNOWN, not a clean pass — replaying
165
- // "registry unreachable" for a TTL asserts a dead network as
166
- // fact long after it recovered
167
- const cacheable = r.ttlMs > 0 && !t.failed && !t.warned && !t.lines.some((l) => l.kind === 'skip')
168
- const lines = cacheable
169
- ? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (cached at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + r.ttlMs)})` } : l))
170
- : t.lines
171
- out.push(...lines)
172
- this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
173
- if (t.failed) anyFail = true
174
- if (r.ttlMs > 0) {
175
- if (cacheable) { cache[r.name] = { version: r.version, key, at: this.now(), lines }; cacheDirty = true }
176
- // a WARN/FAIL evicts the stale pass — a later run must re-probe,
177
- // not replay an ok recorded before the breakage
178
- else if (entry != null) { delete cache[r.name]; cacheDirty = true }
179
- }
239
+ for (let i = 0; i < this.runners.length; i++) {
240
+ const r = results[i]!
241
+ const name = this.runners[i]!.name
242
+ out.push(...r.lines)
243
+ if (r.skip != null) skipped.push(r.skip)
244
+ if (r.ran) ran++
245
+ if (r.failed) anyFail = true
246
+ if (r.store != null) { cache[name] = r.store; cacheDirty = true }
247
+ else if (r.evict) { delete cache[name]; cacheDirty = true }
180
248
  }
181
249
 
182
250
  // contract: `skip:` lines ONLY when no check ran at all (whole probe is
@@ -0,0 +1,82 @@
1
+ // resolveFloor — which task fills each floor slot (lint, typecheck, test,
2
+ // imports:check, cycles) and, when nothing can, the reason it stays empty.
3
+ //
4
+ // Its own module rather than a section of repo-config so a RULE can ask what
5
+ // the gate would actually run: repo-config imports the rule registry, so a
6
+ // rule importing repo-config back is a cycle that dies at module init.
7
+ // RepoConfig arrives as a TYPE only, which is erased.
8
+ import { existsSync, readFileSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { fileURLToPath } from 'url'
11
+ import { rawName, routesToShipkit } from './canonical-scripts.ts'
12
+ import type { TaskDef } from './task-runner.ts'
13
+ import type { RepoConfig } from '../repo-config.ts'
14
+
15
+ export const FLOOR_IDS = ['lint', 'typecheck', 'test', 'imports:check', 'cycles'] as const
16
+
17
+ export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)'
18
+ export type FloorResolution = {
19
+ tasks: TaskDef[]
20
+ /** slots disabled by `defaults: { x: false }` — always printed */
21
+ disabled: string[]
22
+ /** slots nothing could fill — always printed, never silent */
23
+ unfilled: { id: string; reason: string }[]
24
+ /** where each registered slot came from (init prints this) */
25
+ sources: Record<string, FloorSource>
26
+ }
27
+
28
+ // zero-config floor (O6): with no script, imports:check/cycles resolve to
29
+ // shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc. Returns
30
+ // the task, or the reason the slot stays empty.
31
+ function builtinFloorTask(id: string, root: string): { task: TaskDef; source: FloorSource } | string {
32
+ if (id === 'imports:check' || id === 'cycles') {
33
+ let cli: string
34
+ // shipkit's OWN module, resolved from this file: no dependency to be
35
+ // out of date with, and no resolution that can fail in a consumer tree
36
+ cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
37
+ return {
38
+ task: { id, cmd: id === 'cycles' ? ['bun', cli, 'cycles'] : ['bun', cli, 'imports', '--check'] },
39
+ source: 'built-in (shipkit)',
40
+ }
41
+ }
42
+ if (id === 'typecheck') {
43
+ if (!existsSync(join(root, 'tsconfig.json'))) return `no 'typecheck' script and no tsconfig.json`
44
+ const local = join(root, 'node_modules/.bin/tsc')
45
+ const tsc = existsSync(local) ? local : Bun.which('tsc')
46
+ if (tsc == null) return `no 'typecheck' script and tsc not found — add typescript or a typecheck script`
47
+ return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
48
+ }
49
+ return `no package.json '${id}' script`
50
+ }
51
+
52
+ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
53
+ const pkgPath = join(root, 'package.json')
54
+ let scripts: Record<string, string> = {}
55
+ if (existsSync(pkgPath)) {
56
+ // unparseable package.json must NOT silently empty the floor — the
57
+ // gate would run only `contracts` and report green
58
+ try { scripts = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {} }
59
+ catch (e) { throw new Error(`package.json unparseable — floor tasks cannot register: ${String(e).slice(0, 120)}`) }
60
+ }
61
+ const tasks: TaskDef[] = []
62
+ const disabled: string[] = []
63
+ const unfilled: { id: string; reason: string }[] = []
64
+ const sources: Record<string, FloorSource> = {}
65
+ for (const id of FLOOR_IDS) {
66
+ const override = c.defaults?.[id]
67
+ if (override === false) { disabled.push(id); continue }
68
+ if (override != null) { tasks.push(override); sources[id] = 'repo.config override'; continue }
69
+ // ONE WAY IN: `<id>` routes to `shipkit ci --only <id>`, so the TOOL
70
+ // body lives at `<id>:raw` and that is what the gate runs. Running the
71
+ // routed script here would spawn the gate from inside itself, forever
72
+ const raw = scripts[rawName(id)]
73
+ if (raw != null) { tasks.push({ id, cmd: ['bun', 'run', rawName(id)] }); sources[id] = 'package.json script'; continue }
74
+ if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push({ id, cmd: ['bun', 'run', id] }); sources[id] = 'package.json script'; continue }
75
+ const b = builtinFloorTask(id, root)
76
+ // a routed script with nothing behind it measures NOTHING, and the
77
+ // reason must say that rather than `no '<id>' script`, which is false
78
+ if (typeof b === 'string') unfilled.push({ id, reason: scripts[id] != null ? `script '${id}' routes to shipkit and there is no '${rawName(id)}' body to run` : b })
79
+ else { tasks.push(b.task); sources[id] = b.source }
80
+ }
81
+ return { tasks, disabled, unfilled, sources }
82
+ }
@@ -10,7 +10,7 @@
10
10
  // path no longer exists are pruned on every write. Churn control: a row
11
11
  // rewrites only when its DATA changes or its last-seen day is older than
12
12
  // 7 days. Never fails the calling command.
13
- import { existsSync, readFileSync } from 'fs'
13
+ import { existsSync, readFileSync, statSync } from 'fs'
14
14
  import { homedir } from 'os'
15
15
  import { join, resolve } from 'path'
16
16
  import type { RepoConfig } from '../repo-config.ts'
@@ -46,6 +46,13 @@ function consumptionMode(root: string): string {
46
46
  } catch { return '?' }
47
47
  }
48
48
 
49
+ /** a LINKED worktree, where `.git` is a file pointing at the main checkout
50
+ * rather than a directory. Cheap on purpose: this runs on every check pass in
51
+ * every repo, and spawning git to learn it would cost more than the write. */
52
+ function isLinkedWorktree(root: string): boolean {
53
+ try { return statSync(join(root, '.git')).isFile() } catch { return false }
54
+ }
55
+
49
56
  export type MotherhomeOutcome = { wrote: boolean; note?: string }
50
57
 
51
58
  export function reportToMotherhome(root: string, config: RepoConfig, today: string = new Date().toISOString().slice(0, 10)): MotherhomeOutcome {
@@ -57,6 +64,12 @@ export function reportToMotherhome(root: string, config: RepoConfig, today: stri
57
64
  if (!base.startsWith('/')) return { wrote: false }
58
65
  const page = join(base, 'build/shipkit/local-projects.md')
59
66
  if (!existsSync(page)) return { wrote: false }
67
+ // a gate run inside a linked worktree must not write the MAIN
68
+ // checkout's tracked page: the row it would add describes a temporary
69
+ // side checkout, and the write lands as unexplained dirt in a tree
70
+ // somebody else is working in. The worktree's own runs are covered
71
+ // when its branch merges.
72
+ if (isLinkedWorktree(root)) return { wrote: false, note: `motherhome: skipped (${config.name} is a linked worktree — its row belongs to the main checkout)` }
60
73
 
61
74
  const mode = consumptionMode(root)
62
75
  // source-consumed code is not the published release its version
@@ -15,6 +15,8 @@ import { RULES } from '../rules/rule-registry.ts'
15
15
  import { runRuleLint } from '../rules/rule.ts'
16
16
  import { formatLine, parseWireOutput, type Line } from './wire.ts'
17
17
  import { writeStatus } from './write-status.ts'
18
+ import { SHARE_ENV } from '../release/releases-share.ts'
19
+ import { rmSync } from 'fs'
18
20
 
19
21
  // orchestrator cache for checks WITHOUT their own suite — short on purpose:
20
22
  // this layer only absorbs back-to-back runs, real TTLs live in the checks
@@ -42,6 +44,21 @@ export type CheckOpts = {
42
44
  * spends a third-party API call per publish. Undeclared checks are still
43
45
  * collected, because an unclassified check stays fatal. */
44
46
  onlyBlocking?: boolean
47
+ /** the caller narrowed the run to these ids (`ci --only`). Empty or absent
48
+ * means everything. A `--only` that narrowed TASKS while every check still
49
+ * ran made the flag actively harmful: `ci --only cycles`, a 0.03s task,
50
+ * probed every third-party service the repo knows about. One flag, one
51
+ * meaning, across all three surfaces. */
52
+ only?: string[]
53
+ }
54
+
55
+ /** does this rule or check answer to one of the `--only` ids? Matched against
56
+ * every name the surface is known by (a rule's id AND its title, a discovered
57
+ * check's name AND its path), because the id an operator types is whichever
58
+ * one they last saw on the wire. */
59
+ export function matchesOnly(only: string[] | undefined, ...names: string[]): boolean {
60
+ if (only == null || only.length === 0) return true
61
+ return only.some((want) => names.some((n) => n.toLowerCase() === want.toLowerCase()))
45
62
  }
46
63
 
47
64
  export type CheckRun = {
@@ -85,26 +102,41 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
85
102
  t.warned = true
86
103
  }
87
104
  for (const rule of [...RULES, ...(c.rules ?? [])]) {
105
+ if (!matchesOnly(o.only, rule.id, rule.title)) continue
88
106
  if (o.onlyBlocking === true && !rule.blocking) continue
89
107
  suite.addCheck({
108
+ blocking: rule.blocking,
90
109
  name: rule.title,
91
110
  remainValidFor: rule.ttl,
92
111
  cacheExtra: rule.cacheKey != null ? () => rule.cacheKey!({ root, config: c }) : undefined,
93
112
  logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
94
113
  })
95
114
  }
115
+ // ONE release survey for the whole run: five discovered checks call
116
+ // collectReports, each in its own subprocess, and the survey is ~4s of
117
+ // network. The path is per RUN and never reused across runs, so nothing
118
+ // here can serve a stale answer to a later gate (releases-share.ts).
119
+ // a caller that already opened one (runCi, which starts the survey next to
120
+ // the tasks) owns it, cleanup included; otherwise this run owns its own
121
+ const inherited = process.env[SHARE_ENV]
122
+ const sharePath = inherited != null && inherited !== '' ? inherited : join(root, 'tmp/shipkit-ci', `releases-${process.pid}-${Date.now()}.json`)
96
123
  const childEnv: Record<string, string | undefined> = {
97
124
  ...process.env,
125
+ [SHARE_ENV]: sharePath,
98
126
  ...(o.fresh === true ? { SHIPKIT_FRESH: '1', RV_CHECK_FRESH: '1' } : {}),
99
127
  }
100
128
  for (const d of discoverChecks(root)) {
101
129
  const rel = relative(root, d.path)
102
130
  // file wins, then the repo default, then nothing: an unclassified
103
131
  // check is an error, never a guess
132
+ if (!matchesOnly(o.only, d.name, rel)) continue
104
133
  const blocking = d.blocking ?? c.checkDefaults?.blocking
105
134
  if (blocking == null) undeclared.push(rel)
106
135
  if (o.onlyBlocking === true && blocking !== true) continue
107
136
  suite.addCheck({
137
+ // a check about the WORLD may remember a measured negative; one
138
+ // about this repo never does (check-suite.ts, the rule)
139
+ blocking: blocking ?? true,
108
140
  name: d.name,
109
141
  remainValidFor: d.selfCached ? undefined : DISCOVERED_TTL,
110
142
  logic: async (t) => {
@@ -121,6 +153,8 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
121
153
  })
122
154
  }
123
155
  const r = await suite.execute()
156
+ // the survey dies with the run that made it, and only the run that made it
157
+ if (inherited == null || inherited === '') for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
124
158
  if (prevFresh == null) delete process.env.SHIPKIT_FRESH; else process.env.SHIPKIT_FRESH = prevFresh
125
159
  if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
126
160
  // frozen = a release-shaped run: reporting would re-dirty the tree the
@@ -13,6 +13,11 @@ import { runFixPass } from './run-fixers.ts'
13
13
  import { formatLine } from './wire.ts'
14
14
  import { runTasks, type TaskResult } from './task-runner.ts'
15
15
  import { writeStatus } from './write-status.ts'
16
+ import { rmSync } from 'fs'
17
+
18
+ import { SHARE_ENV } from '../release/releases-share.ts'
19
+ import { collectReports } from '../release/run-releases.ts'
20
+ import { withContractsScope } from '../contracts/contracts-once.ts'
16
21
 
17
22
  export type CiOpts = {
18
23
  bail?: boolean
@@ -54,8 +59,22 @@ export type CiOpts = {
54
59
  * loads of repo.config.ts can disagree, and the release path classifies
55
60
  * rule-vs-probe severity from the config it holds. */
56
61
  config?: RepoConfig
62
+ /** which test tier the run asks the repo's suite for. 'quick' is the
63
+ * working default; 'full' sets SHIPKIT_TEST_FULL=1 for every task, which
64
+ * a suite reads to include the slow, real-world tiers it otherwise skips.
65
+ * The release path always asks for 'full': a version published behind a
66
+ * partial suite is a green nobody measured. Default reads the env var, so
67
+ * `SHIPKIT_TEST_FULL=1 shipkit ci` works with no flag. */
68
+ tests?: TestTier
57
69
  }
58
70
 
71
+ export type TestTier = 'quick' | 'full'
72
+
73
+ /** the env var a repo's own suite reads to widen itself. Named here because
74
+ * the gate is what sets it, and a suite that invents its own name is a tier
75
+ * the release path cannot reach. */
76
+ export const FULL_TESTS_ENV = 'SHIPKIT_TEST_FULL'
77
+
59
78
  /** what STATUS.md may claim about this run. `--with` ADDS a task, so it stays
60
79
  * the full gate; `--only`, `--skip` and skipChecks each remove coverage, and
61
80
  * a narrowed run that stamps the full gate's verdict is a green nobody
@@ -71,6 +90,12 @@ function ciScope(o: CiOpts): { command: string; fullGate: boolean } {
71
90
  }
72
91
 
73
92
  export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
93
+ // ONE gate run: the `contracts` task evaluates, rule SK002 reads what it
94
+ // computed instead of evaluating a second time (contracts-once.ts)
95
+ return await withContractsScope(() => runCiInScope(root, o))
96
+ }
97
+
98
+ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
74
99
  const print = o.print ?? ((l: string) => console.log(l))
75
100
  const collected: string[] = []
76
101
  const sink = (l: string): void => { collected.push(l); print(l) }
@@ -83,12 +108,36 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
83
108
  const all = allTasks(c, root)
84
109
  const { disabled, unfilled } = all
85
110
  const skipIds = new Set(o.skipTaskIds ?? [])
86
- const tasks = all.tasks.filter((t) => !skipIds.has(t.id))
111
+ const tier: TestTier = o.tests ?? (process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick')
112
+ const tasks = all.tasks
113
+ .filter((t) => !skipIds.has(t.id))
114
+ // the whole run carries the flag rather than one task id: a repo names
115
+ // its suites what it likes (`test`, `test:libs`, a prepublish script),
116
+ // and a gate that guesses which one is the tests will miss one
117
+ .map((t) => (tier === 'full' ? { ...t, env: { ...t.env, [FULL_TESTS_ENV]: '1' } } : t))
118
+ // announced BEFORE the run, so a log that died mid-way still says which
119
+ // tier it was asking for
120
+ sink(tier === 'full'
121
+ ? `ok: test tier FULL — every suite, the slow ones included (${FULL_TESTS_ENV}=1)`
122
+ : `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
87
123
  for (const t of all.tasks) if (skipIds.has(t.id)) sink(`skip: ${t.id} (not part of this release, the unit's own gate covers it)`)
88
124
  for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
89
125
  // a floor slot nothing could fill is VISIBLE — a thin gate must never
90
126
  // look like a full one (the fresh-repo floor gap)
91
127
  for (const u of unfilled) sink(`skip: ${u.id} (${u.reason})`)
128
+ // the release survey is ~4s of network that five discovered checks need
129
+ // and the TASKS do not. Started HERE it overlaps lint/typecheck/test and
130
+ // costs the run nothing; started inside the first check that wants it, it
131
+ // is 4s nobody can overlap. Failures are the checks' to report, not this
132
+ // line's: a survey nobody can run just means the checks do it themselves.
133
+ const sharePath = join(root, 'tmp/shipkit-ci', `releases-${process.pid}-${Date.now()}.json`)
134
+ const priorShare = process.env[SHARE_ENV]
135
+ process.env[SHARE_ENV] = sharePath
136
+ // a NARROWED run pre-warms nothing: `--only cycles` asks one question, and
137
+ // paying a 4s network survey to answer it is the same bug --only was
138
+ // fixed for
139
+ const willRunChecks = o.skipChecks !== true && (o.onlyIds ?? []).length === 0
140
+ const survey = willRunChecks ? collectReports(root, c).catch(() => null) : null
92
141
  const summary = await runTasks({
93
142
  tasks,
94
143
  logDir: c.logDir ?? join(root, 'tmp/shipkit-ci'),
@@ -100,11 +149,23 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
100
149
  print: sink,
101
150
  onTaskFinish: o.onTaskFinish,
102
151
  })
152
+ // what the suite ACTUALLY ran, from its own summary line. A flag that was
153
+ // set proves the intent; the count proves the tests
154
+ const ranTests = summary.results
155
+ .map((r) => /Ran (\d+) tests?/.exec(r.lastLine ?? '')?.[1])
156
+ .find((n) => n != null)
157
+ if (ranTests != null) sink(`ok: test tier ${tier}: ${ranTests} tests ran`)
103
158
  // the checks, in the SAME command. there is no second surface: a check
104
159
  // declares whether it blocks, and that is the only question about it.
160
+ // a narrowed run says what it did NOT look at: a green that only ever
161
+ // asked one question must not read like the full gate
162
+ if ((o.onlyIds ?? []).length > 0 && o.skipChecks !== true) sink(`skip: checks not named by --only (${(o.onlyIds ?? []).join(', ')})`)
163
+ if (survey != null) await survey
105
164
  const checks = o.skipChecks === true
106
165
  ? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
107
- : await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks })
166
+ : await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds })
167
+ if (priorShare == null) delete process.env[SHARE_ENV]; else process.env[SHARE_ENV] = priorShare
168
+ for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
108
169
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
109
170
  for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
110
171
  for (const u of checks.undeclared) {
@@ -14,6 +14,7 @@ import { fileURLToPath } from 'url'
14
14
  import { armHooks } from '../hooks/run-hooks.ts'
15
15
  import type { RepoConfig } from '../repo-config.ts'
16
16
  import { runFix } from '../rules/run-fix.ts'
17
+ import { routesToShipkit } from './canonical-scripts.ts'
17
18
  import { spawnWithTimeout } from './spawn-timeout.ts'
18
19
 
19
20
  const FIXER_TIMEOUT_MS = 120_000
@@ -60,8 +61,12 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
60
61
  return true
61
62
  }
62
63
 
63
- const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null)
64
+ // `fmt` is the one slot the fix pass invokes BY SCRIPT, so it is the one
65
+ // name ONE WAY IN deliberately leaves raw: a routed body here would make
66
+ // the fixer spawn the gate that spawns the fixer
67
+ const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null && !routesToShipkit(scripts[n]!))
64
68
  if (fmtName != null) slots.push({ id: 'fmt', cmd: ['bun', 'run', fmtName], source: 'package.json script' })
69
+ else if (FMT_SCRIPTS.some((n) => scripts[n] != null)) unfilled.push({ id: 'fmt', reason: `the 'fmt' script routes back to shipkit — the fix pass runs it, so it holds the formatter itself` })
65
70
  else unfilled.push({ id: 'fmt', reason: `no 'fmt' or 'format' script — formatting stays a manual step` })
66
71
 
67
72
  if (offByConfig('imports:fix')) { /* the repo said no, in the one place that governs both directions */ }