rvlib-shipkit 0.37.0 → 0.38.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.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,24 @@
1
- # Changelog
1
+ ## 0.38.0
2
+
3
+ - **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.
4
+
5
+ - **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.
6
+
7
+ - **`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.
8
+
9
+ - **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.
10
+
11
+ ## 0.37.1
12
+
13
+ - **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.
14
+
15
+ - **The gate says what an undeclared floor costs, and declares nothing on a repo's behalf.** A slot with no `inputs`, no `ttl` and no `never` is measured every run, and `resolveFloor` declares none for any of the five, so a second gate over an UNCHANGED tree re-runs everything: 17.4s per gate measured on a field repo, 14.8s of it the test task, against the shape an agent actually runs (verify, commit, verify again). Nothing pointed at the repair either, since `shipkit init` scaffolded no `defaults` block, so a fresh repo stayed that way until somebody read `shipkit keys --burn-down` and knew what to write. `init` now scaffolds the commented `defaults` line naming the fix.
16
+ - **What this release deliberately does NOT do is auto-declare `['.']` for those slots**, and the reason is worth more than the speed. `['.']` is not the work tree, it is the TRACKED tree: `git-scan` force-adds only a path a declaration NAMES, so an ignored path enters a key by NAME and never by content. Every floor task reads ignored content in an ordinary repo, a suite through its fixtures and its local database, tsc and the two graph walks through generated types and `node_modules`. Measured on a scratch repo: warm the ledger with two green runs, break a gitignored file the suite reads, and the third gate prints `ok: test (replayed, 0.0s saved)` over a suite that exits 1. `tests/floor-cache-safety.test.ts` holds that repro, watched fail, with the declaring lookalike beside it still replaying. Speed here is bought by a REPO declaring what its own tasks read, which is the only declaration with a real hit rate anyway.
17
+ - Two doc sentences that argued the other way are corrected where they live: `TaskDef.inputs` no longer says `['.']` "cannot under declare", and the `git-key` header no longer promises the never-cached default is the whole safety story without naming who can declare on a unit's behalf.
18
+
19
+ - **SK004 asks git what is ignored, instead of trusting the text it wrote.** The rule compared the managed block to the expected string and stopped there, so it reported `current` over a repo where git ignored none of what the block claims. Two ways that happens, both from the field. A bare `printf 'work/\n' >> .gitignore` onto a file whose last line carried no terminator fuses the two into `sourcework/`, which ignores neither path, and the file ends with a newline afterwards so nothing textual looks wrong (cost in that repo: 1.1GB and 700 screenshots pushed across four commits, a rejected push, and a `git filter-repo` rewrite). And a negation below the block is the DOCUMENTED override, so nothing stopped one landing on `.rv-private/`, the directory holding the keyword list the leak guard itself reads. Every policy pattern is now probed through one `git check-ignore --stdin` call and a contradiction FAILs, naming the pattern and the `check-ignore -v` that reports the winning line. The three venue folders stay exempt, because unignoring one is how a live listing ships. A `.gitignore` that does not end with a newline is now a FAIL of its own, since that missing byte is the trap rather than a cosmetic defect, and `--fix` terminates the file even when the block is current. Outside a git repo the rule states the block went unverified rather than reporting a green nothing measured.
20
+
21
+ - **A check declaration the gate's own formatter wrote was unreadable to the gate.** `BLOCKING_RE` and `GATE_RE` both ended in `\s*$`, and oxfmt (which the fix pass RUNS) terminates every statement with a semicolon. So `shipkit ci` reformatted `export const blocking = false` into `export const blocking = false;`, then failed the file with `does not declare whether it blocks` and printed as the fix the exact line it had just written. Two repos in the field shipped `// oxfmt-ignore` workarounds to get a green gate. The worse half was silent: a `export const gate = false;` was not read either, so a world probe that had opted out of the gate quietly re-entered it, and a repo could declare fifteen off-gate probes and watch all fifteen run. Both readers now accept an optional `as const` and an optional semicolon through one shared tail. `INPUTS_RE`, `TTL_RE` and `NEVER_RE` never anchored the line end, so they were never affected. Pinned by three tests: the formatted shapes, the gate opt-out in both spellings, and a CONTROL that the unterminated shapes and a silent file still read as before.
2
22
 
3
23
  ## 0.37.0
