rvlib-shipkit 0.37.1 → 0.38.1

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## 0.38.1
2
+
3
+ - **The suite runner no longer refuses the test arguments of a repo with no `tests` block.** The floor spawns `bun suite-cli.ts [flags] -- <bun test args>`, and bun swallows a LEADING `--`. With no declaration there are no flags before it, so the runner saw `src/ tests/` bare and answered `unknown flag src/`, and a `bun test <args>` script with no `tests` block had a dead test slot on 0.38.0. The runner's own flags now end at `--` or at the first token that is not one of them. Pinned by a test fed both shapes, with a `bun test` flag and a separated declaration as controls.
4
+
5
+ ## 0.38.0
6
+
7
+ - **A plain `bun test` floor runs its suites in parallel and times each one against a budget.** The floor `test` slot used to spawn `bun run test:raw`, which is one bun process running every suite on one core. Measured on a field repo: 71 suites in 74s, then a SIGKILL reported as `exited with code 1`, against 19.5s for the same suites in parallel workers. When the body is a literal `bun test …` (no cd, env prefix, pipe, quote or substitution, `--flag=value` allowed), the slot now runs `src/suite/suite-cli.ts`: every suite in `--parallel` workers, then the suites declared `serial` one at a time, with bun's own per-suite `--timings` read back. In the quick tier, the one every edit waits on, a suite over `repo.config.ts tests.budgetMs` (default 2000ms) runs again alone, and FAILs the task only when that alone timing is still over and it is not declared in `tests.slow` with `catches` (what no fast test catches) and `whyNotFast`. The quick tier leaves declared slow suites out, the full tier runs them, and a declared suite that runs under half the budget prints a WARN to drop the declaration without blocking. The full tier fails nothing on time and reports its three slowest suites. The full tier also runs one suite at a time: its suites spawn git and whole gates, and in parallel they measured slower than sequential and timed out. A killed suite prints `killed by SIGKILL while running <suite>`, from a crashed parallel worker or from the serial phase. The headline keeps `Ran N tests`. Any other script body runs as written, and the gate prints `skip: suite runner (…)` saying it is neither parallel nor timed. `defineRepo` refuses a declaration with a blank reason, a path outside the repo, a duplicate, or an unknown field, on the repo and on each `units[]` entry (paths relative to the unit), and the runner FAILs one naming a suite that does not exist.
8
+
9
+ - **A repo task with an unknown field now fails at config load.** `defineRepo` refused a typo inside a `defaults` slot but not inside a `tasks` entry, and `repo.config.ts` usually sits outside every tsconfig, so no type error catches it either. A field repo declared `reads: ['.']` on its `fmt` and `build` tasks, where the field is `inputs`: both loaded as undeclared, and the 10s build re-ran on every gate over an unchanged tree. The error names the task and lists the valid fields.
10
+
11
+ - **`ruleExempt`: a repo exempts one rule by id, with the reason.** `repo.config.ts ruleExempt: { SK003: '<why>' }`. The exempted rule does not lint and never autofixes. Every surface that runs it prints `skip: [SK003] SK003 exempt: <why>`, and `shipkit rules` marks it `EXEMPT here`. `defineRepo` throws on an id no built-in or repo rule carries, and on a blank reason. The reason is part of the rule's cache identity, so declaring or lifting an exemption never replays an old answer. It changes nothing a rule enforces anywhere else.
12
+
13
+ - **A repo can be one folder of a larger git work tree: `nested: true`, rule SK015.** A `repo.config.ts` inside another repository used to treat the parent's history as its own. The contract folder repair renamed a tracked folder and landed a machine commit on the parent's branch, `machineCommit` committed wherever its anchor resolved, `bump` and `release` would tag and push the parent, SK007 judged the parent's github remote, SK009 scanned the parent's whole log, and the ledger keyed a declared input like `src` on the PARENT's `src`, so an edit inside the folder never moved its key. On the disk fact alone, declared or not (the fix pass runs before any rule): the repair leaves a tracked folder alone, `machineCommit` refuses an anchor that belongs to a nested config, `bump` and `release` refuse and name the parent, SK007 and SK009 skip and name it, the hooks skip with the nesting named, and the scan keys every declared path, the whole tree included, on the folder's own subtree. SK015 FAILs a nested folder that does not declare `nested: true`, and a work tree root that does. The generated-file untrack pass was already inert there, because it needs a `.git` in the folder, and a test now pins that.
14
+
1
15
  ## 0.37.1
2
16
 