4
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
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
@@ -42,7 +42,11 @@ export type DiscoveredCheck = {
42
42
 
43
43
  // read without importing: checks run as subprocesses, and discovery must stay
44
44
  // pure-fs. `export const blocking = true` (or false), nothing cleverer.
45
- const BLOCKING_RE = /^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)\s*$/m
45
+ // the tail is the gate's OWN formatter: oxfmt terminates every statement, and
46
+ // the fix pass runs it, so a reader that refuses a semicolon refuses the shape
47
+ // the gate itself just wrote.
48
+ const TAIL = String.raw`(?:\s+as\s+const)?\s*;?\s*$`
49
+ const BLOCKING_RE = new RegExp(String.raw`^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)` + TAIL, 'm')
46
50
  // `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
47
51
  // (a computed list, a spread) is deliberately unreadable here and lands as no
48
52
  // declaration, which means never cached: a scanner guessing at a program is
@@ -53,7 +57,7 @@ const NEVER_RE = /^\s*export\s+const\s+never\s*(?::\s*string\s*)?=\s*['"]([^'"]+
53
57
  // `export const gate = false` takes a check out of `shipkit ci`. Only the
54
58
  // literal false opts out: anything unreadable stays in the gate, because a
55
59
  // scanner that guesses its way to SKIPPING a check is how coverage disappears
56
- const GATE_RE = /^\s*export\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*false\s*$/m
60
+ const GATE_RE = new RegExp(String.raw`^\s*export\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*false` + TAIL, 'm')
57
61
  const STRING_ITEM_RE = /['"]([^'"]+)['"]/g
58
62
 
59
63
  export function discoverChecks(root: string): DiscoveredCheck[] {
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
@@ -68,6 +84,24 @@ function builtinFloorTask(id: string, root: string): { task: TaskDef; source: Fl
68
84
  return `no package.json '${id}' script`
69
85
  }
70
86
 
87
+ // NOTHING here declares a read set on a repo's behalf, and that is a decision
88
+ // rather than an omission. A floor slot with no `inputs` is measured every run,
89
+ // which costs speed and never correctness (ledger/git-key.ts owns that default).
90
+ //
91
+ // The tempting shortcut is `['.']`, on the reasoning that the whole tree cannot
92
+ // under declare. It can. `['.']` is the TRACKED tree: git-scan force-adds only a
93
+ // path a declaration NAMES, so an ignored path enters the key by name and never
94
+ // by content. Every floor slot reads ignored content in an ordinary repo, a
95
+ // suite through its fixtures and a local database, tsc and the graph walks
96
+ // through generated types and node_modules. Shipped for one day, that made the
97
+ // gate print `ok: test (replayed)` over a suite that exits 1
98
+ // (tests/floor-cache-safety.test.ts holds the repro).
99
+ //
100
+ // Speed here is bought by a REPO saying what its own tasks read, in
101
+ // `defaults`, which is the only declaration with a real hit rate anyway.
102
+ // `shipkit init` scaffolds the line, `shipkit keys --burn-down` names what is
103
+ // still undeclared, and the gate prints the cost of it once per run.
104
+
71
105
  export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
72
106
  const pkgPath = join(root, 'package.json')
73
107
  let scripts: Record<string, string> = {}
@@ -81,6 +115,7 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
81
115
  const disabled: string[] = []
82
116
  const unfilled: { id: string; reason: string }[] = []
83
117
  const sources: Record<string, FloorSource> = {}
118
+ const notes: string[] = []
84
119
  for (const id of FLOOR_IDS) {
85
120
  const override = c.defaults?.[id]
86
121
  if (override === false) { disabled.push(id); continue }
@@ -98,13 +133,13 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
98
133
  // routed script here would spawn the gate from inside itself, forever
99
134
  const decorate = (t: TaskDef): TaskDef => (override == null ? t : { ...t, ...override, id })
100
135
  const raw = scripts[rawName(id)]
101
- if (raw != null) { tasks.push(decorate({ id, cmd: ['bun', 'run', rawName(id)] })); sources[id] = 'package.json script'; continue }
102
- 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 }
103
138
  const b = builtinFloorTask(id, root)
104
139
  // a routed script with nothing behind it measures NOTHING, and the
105
140
  // reason must say that rather than `no '<id>' script`, which is false
106
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 })
107
142
  else { tasks.push(decorate(b.task)); sources[id] = b.source }