3
17
  - **SK013 no longer wires a Bash guard that cannot run.** A consumer that runs shipkit from a checkout by ABSOLUTE path, with no `rvlib-shipkit` dependency and no `node_modules`, carried `bun "$CLAUDE_PROJECT_DIR/node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts"`, so every Bash call of every agent in that repo printed `Module not found`, while the rule only WARNed `not installed yet, bun install` and the gate stayed green. `bun install` installs nothing in a repo that declares no dependency, so that WARN now fires only when `package.json` declares `rvlib-shipkit`, and otherwise the missing guard FAILs. `guardCommand` glued an absolute cli path under the project dir (`$CLAUDE_PROJECT_DIR//Users/...`), and it now writes the absolute path alone. The lint resolves an absolute guard path too, where it used to pass any form it could not read. The fixer, which `shipkit ci` runs by default, repoints a guard whose script does not exist at the copy the repo actually runs: the cli path its scripts name when a guard sits beside it (a repo's own `src/cli.ts` matches the same script shape), else its declared dependency, else the shipkit doing the fixing, which is what the launcher falls back to. A guard that resolves, or waits on a declared install, stays as written, and a target that does not resolve itself is never written.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.37.1",
3
+ "version": "0.38.1",
4
4
  "funding": "https://github.com/sponsors/rvion",
5
5
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
6
6
  "license": "MIT",
@@ -5,7 +5,16 @@
5
5
  // is a repo whose agents cannot know what binds them.
6
6
  import { Command, Option } from 'clipanion'
7
7
  import { RULES } from '../rules/rule-registry.ts'
8
- import { loadRepoConfig } from '../repo-config.ts'
8
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
9
+
10
+ export type RuleRow = { id: string; title: string; owner: string; blocking: boolean; fixable: boolean; source: 'shipkit' | 'repo'; exempt?: string }
11
+
12
+ /** the catalog: built-in rules, then the repo's own */
13
+ export function ruleRows(config: RepoConfig | null): RuleRow[] {
14
+ const row = (source: RuleRow['source']) => (r: { id: string; title: string; owner: string; blocking: boolean; fix?: unknown }): RuleRow =>
15
+ ({ id: r.id, title: r.title, owner: r.owner, blocking: r.blocking, fixable: r.fix != null, source, ...(config?.ruleExempt?.[r.id] == null ? {} : { exempt: config.ruleExempt[r.id] }) })
16
+ return [...RULES.map(row('shipkit')), ...(config?.rules ?? []).map(row('repo'))]
17
+ }
9
18
 
10
19
  export class RulesCommand extends Command {
11
20
  static override paths = [['rules']]
@@ -24,13 +33,9 @@ export class RulesCommand extends Command {
24
33
  async execute(): Promise<number> {
25
34
  // a repo without repo.config.ts still gets the built-in catalog: this
26
35
  // command must answer "what rules exist" from anywhere
27
- const repoRules = await loadRepoConfig(process.cwd())
28
- .then((c) => c.rules ?? [])
29
- .catch(() => [])
30
- const rows = [
31
- ...RULES.map((r) => ({ id: r.id, title: r.title, owner: r.owner, blocking: r.blocking, fixable: r.fix != null, source: 'shipkit' as const })),
32
- ...repoRules.map((r) => ({ id: r.id, title: r.title, owner: r.owner, blocking: r.blocking, fixable: r.fix != null, source: 'repo' as const })),
33
- ]
36
+ const config = await loadRepoConfig(process.cwd()).catch(() => null)
37
+ const repoRules = config?.rules ?? []
38
+ const rows = ruleRows(config)
34
39
  if (this.json) {
35
40
  this.context.stdout.write(`${JSON.stringify(rows)}\n`)
36
41
  return 0
@@ -38,7 +43,7 @@ export class RulesCommand extends Command {
38
43
  // a catalog, not a health run — no wire prefixes (`ok:` would assert
39
44
  // a passing check into anything that parses wire output)
40
45
  for (const r of rows)
41
- this.context.stdout.write(`${r.blocking ? 'BLOCKS ' : 'warns '}${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}\n`)
46
+ this.context.stdout.write(`${r.blocking ? 'BLOCKS ' : 'warns '}${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}${r.exempt == null ? '' : ` · EXEMPT here: ${r.exempt}`}\n`)
42
47
  if (repoRules.length > 0)
43
48
  this.context.stdout.write(`\n${RULES.length} built-in · ${repoRules.length} repo-local (repo.config.ts rules)\n`)
44
49
  return 0
package/src/core/floor.ts CHANGED
@@ -10,6 +10,7 @@ import { join } from 'path'
10
10
  import { fileURLToPath } from 'url'
11
11
  import { rawName, routesToShipkit } from './canonical-scripts.ts'
12
12
  import { resolveTypecheckProjects } from '../ts/tsconfig-projects.ts'
13
+ import { parseBunTestScript, suiteFlags } from '../suite/judge-suites.ts'
13
14
  import type { TaskDef } from './task-runner.ts'
14
15
  import type { RepoConfig } from '../repo-config.ts'
15
16
 
@@ -24,6 +25,21 @@ export type FloorResolution = {
24
25
  unfilled: { id: string; reason: string }[]
25
26
  /** where each registered slot came from (init prints this) */
26
27
  sources: Record<string, FloorSource>
28
+ /** skip lines about how a slot runs, printed by the gate */
29
+ notes?: string[]
30
+ }
31
+
32
+ /** a package.json body for a slot. A plain `bun test` in the test slot runs
33
+ * through the suite runner (parallel, timed against the budget), anything
34
+ * else runs as written and the gate says what that costs */
35
+ function scriptCmd(c: RepoConfig, id: string, name: string, body: string, notes: string[]): string[] {
36
+ if (id !== 'test') return ['bun', 'run', name]
37
+ const args = parseBunTestScript(body)
38
+ if (args == null) {
39
+ notes.push(`skip: suite runner (script '${name}' is not a plain \`bun test\`, so its suites run as written: not in parallel, not timed against a budget)`)
40
+ return ['bun', 'run', name]
41
+ }
42
+ return ['bun', fileURLToPath(import.meta.resolve('rvlib-shipkit/src/suite/suite-cli.ts')), ...suiteFlags(c.tests), '--', ...args]
27
43
  }
28
44
 
29
45
  // zero-config floor (O6): with no script, imports:check/cycles resolve to
@@ -99,6 +115,7 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
99
115
  const disabled: string[] = []
100
116
  const unfilled: { id: string; reason: string }[] = []
101
117
  const sources: Record<string, FloorSource> = {}
118
+ const notes: string[] = []
102
119
  for (const id of FLOOR_IDS) {
103
120
  const override = c.defaults?.[id]
104
121
  if (override === false) { disabled.push(id); continue }
@@ -116,13 +133,13 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
116
133
  // routed script here would spawn the gate from inside itself, forever
117
134
  const decorate = (t: TaskDef): TaskDef => (override == null ? t : { ...t, ...override, id })
118
135
  const raw = scripts[rawName(id)]
119
- if (raw != null) { tasks.push(decorate({ id, cmd: ['bun', 'run', rawName(id)] })); sources[id] = 'package.json script'; continue }
120
- if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push(decorate({ id, cmd: ['bun', 'run', id] })); sources[id] = 'package.json script'; continue }
136
+ if (raw != null) { tasks.push(decorate({ id, cmd: scriptCmd(c, id, rawName(id), raw, notes) })); sources[id] = 'package.json script'; continue }
137
+ if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push(decorate({ id, cmd: scriptCmd(c, id, id, scripts[id]!, notes) })); sources[id] = 'package.json script'; continue }
121
138
  const b = builtinFloorTask(id, root)
122
139
  // a routed script with nothing behind it measures NOTHING, and the
123
140
  // reason must say that rather than `no '<id>' script`, which is false
124
141
  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 })
125
142
  else { tasks.push(decorate(b.task)); sources[id] = b.source }
126
143
  }
127
- return { tasks, disabled, unfilled, sources }
144
+ return { tasks, disabled, unfilled, sources, notes }
128
145
  }
@@ -146,7 +146,7 @@ export type LeaseOutcome<T> = { ok: true; value: T; waitedMs: number } | { ok: f
146
146
  * that is gone or wedged, refused (never hung) past maxWaitMs. */
147
147
  export async function withMutateLease<T>(
148
148
  path: string,
149
- o: { label: string; print?: (line: string) => void; maxWaitMs?: number; escape?: string },
149
+ o: { label: string; print?: (line: string) => void; maxWaitMs?: number; escape?: string; announceMs?: number },
150
150
  fn: () => T | Promise<T>,
151
151
  ): Promise<LeaseOutcome<T>> {
152
152
  const lockPath = `${path}.lock`
@@ -179,7 +179,7 @@ export async function withMutateLease<T>(
179
179
  ok: false,
180
180
  warn: `${o.label} lease held by ${holder == null ? 'an unreadable lock' : `pid ${holder.pid} (${holder.label})`} for ${Math.round(ageMs / 1000)}s — gave up after ${Math.round(waited / 1000)}s rather than run two of these at once. Lock: ${lockPath}${o.escape == null ? '' : `; skip this phase with ${o.escape}`}`,
181
181
  }
182
- if (waited >= LEASE_ANNOUNCE_MS && waited - announced >= LEASE_REANNOUNCE_MS) {
182
+ if (waited >= (o.announceMs ?? LEASE_ANNOUNCE_MS) && waited - announced >= LEASE_REANNOUNCE_MS) {
183
183
  announced = waited
184
184
  print(`ok: waiting ${Math.round(waited / 1000)}s for the ${o.label} lease — ${holder == null ? 'an unreadable lock holds it' : `pid ${holder.pid} holds it (${holder.label})`}, lock ${lockPath}`)
185
185
  }
@@ -18,6 +18,7 @@
18
18
  // not worth a red gate. `SHIPKIT_NO_AUTOCOMMIT=1` turns it off.
19
19
  import { existsSync } from 'fs'
20
20
  import { join, resolve } from 'path'
21
+ import { nestedConfigRoot } from './nesting.ts'
21
22
 
22
23
  const git = (cwd: string, args: string[]): { ok: boolean; out: string } => {
23
24
  const r = Bun.spawnSync(['git', ...args], { cwd })
@@ -40,6 +41,8 @@ export function machineCommit(o: MachineCommit): boolean {
40
41
  const top = git(resolve(o.anchor), ['rev-parse', '--show-toplevel'])
41
42
  if (!top.ok) return false // not a git repo, nothing to land
42
43
  const root = top.out
44
+ // an anchor inside a nested shipkit repo would land on the parent's branch
45
+ if (nestedConfigRoot(resolve(o.anchor), root) != null) return false
43
46
  // a detached HEAD or a repo with no commit yet has nowhere sane to land
44
47
  if (!git(root, ['symbolic-ref', '--quiet', 'HEAD']).ok) return false
45
48
  if (!git(root, ['rev-parse', '--verify', 'HEAD']).ok) return false
@@ -0,0 +1,57 @@
1
+ // a repo.config.ts can sit in ONE FOLDER of a larger git work tree (an
2
+ // extension folder inside a product monorepo). shipkit then owns that folder
3
+ // only: it never commits, untracks, tags or pushes on the parent's history, and
4
+ // every path it hands git names a path inside the folder. `nested: true` in
5
+ // repo.config.ts states it, rule SK015 fails when the declaration and the disk
6
+ // disagree, and the call sites below refuse on the disk fact alone, declared or
7
+ // not, because the fix pass runs before any rule can say so.
8
+ import { existsSync, realpathSync } from 'fs'
9
+ import { dirname, join } from 'path'
10
+
11
+ const gitLine = (cwd: string, args: string[]): string | null => {
12
+ try {
13
+ const p = Bun.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
14
+ return p.exitCode === 0 ? p.stdout.toString().trim() : null
15
+ } catch {
16
+ return null
17
+ }
18
+ }
19
+
20
+ /** the work tree root holding `root` when it is a PARENT of `root`. null when
21
+ * `root` is its own work tree root, or sits outside git */
22
+ export function nestedIn(root: string): string | null {
23
+ const top = gitLine(root, ['rev-parse', '--show-toplevel'])
24
+ if (top == null || top === '') return null
25
+ try {
26
+ return realpathSync(top) === realpathSync(root) ? null : top
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ /** `root` relative to its work tree root, posix: '' at the root, else ending in '/' */
33
+ export function treePrefix(root: string): string {
34
+ return gitLine(root, ['rev-parse', '--show-prefix']) ?? ''
35
+ }
36
+
37
+ /** the nearest repo.config.ts at or above `dir` and strictly below the work
38
+ * tree root `top`: the nested shipkit repo `dir` belongs to, if any */
39
+ export function nestedConfigRoot(dir: string, top: string): string | null {
40
+ let cur: string
41
+ let stop: string
42
+ try {
43
+ cur = realpathSync(dir)
44
+ stop = realpathSync(top)
45
+ } catch {
46
+ return null
47
+ }
48
+ while (cur !== stop && cur.startsWith(`${stop}/`)) {
49
+ if (existsSync(join(cur, 'repo.config.ts'))) return cur
50
+ cur = dirname(cur)
51
+ }
52
+ return null
53
+ }
54
+
55
+ /** the one refusal line every history-writing command prints */
56
+ export const nestedRefusal = (top: string): string =>
57
+ `this repo is one folder of the git repo at ${top}, and that history is not shipkit's to commit, tag or push`
@@ -22,6 +22,7 @@ import { readdirSync, renameSync, rmdirSync } from 'fs'
22
22
  import { join } from 'path'
23
23
  import { POLICY_LINES } from '../rules/SK004-gitignore-policy.ts'
24
24
  import { machineCommit } from './machine-commit.ts'
25
+ import { nestedIn } from './nesting.ts'
25
26
 
26
27
  /** the always-ignored `.rv-*` contract folders, from the policy that owns them */
27
28
  export const contractDirNames = (): string[] =>
@@ -97,6 +98,8 @@ export function repairContractDirs(root: string): DirRepairResult {
97
98
  const renamed: DirRepair[] = []
98
99
  const removed: string[] = []
99
100
  const uncommitted: DirRepair[] = []
101
+ // a nested repo never untracks: the index and the commit are the parent's
102
+ const nested = plan.repairs.length === 0 ? null : nestedIn(root)
100
103
  for (const r of plan.repairs) {
101
104
  const from = join(root, r.from)
102
105
  const to = join(root, r.to)
@@ -113,7 +116,7 @@ export function repairContractDirs(root: string): DirRepairResult {
113
116
  // says those files are local. Untracked files rename freely: there
114
117
  // is nothing to lose and nothing to commit
115
118
  const tracked = isTracked(root, r.from)
116
- if (tracked && !isIgnoredHere(root, r.to)) continue
119
+ if (tracked && (nested != null || !isIgnoredHere(root, r.to))) continue
117
120
  renameSync(from, to)
118
121
  renamed.push(r)
119
122
  // a repair that half happened is not the silence that was asked
@@ -21,7 +21,7 @@ import { spawnWithTimeout } from './spawn-timeout.ts'
21
21
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
22
22
  import { reportToMotherhome } from './motherhome.ts'
23
23
  import { RULES } from '../rules/rule-registry.ts'
24
- import { runRuleLint } from '../rules/rule.ts'
24
+ import { ruleCacheExtra, runRuleLint } from '../rules/rule.ts'
25
25
  import { formatLine, parseWireOutput, type Line } from './wire.ts'
26
26
  import { writeStatus } from './write-status.ts'
27
27
  import { gateInventory } from '../ledger/gate-units.ts'
@@ -202,7 +202,7 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
202
202
  blocking: rule.blocking,
203
203
  name: rule.title,
204
204
  remainValidFor: rule.ttl,
205
- cacheExtra: () => worldKey(rule.id, rule.cacheKey?.({ root, config: c })),
205
+ cacheExtra: () => worldKey(rule.id, ruleCacheExtra(rule, { root, config: c })),
206
206
  logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
207
207
  })
208
208
  }
@@ -133,6 +133,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
133
133
  : `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
134
134
  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)`)
135
135
  for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
136
+ for (const n of all.notes ?? []) sink(n)
136
137
  // THE LEDGER, opened once per run and AFTER the fix pass: the fixers
137
138
  // rewrite source, and a scan taken before them would have every unit
138
139
  // discarded by its own gate's repair work.
@@ -70,7 +70,7 @@ export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
70
70
  if (top.exitCode !== 0) return { state: 'no-git', detail: 'not a git repo' }
71
71
  const topDir = top.stdout.toString().trim()
72
72
  try {
73
- if (realpathSync(topDir) !== realpathSync(root)) return { state: 'not-root', detail: `work tree root is ${topDir}, not here` }
73
+ if (realpathSync(topDir) !== realpathSync(root)) return { state: 'not-root', detail: `nested in ${topDir}, which owns its own commit gates` }
74
74
  } catch {
75
75
  return { state: 'no-git', detail: 'work tree root unresolvable' }
76
76
  }
@@ -11,6 +11,7 @@ import { allTasks, type RepoConfig } from '../repo-config.ts'
11
11
  import type { TaskDef } from '../core/task-runner.ts'
12
12
  import { discoverChecks } from '../core/discover-checks.ts'
13
13
  import { RULES } from '../rules/rule-registry.ts'
14
+ import { ruleCacheExtra } from '../rules/rule.ts'
14
15
  import type { UnitDecl } from './git-key.ts'
15
16
 
16
17
  /** the env var a repo's own suite reads to widen itself. It lives HERE because
@@ -103,7 +104,7 @@ export function gateInventory(root: string, c: RepoConfig, tasks?: TaskDef[]): U
103
104
  code: r.code,
104
105
  ttl: r.ttl,
105
106
  never: r.never,
106
- extra: r.cacheKey?.({ root, config: c }),
107
+ extra: ruleCacheExtra(r, { root, config: c }),
107
108
  },
108
109
  title: r.title,
109
110
  gate: true,
@@ -31,6 +31,7 @@
31
31
  // retried and only a NON lock failure is fatal.
32
32
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'fs'
33
33
  import { isAbsolute, join, normalize } from 'path'
34
+ import { treePrefix } from '../core/nesting.ts'
34
35
 
35
36
  /** what a scan measured. Every OID is a git object id of the CURRENT disk
36
37
  * content, tracked or not. */
@@ -244,15 +245,20 @@ export function scanPaths(root: string, rawPaths: string[]): ScanResult {
244
245
 
245
246
  const treeOid = gitOrThrow(root, ['write-tree'], indexFile).trim()
246
247
  const oidByPath = new Map<string, string>()
247
- // the work tree's own id IS the tree the adds just produced — there is no
248
- // `<tree>:` path to ask git for
249
- if (wanted.includes(WHOLE_TREE)) oidByPath.set(WHOLE_TREE, treeOid)
248
+ // a declared path is relative to root, and `<tree>:<path>` reads from the
249
+ // work tree root. In a nested repo the two differ, so every query carries
250
+ // the folder's prefix, and the whole tree is the folder's own subtree
251
+ const prefix = treePrefix(root)
252
+ const underRoot = (p: string): string => (p === WHOLE_TREE ? prefix.replace(/\/$/, '') : `${prefix}${p}`)
253
+ // at a work tree root the whole tree IS the tree the adds just produced,
254
+ // there is no `<tree>:` path to ask git for
255
+ if (wanted.includes(WHOLE_TREE) && prefix === '') oidByPath.set(WHOLE_TREE, treeOid)
250
256
  const forceSet = new Set(forceAdd)
251
- const plain = named.filter((p) => !forceSet.has(p))
257
+ const plain = [...named.filter((p) => !forceSet.has(p)), ...(wanted.includes(WHOLE_TREE) && prefix !== '' ? [WHOLE_TREE] : [])]
252
258
  for (const [tree, paths, index] of [[treeOid, plain, indexFile], [null, forceAdd, ignoredIndex]] as const) {
253
259
  if (paths.length === 0) continue
254
260
  const root_ = tree ?? gitOrThrow(root, ['write-tree'], index).trim()
255
- const query = paths.map((p) => `${root_}:${p}`).join('\n')
261
+ const query = paths.map((p) => `${root_}:${underRoot(p)}`).join('\n')
256
262
  const p = Bun.spawnSync(['git', 'cat-file', '--batch-check'], {
257
263
  cwd: root,
258
264
  stdin: Buffer.from(`${query}\n`),
@@ -36,6 +36,7 @@ import { releaseScope } from './release-scope.ts'
36
36
  import { formatUnitError, releaseOrder, renderTag, resolveUnits, unitById, unitPrepublishScripts, type ReleaseUnit } from './units.ts'
37
37
  import { environmentBlockers, runReleaseGate, releaseUnit } from './run-release.ts'
38
38
  import type { RepoConfig } from '../repo-config.ts'
39
+ import { nestedIn, nestedRefusal } from '../core/nesting.ts'
39
40
 
40
41
  export type BumpKind = 'major' | 'minor' | 'patch'
41
42
 
@@ -218,6 +219,8 @@ type Selected = { unit: ReleaseUnit; version: string; changelogRel: string; kind
218
219
  export async function runBump(root: string, config: RepoConfig, kind: BumpKind | null, o: BumpOpts = {}): Promise<number> {
219
220
  const print = o.print ?? ((l: string) => console.log(l))
220
221
  if (o.all === true && o.unit != null) { print('FAIL: pass a unit or --all, never both'); return 1 }
222
+ const nested = nestedIn(root)
223
+ if (nested != null) { print(`FAIL: ${nestedRefusal(nested)}`); return 1 }
221
224
 
222
225
  const git: Git = (args) => gitAt(root, args)
223
226
  const cur = git(['rev-parse', '--abbrev-ref', 'HEAD']).out
@@ -15,6 +15,7 @@ import { releaseScope } from './release-scope.ts'
15
15
  import { npmPublishArgv, unitBlockers } from './unit-gate.ts'
16
16
  import { formatUnitError, resolveUnits, unitById, type ReleaseUnit } from './units.ts'
17
17
  import type { RepoConfig } from '../repo-config.ts'
18
+ import { nestedIn, nestedRefusal } from '../core/nesting.ts'
18
19
 
19
20
  // adding a FOURTH knob here is the signal that this shape wants rethinking
20
21
  // rather than another key (meta/gaps.md, release-path config keys accreting)
@@ -135,6 +136,8 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
135
136
  /** `shipkit release [unit]` — resolve the unit, then ship it. */
136
137
  export async function runRelease(root: string, config: RepoConfig, o: ReleaseOpts = {}): Promise<number> {
137
138
  const print = o.print ?? ((l: string) => console.log(l))
139
+ const nested = nestedIn(root)
140
+ if (nested != null) { print(`FAIL: ${nestedRefusal(nested)}`); return 1 }
138
141
  const { units, errors } = resolveUnits(root, config)
139
142
  const id = o.unit ?? 'root'
140
143
  // only THIS unit's own resolution has to hold: a broken sibling is not a
@@ -8,6 +8,7 @@
8
8
  // rediscover packages (drift probes, per-lib tests, per-lib typecheck, the
9
9
  // stranded-tag probe) reads it instead of walking the tree with its own rules.
10
10
  import { existsSync, readFileSync, statSync } from 'fs'
11
+ import type { TestsConfig } from '../suite/judge-suites.ts'
11
12
  import { basename, join, relative } from 'path'
12
13
  import type { RepoConfig } from '../repo-config.ts'
13
14
 
@@ -29,9 +30,11 @@ export type ReleaseUnitDecl = {
29
30
  githubRelease?: boolean
30
31
  /** repo-relative tsconfig for the unit's own typecheck (default '<dir>/tsconfig.json' when it exists) */
31
32
  tsconfig?: string
33
+ /** the unit's own suite budget, paths relative to the unit dir (suite/judge-suites.ts) */
34
+ tests?: TestsConfig
32
35
  }
33
36
 
34
- export const UNIT_DECL_KEYS: readonly string[] = ['dir', 'id', 'tag', 'changelog', 'npm', 'githubRelease', 'tsconfig']
37
+ export const UNIT_DECL_KEYS: readonly string[] = ['dir', 'id', 'tag', 'changelog', 'npm', 'githubRelease', 'tsconfig', 'tests']
35
38
 
36
39
  /** a unit, fully resolved: no optionals, no defaults left to re-apply downstream */
37
40
  export type ReleaseUnit = {
@@ -27,6 +27,7 @@ import type { HooksConfig } from './hooks/run-hooks.ts'
27
27
  import type { ReleaseConfig } from './release/run-release.ts'
28
28
  import { UNIT_DECL_KEYS, type ReleaseUnitDecl } from './release/units.ts'
29
29
  import { TASK_DEF_KEYS, type TaskDef } from './core/task-runner.ts'
30
+ import { testsConfigProblems, type TestsConfig } from './suite/judge-suites.ts'
30
31
  import type { TuiPanelDef } from './tui/tui-panel.ts'
31
32
  import type { MdGraph } from './wiki/md-graph.ts'
32
33
 
@@ -87,6 +88,14 @@ export type RepoConfig = {
87
88
  * rides into unrelated commits. `false` opts a repo out entirely, and
88
89
  * `--frozen` suppresses it for one run the way it suppresses SK005. */
89
90
  untrackGenerated?: boolean
91
+ /** this repo is ONE FOLDER of a larger git work tree, and shipkit owns that
92
+ * folder only: it never commits, untracks, tags or pushes on the parent's
93
+ * history. Rule SK015 fails when this and the disk disagree (core/nesting.ts) */
94
+ nested?: boolean
95
+ /** rule id → the reason this repo breaks that invariant on purpose. An
96
+ * exempted rule does not lint and prints a skip line carrying the reason.
97
+ * `defineRepo` throws on an unknown id or a blank reason */
98
+ ruleExempt?: Record<string, string>
90
99
  /** declared github visibility INTENT (absent = 'private') — rule SK007
91
100
  * fails loud when `gh repo view` disagrees in either direction */
92
101
  visibility?: 'private' | 'public'
@@ -148,6 +157,10 @@ export type RepoConfig = {
148
157
  * visits after a deploy, and how it logs in. The password is a secret
149
158
  * NAME: the value never sits in config, argv or env. smoke/run-smoke.ts */
150
159
  smoke?: SmokeConfig
160
+ /** the suite budget for a plain `bun test` floor: per suite time limit, the
161
+ * slow suites the quick tier leaves out, and the suites that cannot run in
162
+ * parallel, each with its reason. suite/judge-suites.ts */
163
+ tests?: TestsConfig
151
164
  /** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
152
165
  tui?: { panels?: TuiPanelDef[] }
153
166
  /** the `shipkit serve` hub: port + optional graph hooks */
@@ -155,7 +168,7 @@ export type RepoConfig = {
155
168
  logDir?: string
156
169
  }
157
170
 
158
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke'])
171
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke', 'tests'])
159
172
 
160
173
  export function defineRepo(c: RepoConfig): RepoConfig {
161
174
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -175,6 +188,8 @@ export function defineRepo(c: RepoConfig): RepoConfig {
175
188
  if (typeof u.dir !== 'string' || u.dir === '') throw new Error(`defineRepo: every units[] entry needs a 'dir'`)
176
189
  for (const k of Object.keys(u))
177
190
  if (!UNIT_DECL_KEYS.includes(k)) throw new Error(`defineRepo: units['${u.dir}'] unknown key '${k}' (known: ${UNIT_DECL_KEYS.join(', ')})`)
191
+ const problems = u.tests != null ? testsConfigProblems(u.tests) : []
192
+ if (problems.length > 0) throw new Error(`defineRepo: units['${u.dir}'] ${problems[0]}`)
178
193
  }
179
194
  for (const d of c.notReleasable ?? []) {
180
195
  if (typeof d !== 'string' || d === '') throw new Error(`defineRepo: every notReleasable entry is a repo-relative dir path`)
@@ -195,12 +210,30 @@ export function defineRepo(c: RepoConfig): RepoConfig {
195
210
  if (!(TASK_DEF_KEYS as readonly string[]).includes(f))
196
211
  throw new Error(`defineRepo: defaults.${k}.${f} is not a task field (fields: ${TASK_DEF_KEYS.join(', ')})`)
197
212
  }
213
+ // same promise for a repo task: repo.config.ts is often outside every
214
+ // tsconfig, so `reads` for `inputs` is no type error and the task never caches
215
+ for (const t of c.tasks ?? [])
216
+ for (const f of Object.keys(t))
217
+ if (!(TASK_DEF_KEYS as readonly string[]).includes(f))
218
+ throw new Error(`defineRepo: tasks['${t.id}'].${f} is not a task field (fields: ${TASK_DEF_KEYS.join(', ')})`)
219
+ // a slow or serial suite carries its case, or the declaration does not load
220
+ if (c.tests != null) {
221
+ const problems = testsConfigProblems(c.tests)
222
+ if (problems.length > 0) throw new Error(`defineRepo: ${problems[0]}`)
223
+ }
198
224
  for (const n of c.oneWayIn?.exempt ?? [])
199
225
  if (!(ROUTED_SCRIPTS as readonly string[]).includes(n))
200
226
  throw new Error(`defineRepo: oneWayIn.exempt '${n}' is not a governed script name (governed: ${ROUTED_SCRIPTS.join(', ')})`)
201
227
  // repo rules are validated against the built-ins, so a bad id, a duplicate,
202
228
  // or an SK squatter dies at config load, not silently mid-check
203
229
  if (c.rules != null) validateRules(c.rules, { label: 'defineRepo: rules', taken: BUILTIN_RULE_IDS })
230
+ // an exemption silences a gate, so it names a rule that exists and says why
231
+ for (const [id, reason] of Object.entries(c.ruleExempt ?? {})) {
232
+ if (!BUILTIN_RULE_IDS.includes(id) && !(c.rules ?? []).some((r) => r.id === id))
233
+ throw new Error(`defineRepo: ruleExempt.${id} names no rule, the ids are in \`shipkit rules\``)
234
+ if (typeof reason !== 'string' || reason.trim() === '')
235
+ throw new Error(`defineRepo: ruleExempt.${id} needs a reason, an exemption nobody can explain is a silenced gate`)
236
+ }
204
237
  // a smoke typo would land as a run that visits nothing and exits 0
205
238
  if (c.smoke != null) validateSmokeConfig(c.smoke)
206
239
  // a leak-guard waiver is the one config row that can make a gate quieter,
@@ -14,6 +14,7 @@
14
14
  // erroring, unreadable answer) is a loud skip — an UNKNOWN, never red,
15
15
  // never cached.
16
16
  import { carveGhJson, firstGhLine } from '../git-bootstrap.ts'
17
+ import { nestedIn } from '../core/nesting.ts'
17
18
  import type { Rule } from './rule.ts'
18
19
 
19
20
  export const githubVisibility: Rule = {
@@ -30,6 +31,9 @@ export const githubVisibility: Rule = {
30
31
  const declared = env.config.visibility ?? 'private'
31
32
  const skip = (why: string): void => void t.lines.push({ kind: 'skip', text: `github visibility unverified — ${why} (declared: ${declared})` })
32
33
  const firstLine = firstGhLine
34
+ const nested = nestedIn(env.root)
35
+ if (nested != null)
36
+ return void t.lines.push({ kind: 'skip', text: `github visibility unverified, nested in ${nested}: the remote belongs to the parent repo (declared: ${declared})` })
33
37
 
34
38
  const remotes = await t.exec(['git', 'remote'], { cwd: env.root })
35
39
  if (remotes.code !== 0) return skip(`git says: ${firstLine(remotes.out) || `exit ${remotes.code}`}`)
@@ -11,6 +11,7 @@
11
11
  import { readFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import { hasAiAttribution, hasDashPunctuation } from '../hooks/commit-message.ts'
14
+ import { nestedIn } from '../core/nesting.ts'
14
15
  import type { Rule } from './rule.ts'
15
16
 
16
17
  // the documented silencer: .claude/settings.json attribution.commit/.pr both
@@ -33,6 +34,9 @@ export const commitAiTells: Rule = {
33
34
  // work-tree content, so nothing a scan can produce describes this answer
34
35
  never: 'reads the full commit history and every ref name, neither of which is work-tree content',
35
36
  lint: async (t, env) => {
37
+ const nested = nestedIn(env.root)
38
+ if (nested != null)
39
+ return void t.lines.push({ kind: 'skip', text: `commit history not scanned, nested in ${nested}: that history belongs to the parent repo` })
36
40
  const declared = env.config.visibility ?? 'private'
37
41
  if (declared !== 'public')
38
42
  return t.ok(`declared ${declared} — the no-AI-attribution ban still applies from commit one (identity/how-we-commit.md); the full-history scan arms at visibility: 'public'`)
@@ -0,0 +1,29 @@
1
+ // SK015 nested repo (build/shipkit.md): a repo.config.ts inside a larger git
2
+ // work tree declares `nested: true`, and a work tree root never does. The call
3
+ // sites that write history refuse on the disk fact either way (core/nesting.ts),
4
+ // so this rule is about the declaration telling the truth to the next reader.
5
+ import { nestedIn } from '../core/nesting.ts'
6
+ import type { Rule } from './rule.ts'
7
+
8
+ export const nestedRepo: Rule = {
9
+ id: 'SK015',
10
+ blocking: true,
11
+ title: 'nested repo',
12
+ owner: 'build/shipkit.md',
13
+ never: 'reads where the git work tree root sits, which no content inside the repo describes',
14
+ lint: (t, env) => {
15
+ const top = nestedIn(env.root)
16
+ const declared = env.config.nested === true
17
+ if (top != null && !declared) {
18
+ t.fail(`repo.config.ts sits inside the git repo at ${top} and does not declare it`)
19
+ t.lines.push({ kind: 'fix-fail', text: 'add `nested: true` to repo.config.ts: shipkit then owns this folder only, and never writes the parent history' })
20
+ return
21
+ }
22
+ if (top == null && declared) {
23
+ t.fail('repo.config.ts declares `nested: true`, but this folder is its own git work tree root (or sits outside git)')
24
+ t.lines.push({ kind: 'fix-fail', text: 'delete `nested: true` from repo.config.ts' })
25
+ return
26
+ }
27
+ t.ok(top == null ? 'not nested, and not declared nested' : `nested in ${top}, declared`)
28
+ },
29
+ }
@@ -17,6 +17,7 @@ import { siblingUnitRanges } from './SK011-sibling-unit-ranges.ts'
17
17
  import { configBarrelImport } from './SK012-config-barrel-import.ts'
18
18
  import { oneWayInPolicy } from './SK013-one-way-in-policy.ts'
19
19
  import { proseSte } from './SK014-prose-ste.ts'
20
+ import { nestedRepo } from './SK015-nested-repo.ts'
20
21
 
21
22
  export const RULES: readonly Rule[] = [
22
23
  packageJsonShape,
@@ -33,6 +34,7 @@ export const RULES: readonly Rule[] = [
33
34
  configBarrelImport,
34
35
  oneWayInPolicy,
35
36
  proseSte,
37
+ nestedRepo,
36
38
  ]
37
39
 
38
40
  // THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
package/src/rules/rule.ts CHANGED
@@ -60,11 +60,24 @@ export type Rule = {
60
60
  fix?: (env: RuleEnv) => FixResult | Promise<FixResult>
61
61
  }
62
62
 
63
+ /** the config part of a rule's cache identity */
64
+ export function ruleCacheExtra(rule: Rule, env: RuleEnv): string | undefined {
65
+ const key = rule.cacheKey?.(env)
66
+ const exempt = env.config.ruleExempt?.[rule.id]
67
+ return exempt == null ? key : `${key ?? ''}|exempt:${exempt}`
68
+ }
69
+
63
70
  // THE one way to run a rule's lint (check suite + the --fix runner): a
64
71
  // throwing lint still fails with its id, a failing/warning run always
65
72
  // carries at least one fix: pointer (the lint's own precise ones win, else
66
73
  // docsBase + the rule's owner page), and every line gets tagged.
67
74
  export async function runRuleLint(rule: Rule, t: CheckCtx, env: RuleEnv): Promise<void> {
75
+ const exempt = env.config.ruleExempt?.[rule.id]
76
+ if (exempt != null) {
77
+ t.lines.push({ kind: 'skip', text: `${rule.id} exempt: ${exempt}` })
78
+ t.lines = tagRuleLines(rule, t.lines)
79
+ return
80
+ }
68
81
  try {
69
82
  await rule.lint(t, env)
70
83
  } catch (e) {
@@ -40,6 +40,12 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
40
40
  for (const rule of targets) {
41
41
  const before = new CheckCtx(new Map())
42
42
  await runRuleLint(rule, before, env)
43
+ // an exempted rule never autofixes: the repo broke that invariant on purpose
44
+ const exempt = config.ruleExempt?.[rule.id]
45
+ if (exempt != null) {
46
+ if (o.quiet !== true) print(`skip: [${rule.id}] ${rule.id} exempt: ${exempt}`)
47
+ continue
48
+ }
43
49
  if (!before.failed && !before.warned) {
44
50
  // inside the ci fix pass, a line per already-clean rule is a wall of
45
51
  // nothing above the run that reports those same rules for real
@@ -0,0 +1,147 @@
1
+ // the suite budget, as pure functions: which script is a plain `bun test`, how a
2
+ // run splits into a parallel and a serial phase, and which suites broke the
3
+ // budget. suite-cli.ts spawns, this module decides (meta/rv-conventions/test-speed.md).
4
+ import { isAbsolute, posix } from 'path'
5
+
6
+ export type SlowSuite = {
7
+ file: string
8
+ /** what this suite catches that no fast test can */
9
+ catches: string
10
+ /** why it cannot be made fast (smaller fixture, pure logic extracted) */
11
+ whyNotFast: string
12
+ }
13
+ export type SerialSuite = {
14
+ file: string
15
+ /** what breaks when it shares a run with other suites */
16
+ why: string
17
+ }
18
+ export type TestsConfig = {
19
+ /** per suite wall time in the quick tier (default 2000). A suite over it under the parallel run is timed again alone, and only that timing fails it. The full tier fails nothing on time */
20
+ budgetMs?: number
21
+ /** suites that break the budget on purpose: the full tier runs them, the quick tier does not */
22
+ slow?: SlowSuite[]
23
+ /** suites that cannot share a run: they run one at a time after the parallel phase */
24
+ serial?: SerialSuite[]
25
+ }
26
+
27
+ export const DEFAULT_BUDGET_MS = 2000
28
+ const TESTS_KEYS = ['budgetMs', 'slow', 'serial']
29
+ const SLOW_KEYS = ['file', 'catches', 'whyNotFast']
30
+ const SERIAL_KEYS = ['file', 'why']
31
+ const OWN_FLAGS = ['--budget-ms', '--slow', '--serial', '--declared-in']
32
+
33
+ export const normSuitePath = (file: string): string => posix.normalize(file.replace(/\\/g, '/')).replace(/^\.\//, '')
34
+
35
+ /** the arguments after `bun test`, or null when a shell would interpret the script
36
+ * (cd, env prefix, pipes, quotes, substitution): that body stays the repo's own */
37
+ export function parseBunTestScript(script: string): string[] | null {
38
+ const s = script.trim()
39
+ if (!/^bun test(\s|$)/.test(s)) return null
40
+ // `=` stays allowed: after `bun test` it only ever means `--flag=value`
41
+ if (/[&|;<>$`'"\\(){}*?!#~]/.test(s.slice('bun test'.length))) return null
42
+ return s.split(/\s+/).slice(2)
43
+ }
44
+
45
+ /** the flags the floor hands suite-cli.ts. Only file names travel: a reason edit
46
+ * must not move the task's cache key */
47
+ export function suiteFlags(t: TestsConfig | undefined): string[] {
48
+ if (t == null) return []
49
+ return [
50
+ ...(t.budgetMs != null ? ['--budget-ms', String(t.budgetMs)] : []),
51
+ ...(t.slow ?? []).flatMap((s) => ['--slow', s.file]),
52
+ ...(t.serial ?? []).flatMap((s) => ['--serial', s.file]),
53
+ ]
54
+ }
55
+
56
+ /** the inverse of suiteFlags, plus where the declarations live (printed in
57
+ * every fix line) and the `bun test` arguments after `--` */
58
+ export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs: string[]; declaredIn: string } | string {
59
+ const tests: Required<TestsConfig> = { budgetMs: DEFAULT_BUDGET_MS, slow: [], serial: [] }
60
+ let declaredIn = 'repo.config.ts tests'
61
+ // the runner's own flags end at `--` OR at the first token that is not one
62
+ // of them: bun swallows a LEADING `--`, so a repo with no declaration hands
63
+ // over the `bun test` arguments with no separator at all
64
+ let i = 0
65
+ for (; i < argv.length; i += 2) {
66
+ const flag = argv[i]!
67
+ if (flag === '--') { i++; break }
68
+ if (!OWN_FLAGS.includes(flag)) break
69
+ const value = argv[i + 1]
70
+ if (value == null || value === '--') return `${flag} needs a value`
71
+ if (flag === '--budget-ms') tests.budgetMs = Number(value)
72
+ else if (flag === '--slow') tests.slow.push({ file: value, catches: '(declared)', whyNotFast: '(declared)' })
73
+ else if (flag === '--serial') tests.serial.push({ file: value, why: '(declared)' })
74
+ else declaredIn = value
75
+ }
76
+ if (!(tests.budgetMs > 0)) return '--budget-ms must be a positive number'
77
+ return { tests, rawArgs: argv.slice(i), declaredIn }
78
+ }
79
+
80
+ /** what `bun test` receives in each phase. The serial phase names its files
81
+ * explicitly, because bun reports no tests when a path is both named and ignored */
82
+ export function planPhases(p: { tests: TestsConfig; tier: 'quick' | 'full'; rawArgs: string[]; parallel: boolean }): { parallel: string[]; serial: string[] | null } {
83
+ const slow = (p.tests.slow ?? []).map((s) => normSuitePath(s.file))
84
+ const serial = (p.tests.serial ?? []).map((s) => normSuitePath(s.file))
85
+ const ignored = [...new Set([...(p.tier === 'quick' ? slow : []), ...serial])]
86
+ const serialRun = serial.filter((f) => p.tier === 'full' || !slow.includes(f))
87
+ return {
88
+ parallel: [...p.rawArgs, ...(p.parallel ? ['--parallel'] : []), ...ignored.map((f) => `--path-ignore-patterns=${f}`)],
89
+ serial: serialRun.length > 0 ? [...p.rawArgs, ...serialRun] : null,
90
+ }
91
+ }
92
+
93
+ type Timed = { file: string; ms: number }
94
+
95
+ export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full' }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
96
+ const budget = p.tests.budgetMs ?? DEFAULT_BUDGET_MS
97
+ const slow = new Set((p.tests.slow ?? []).map((s) => normSuitePath(s.file)))
98
+ const timed = Object.entries(p.timings).map(([file, ms]) => ({ file: normSuitePath(file), ms })).sort((a, b) => b.ms - a.ms)
99
+ return {
100
+ // the budget guards the tier every edit waits on. The full tier is
101
+ // where slow suites belong, and it only reports
102
+ over: p.tier === 'quick' ? timed.filter((t) => t.ms > budget && !slow.has(t.file)) : [],
103
+ // half the budget, so a declaration does not flap on timing noise
104
+ noLongerSlow: timed.filter((t) => slow.has(t.file) && t.ms < budget / 2),
105
+ slowest: timed.slice(0, 3),
106
+ }
107
+ }
108
+
109
+ /** what stays over budget once each suspect ran alone: a parallel timing
110
+ * carries the load of every other suite and every other gate on the machine,
111
+ * so only the alone timing can fail a suite */
112
+ export function confirmOver(over: Timed[], alone: Record<string, number>, budgetMs: number): Timed[] {
113
+ const byFile = new Map(Object.entries(alone).map(([f, ms]) => [normSuitePath(f), ms]))
114
+ return over.map((t) => ({ file: t.file, ms: byFile.get(t.file) ?? t.ms })).filter((t) => t.ms > budgetMs)
115
+ }
116
+
117
+ /** the suites bun names as a crashed parallel worker */
118
+ export function crashedWorkers(output: string): { file: string; signal: string }[] {
119
+ return [...output.matchAll(/^\s*✗\s+(\S+)\s+\(worker crashed: (SIG[A-Z0-9]+)\)\s*$/gm)].map((m) => ({ file: normSuitePath(m[1]!), signal: m[2]! }))
120
+ }
121
+
122
+ export function testsConfigProblems(t: TestsConfig): string[] {
123
+ const problems: string[] = []
124
+ for (const k of Object.keys(t)) if (!TESTS_KEYS.includes(k)) problems.push(`tests.${k} is not a tests field (fields: ${TESTS_KEYS.join(', ')})`)
125
+ if (t.budgetMs != null && !(typeof t.budgetMs === 'number' && t.budgetMs > 0)) problems.push('tests.budgetMs must be a positive number of milliseconds')
126
+ const lists: [string, { file: string }[], string[], [string, string][]][] = [
127
+ ['slow', t.slow ?? [], SLOW_KEYS, [['catches', 'what it catches that no fast test can'], ['whyNotFast', 'why it cannot be made fast']]],
128
+ ['serial', t.serial ?? [], SERIAL_KEYS, [['why', 'what breaks when it shares a run']]],
129
+ ]
130
+ for (const [name, entries, keys, reasons] of lists) {
131
+ const seen = new Set<string>()
132
+ for (const e of entries) {
133
+ const file = typeof e.file === 'string' ? e.file.trim() : ''
134
+ if (file === '') { problems.push(`tests.${name} has an entry with no file`); continue }
135
+ const rel = normSuitePath(file)
136
+ if (isAbsolute(file) || rel === '..' || rel.startsWith('../')) problems.push(`tests.${name} ${file} must be a path inside the repo`)
137
+ if (seen.has(rel)) problems.push(`tests.${name} names ${rel} twice`)
138
+ seen.add(rel)
139
+ for (const k of Object.keys(e)) if (!keys.includes(k)) problems.push(`tests.${name} ${rel}: ${k} is not a field (fields: ${keys.join(', ')})`)
140
+ for (const [k, says] of reasons) {
141
+ const v = (e as Record<string, unknown>)[k]
142
+ if (typeof v !== 'string' || v.trim() === '') problems.push(`tests.${name} ${rel} needs ${k}: ${says}`)
143
+ }
144
+ }
145
+ }
146
+ return problems
147
+ }
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env bun
2
+ // the floor `test` slot for a plain `bun test` script: quick tier suites in
3
+ // parallel workers, the full tier one at a time, the declared serial ones after,
4
+ // and each quick tier suite timed against the budget.
5
+ // The LAST stdout line is the task headline the gate prints, and it keeps bun's
6
+ // `Ran N tests` so the gate can still report what ran.
7
+ //
8
+ // usage: suite-cli [--budget-ms N] [--slow file]... [--serial file]... [--declared-in <where>] -- <bun test args>
9
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
10
+ import { tmpdir } from 'os'
11
+ import { join } from 'path'
12
+ import { FULL_TESTS_ENV } from '../ledger/gate-units.ts'
13
+ import { confirmOver, crashedWorkers, DEFAULT_BUDGET_MS, judgeSuites, normSuitePath, parseSuiteFlags, planPhases } from './judge-suites.ts'
14
+
15
+ type Phase = { code: number; signal: string | null; out: string; timings: Record<string, number> }
16
+
17
+ async function runPhase(args: string[], timingsFile: string | null, echo = true): Promise<Phase> {
18
+ const timing = timingsFile != null ? [`--timings=${timingsFile}`, '--update-timings'] : []
19
+ const proc = Bun.spawn(['bun', 'test', ...args, ...timing], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' })
20
+ let out = ''
21
+ const pump = async (stream: ReadableStream<Uint8Array>, sink: NodeJS.WriteStream): Promise<void> => {
22
+ const dec = new TextDecoder()
23
+ for await (const chunk of stream) {
24
+ const text = dec.decode(chunk)
25
+ out += text
26
+ if (echo) sink.write(text)
27
+ }
28
+ }
29
+ await Promise.all([pump(proc.stdout, process.stdout), pump(proc.stderr, process.stderr)])
30
+ const code = await proc.exited
31
+ let timings: Record<string, number> = {}
32
+ try { if (timingsFile != null && existsSync(timingsFile)) timings = (JSON.parse(readFileSync(timingsFile, 'utf8')) as { files?: Record<string, number> }).files ?? {} }
33
+ catch { timings = {} }
34
+ return { code, signal: proc.signalCode ?? null, out, timings }
35
+ }
36
+
37
+ const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
38
+ const lastCount = (out: string, re: RegExp): number => Number([...out.matchAll(re)].at(-1)?.[1] ?? 0)
39
+
40
+ export async function suiteCli(argv: string[]): Promise<number> {
41
+ const parsed = parseSuiteFlags(argv)
42
+ if (typeof parsed === 'string') { console.log(`FAIL: suite runner: ${parsed}`); return 2 }
43
+ const { tests, rawArgs, declaredIn } = parsed
44
+ const tier = process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick'
45
+ const budget = tests.budgetMs ?? DEFAULT_BUDGET_MS
46
+
47
+ // a declaration naming a gone suite would silently stop excluding anything
48
+ const gone = [...(tests.slow ?? []).map((s) => ['slow', s.file]), ...(tests.serial ?? []).map((s) => ['serial', s.file])]
49
+ .filter(([, f]) => !existsSync(f!))
50
+ if (gone.length > 0) {
51
+ for (const [list, f] of gone) console.log(`FAIL: ${declaredIn}.${list} names ${normSuitePath(f!)}, which does not exist`)
52
+ return 1
53
+ }
54
+
55
+ const help = Bun.spawnSync(['bun', 'test', '--help'], { stdout: 'pipe', stderr: 'pipe' })
56
+ const helpText = `${help.stdout.toString()}${help.stderr.toString()}`
57
+ // the full tier runs one suite at a time: its suites spawn git and whole
58
+ // gates, and sharing a run measured slower than sequential and timed out
59
+ const parallel = helpText.includes('--parallel') && tier === 'quick'
60
+ const timed = helpText.includes('--update-timings')
61
+ const plan = planPhases({ tests, tier, rawArgs, parallel })
62
+ const dir = mkdtempSync(join(tmpdir(), 'shipkit-suite-'))
63
+ const notes: string[] = []
64
+ if (!helpText.includes('--parallel')) notes.push(`skip: parallel suites (bun ${Bun.version} has no --parallel)`)
65
+ if (!timed) notes.push(`skip: suite budget (bun ${Bun.version} writes no --timings)`)
66
+ try {
67
+ const p1 = await runPhase(plan.parallel, timed ? join(dir, 'parallel.json') : null)
68
+ const p2 = plan.serial != null ? await runPhase(plan.serial, timed ? join(dir, 'serial.json') : null) : null
69
+ const phases = [p1, ...(p2 != null ? [p2] : [])]
70
+ const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
71
+ const failedTests = phases.reduce((n, p) => n + lastCount(p.out, /^\s*(\d+) fail$/gm), 0)
72
+ const timings = Object.assign({}, ...phases.map((p) => p.timings)) as Record<string, number>
73
+ const judged = judgeSuites({ timings, tests, tier })
74
+ const slowest = judged.slowest.length > 0 ? ` (slowest: ${judged.slowest.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')})` : ''
75
+
76
+ // a parallel timing carries every other suite's load, so a suspect is
77
+ // timed again alone, one process, before it can fail the task
78
+ let over = judged.over
79
+ if (over.length > 0) {
80
+ const alone = await runPhase([...rawArgs, ...over.map((t) => t.file)], join(dir, 'alone.json'), false)
81
+ over = confirmOver(over, alone.timings, budget)
82
+ }
83
+
84
+ const killed = crashedWorkers(p1.out).map((c) => `killed by ${c.signal} while running ${c.file} (out of memory, or killed from outside)`)
85
+ if (p1.signal != null) killed.push(`killed by ${p1.signal} during the parallel run (out of memory, or killed from outside)`)
86
+ if (p2?.signal != null) {
87
+ const unfinished = (plan.serial ?? []).filter((a) => !a.startsWith('-') && !rawArgs.includes(a) && p2.timings[normSuitePath(a)] == null)
88
+ killed.push(`killed by ${p2.signal} while running ${unfinished.join(' or ')} (out of memory, or killed from outside)`)
89
+ }
90
+ // never blocking: a suite that got faster must not stop a release
91
+ const stale = judged.noLongerSlow.map((t) => `WARN ${declaredIn}.slow declares ${t.file}, which ran in ${secs(t.ms)}, under half the ${budget}ms budget: remove the declaration`)
92
+
93
+ for (const n of notes) console.log(n)
94
+ for (const s of stale) console.log(s)
95
+ const fails: string[] = [
96
+ ...killed.map((k) => `FAIL: ${k}`),
97
+ ...(failedTests > 0 || (phases.some((p) => p.code !== 0 && p.signal == null) && killed.length === 0)
98
+ ? [`FAIL: ${failedTests} test(s) failed, Ran ${ran} tests${slowest}`] : []),
99
+ ...(over.length > 0
100
+ ? [`FAIL: ${over.length} suite(s) over the ${budget}ms budget: ${over.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')} (timed alone). Make each fast, or declare it in ${declaredIn}.slow with what it catches and why it cannot be fast`]
101
+ : []),
102
+ ]
103
+ // the most actionable failure prints LAST, where the gate reads its headline
104
+ for (const f of fails.slice(1).reverse()) console.log(f)
105
+ if (fails.length > 0) { console.log(fails[0]); return 1 }
106
+ const serialCount = plan.serial != null ? plan.serial.length - rawArgs.length : 0
107
+ console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites, ${parallel ? 'parallel' : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier${slowest}`)
108
+ return 0
109
+ } finally {
110
+ rmSync(dir, { recursive: true, force: true })
111
+ }
112
+ }
113
+
114
+ if (import.meta.main) process.exit(await suiteCli(process.argv.slice(2)))