108
143
  }
109
- return { tasks, disabled, unfilled, sources }
144
+ return { tasks, disabled, unfilled, sources, notes }
110
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.
@@ -36,8 +36,12 @@ export type TaskDef = {
36
36
  * speed and never correctness. A list MISSING a path the command reads is
37
37
  * the one that costs correctness: the key cannot move when that path
38
38
  * changes, so the answer replays over a tree it no longer describes. Over
39
- * declaring costs a re-run, under declaring is a false green, and `['.']`
40
- * is the whole work tree and cannot under declare. A task carries no code
39
+ * declaring costs a re-run, under declaring is a false green. `['.']` is
40
+ * the whole TRACKED tree, and it is NOT a catch-all: an ignored path
41
+ * enters a key by name and never by content, so a task reading a
42
+ * gitignored fixture, a built dir or a .env under declares with `['.']`
43
+ * exactly as it would with a narrow list. A task that reads the network,
44
+ * a keychain or the clock declares `ttl`, never `inputs`. A task carries no code
41
45
  * closure, so the script it runs belongs in here too.
42
46
  * Declaring both this and `ttl` is a registration failure. */
43
47
  inputs?: string[]
@@ -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
  }
package/src/init-repo.ts CHANGED
@@ -29,10 +29,14 @@ export default defineRepo({
29
29
  venues: [], // e.g. ['itch', 'steam'], selects which .rv-<venue>/ contracts are REQUIRED
30
30
  social: [], // e.g. ['x', 'discord'], the .rv-social/<channel>/ post folders (+ .rv-discord/)
31
31
  tasks: [], // repo commit-gate tasks; deps double as race guards
32
+ // lint, typecheck, imports:check and cycles replay off the tracked tree.
33
+ // \`test\` measures every run until you say what it reads, because an
34
+ // ignored path (a fixture, a local db, a .env) is not in a tree key:
35
+ // defaults: { test: { inputs: ['tests', 'src', 'package.json'] } },
32
36
  // .comfy-ts/ holds schema dumps naming a private model collection.
33
37
  // gitignore (SK004) is advisory, this pre-commit gate is not
34
38
  hooks: { blockedPaths: ['.comfy-ts/'] },
35
- // the mandated reviewer agent type (rule SK008). Uncomment with YOUR
39
+ // the declared reviewer agent type (rule SK008). Uncomment with YOUR
36
40
  // standing brief, then \`shipkit check --fix SK008\` writes the agent file:
37
41
  // reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
38
42
  // health checks need no config. Drop a <name>.check.ts file anywhere,
@@ -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,
@@ -16,8 +16,11 @@
16
16
  // NEITHER declares nothing. Never cached. This is the DEFAULT, and it is
17
17
  // the whole safety story: forgetting to declare costs speed, never
18
18
  // correctness, so the burn-down list below is a to-do rather than a
19
- // hazard. Declaring BOTH is a registration failure, loud, because
20
- // the two answers contradict each other.
19
+ // hazard. It holds only while nothing declares ON a unit's behalf:
20
+ // the gate floor gives `['.']` to the four slots tracked source
21
+ // decides and deliberately leaves `test` here (core/floor.ts says
22
+ // why). Declaring BOTH is a registration failure, loud, because the
23
+ // two answers contradict each other.
21
24
  //
22
25
  // CROSS WORKTREE. Every component here is content addressed or a version
23
26
  // string; not one of them is an absolute path. Two worktrees of one repo
@@ -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,15 +88,23 @@ 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'
93
- /** the reviewer agent type this repo mandates: `.claude/agents/<name>.md`,
102
+ /** the reviewer agent type this repo declares: `.claude/agents/<name>.md`,
94
103
  * whose system prompt points at `brief` (an md file holding the standing
95
104
  * review framing — one home, shared across repos). `tools` overrides the
96
105
  * read-only allowlist SK008 enforces (default: Read, Grep, Glob, Bash).
97
106
  * Rule SK008 lints it, `check --fix SK008` writes it. Absent = no
98
- * mandated reviewer. */
107
+ * declared reviewer. */
99
108
  reviewer?: { name: string; brief: string; tools?: string[] }
100
109
  /** ONE WAY IN escape hatch (build/shipkit.md): governed script names this
101
110
  * repo keeps as a direct tool call. Declared per NAME, never as a list of
@@ -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,