rvlib-shipkit 0.34.0 → 0.35.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,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.1
4
+
5
+ - **SK013's green line claimed a replacement that could not be named.** With the guard declared but not yet installed, the rule WARNed correctly and then printed `closes 14/14 raw runner(s), with the replacement named` directly underneath it. Both lines were about the same file and only one of them was true. The summary now reports what the file actually delivers: the guard is `declared, not yet runnable` until its script resolves.
6
+
7
+ ## 0.35.0
8
+
9
+ - **`shipkit pb collection` mints imports of a package that exists.** The pocketbase data layer and the Mantine shell are one package now, `rvlib-pb`: the generated `columns.tsx`, `grid.tsx` and `index.ts` import `Column`, `LiveGrid` and `defineCollectionUI` from `rvlib-pb/ui`, and `LiveList` from `rvlib-pb`. The two tiers are different subpaths on purpose, so a generated grid that took its type from the ui subpath would typecheck and would put `@mantine/core` in the tsconfig of anything that reads it. This generator ships inside the tarball and writes into repos it never sees, so a stale specifier here keeps producing broken scaffolds long after a rename lands: the suite now asserts every import string the generator emits, which nothing did before.
10
+
3
11
  ## 0.34.0
4
12
 
5
13
  - **SK013's autofix was manufacturing the FAIL its own lint then raised.** It wired a PreToolUse hook at `node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts` whether or not that path existed, so any repo that runs shipkit through the global shim without installing the package came out of a repair pass permanently red. A machine-clearable red that a machine just created is a design bug. The hook is still declared in every case, so it starts working the moment the dependency lands, with no second fix run: what changed is the verdict. A guard under `node_modules/` that is not there yet is an uninstalled dependency, so it WARNs and names `bun install`, with the deny list standing as the whole floor meanwhile. A guard path the repo chose and got wrong still FAILs, because that one never resolves on its own.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.34.0",
3
+ "version": "0.35.1",
4
4
  "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",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli/cmd_Ci.ts CHANGED
@@ -16,6 +16,7 @@ export class CiCommand extends Command {
16
16
  ['leave repo config files alone', 'shipkit ci --no-fix-rules'],
17
17
  ['stop at the first failure', 'shipkit ci --bail'],
18
18
  ['one task + its dep closure', 'shipkit ci --only typecheck'],
19
+ ['re-measure everything, replay nothing', 'shipkit ci --fresh'],
19
20
  ['include an opt-in task', 'shipkit ci --with up:itch'],
20
21
  ],
21
22
  })
@@ -28,6 +29,7 @@ export class CiCommand extends Command {
28
29
  skip = Option.Array('--skip', [], { description: 'skip a task group (repeatable)' })
29
30
  withIds = Option.Array('--with', [], { description: 'include an opt-in task (repeatable)' })
30
31
  only = Option.Array('--only', [], { description: 'run only this task + its dep closure (repeatable)' })
32
+ fresh = Option.Boolean('--fresh', false, { description: 'measure everything: no unit replays from the ledger, and what this run measures is stored fresh' })
31
33
  full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
32
34
 
33
35
  async execute(): Promise<number> {
@@ -45,6 +47,7 @@ export class CiCommand extends Command {
45
47
  skipGroups: this.skip,
46
48
  withIds: this.withIds,
47
49
  onlyIds: this.only,
50
+ fresh: this.fresh,
48
51
  ...(this.full ? { tests: 'full' as const } : {}),
49
52
  print: (l) => this.context.stdout.write(`${l}\n`),
50
53
  })
@@ -8,7 +8,7 @@
8
8
  import { createHash } from 'crypto'
9
9
  import { Command, Option } from 'clipanion'
10
10
  import { loadRepoConfig } from '../repo-config.ts'
11
- import { gateUnits } from '../ledger/gate-units.ts'
11
+ import { envTier, FULL_TESTS_ENV, gateUnits, tieredTasks } from '../ledger/gate-units.ts'
12
12
  import { KeyLayer } from '../ledger/git-key.ts'
13
13
 
14
14
  export class KeysCommand extends Command {
@@ -22,6 +22,7 @@ export class KeysCommand extends Command {
22
22
  ['only the ones nothing can cache yet', 'shipkit keys --burn-down'],
23
23
  ['why this key is what it is', 'shipkit keys --why typecheck'],
24
24
  ['re-hash after the fact and report drift', 'shipkit keys --rescan'],
25
+ ['the keys a --full gate asks for', 'shipkit keys --full'],
25
26
  ['machine-readable', 'shipkit keys --json'],
26
27
  ],
27
28
  })
@@ -29,19 +30,24 @@ export class KeysCommand extends Command {
29
30
  json = Option.Boolean('--json', false, { description: 'machine-readable { units: UnitKey[], burnDown, errors, scanMs }' })
30
31
  burnDown = Option.Boolean('--burn-down', false, { description: 'list only the units nothing can cache yet, and why' })
31
32
  why = Option.String('--why', { required: false, description: 'print every component that went into ONE unit\'s key' })
33
+ full = Option.Boolean('--full', false, { description: `render the keys a FULL-tier run asks for (same as ${FULL_TESTS_ENV}=1). The tier is in every task's key` })
32
34
  rescan = Option.Boolean('--rescan', false, { description: 'run the post-run re-hash and name every input that moved since the scan' })
33
35
 
34
36
  async execute(): Promise<number> {
35
37
  const root = process.cwd()
36
38
  const c = await loadRepoConfig(root)
37
- const decls = gateUnits(root, c)
39
+ // the TIER the gate would run, applied here too: `--full` rewrites
40
+ // every task's env, which is in the key, so a surface that renders keys
41
+ // without it renders a tier nobody asked for
42
+ const tier = this.full ? 'full' : envTier()
43
+ const decls = gateUnits(root, c, tieredTasks(c, root, tier))
38
44
  const layer = KeyLayer.open(root, decls, { cliEntry: new URL('../cli.ts', import.meta.url).pathname })
39
45
  const keys = layer.keys()
40
46
  const out = (l: string): void => { this.context.stdout.write(`${l}\n`) }
41
47
 
42
48
  if (this.json) {
43
49
  const drift = this.rescan ? layer.rescan() : null
44
- out(JSON.stringify({ units: keys, burnDown: layer.registration.burnDown, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
50
+ out(JSON.stringify({ tier, units: keys, burnDown: layer.registration.burnDown, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
45
51
  return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
46
52
  }
47
53
 
@@ -90,7 +96,7 @@ export class KeysCommand extends Command {
90
96
  const world = keys.filter((k) => k.population === 'world').length
91
97
  const none = keys.filter((k) => k.population === 'none').length
92
98
  const todo = layer.registration.burnDown.length
93
- out(`\n${keys.length} units — ${content} content, ${world} world, ${none} uncacheable (${none - todo} decided, ${todo} still undeclared) · key layer ${Math.round(layer.openMs)}ms, of which ${Math.round(layer.scanMs)}ms of git`)
99
+ out(`\n${keys.length} units — ${content} content, ${world} world, ${none} uncacheable (${none - todo} decided, ${todo} still undeclared) · ${tier} test tier · key layer ${Math.round(layer.openMs)}ms, of which ${Math.round(layer.scanMs)}ms of git`)
94
100
  if (todo > 0 && !this.burnDown) out(` the burn-down: shipkit keys --burn-down`)
95
101
 
96
102
  if (this.rescan) {
@@ -6,56 +6,23 @@
6
6
  // WARN/FAIL evict. Busts: version bump, secret rotation (key hashes the
7
7
  // secret value), `<freshEnv>=1`.
8
8
  //
9
- // STORAGE: one immutable file per (check, version, cache key), under
10
- // `<cacheDir>/<suite>/`. Three agents in one checkout is the normal case, so
11
- // there is no shared file to lose a write to: a run with a different key
12
- // writes a DIFFERENT file, a run with the same key replaces its own by tmp +
13
- // rename (whole answer or nothing, never half), and nothing is pruned,
14
- // because pruning by "the checks I know about" deletes the entries of a
15
- // runner that narrowed itself with --only. It replaced one aggregate JSON
16
- // read at the top of the run and rewritten at the bottom, which lost every
17
- // entry another runner wrote in between and served a torn read as an empty
18
- // cache.
9
+ // STORAGE is not this module's concern any more: ledger/store.ts owns the
10
+ // one store, its shape, and its housekeeping (one immutable file per key,
11
+ // tmp + rename, age-based sweep). This is the WORLD half of the three
12
+ // populations — an answer nothing on disk describes, remembered for a TTL and
13
+ // busted by rotation. The CONTENT half is keyed instead of timed and lives in
14
+ // ledger/ledger.ts. Same store, different key.
19
15
  //
20
16
  // Secret VALUES never enter this module's state beyond the injected
21
17
  // getSecret call + a truncated hash for cache-busting.
22
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
23
- import { dirname, join } from 'path'
18
+ import { existsSync } from 'fs'
24
19
  import { CheckCtx, RateLimited, ToolUnresolved } from './check-ctx.ts'
25
- import { atomicWrite } from './locked-write.ts'
20
+ import { dropStored, entryPath, FRESH_ENV, readStored, sweepStore, writeStored } from '../ledger/store.ts'
26
21
  import { fmtStamp, formatLine, parseDuration, type Line } from './wire.ts'
27
22
 
28
23
  type Logic = (t: CheckCtx) => unknown
29
24
  type Prep = { skip?: string; cacheExtra?: string }
30
25
  type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic; blocking: boolean }
31
- type CacheEntry = { version: number; key: string; at: number; lines: Line[] }
32
-
33
- /** a missing or half-written file reads as "no answer", which is the safe
34
- * side: the check runs. tmp + rename means half-written cannot happen from a
35
- * shipkit write, so this only ever catches a truncated file from something
36
- * else, and treats it the same way. */
37
- const readEntry = (path: string): CacheEntry | null => {
38
- try {
39
- const e = JSON.parse(readFileSync(path, 'utf8')) as Partial<CacheEntry>
40
- return Array.isArray(e.lines) && typeof e.at === 'number' && typeof e.version === 'number' && typeof e.key === 'string' ? (e as CacheEntry) : null
41
- } catch { return null }
42
- }
43
-
44
- const writeEntry = (path: string, e: CacheEntry): void => {
45
- try {
46
- mkdirSync(dirname(path), { recursive: true })
47
- atomicWrite(path, JSON.stringify(e))
48
- } catch {
49
- // a cache that cannot be written is slow, never wrong: the answer was
50
- // already printed, and the next run measures again
51
- }
52
- }
53
-
54
- const slug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
55
-
56
- /** how long a superseded key's file survives. Far past the longest TTL any
57
- * check declares, so a live entry is never swept out from under a replay. */
58
- const SUPERSEDED_KEEP_MS = 30 * 24 * 60 * 60 * 1000
59
26
 
60
27
  export type GetSecret = (name: string, field: 'get' | 'account') => string | null
61
28
 
@@ -66,15 +33,19 @@ export type SecretSpec = {
66
33
  skipHint: string
67
34
  }
68
35
 
69
- export type CheckOutcome = { lines: Line[]; status: 'pass' | 'fail' | 'warn' | 'skip'; cached: boolean }
36
+ export type CheckOutcome = { lines: Line[]; status: 'pass' | 'fail' | 'warn' | 'skip'; cached: boolean; /** wall time of the LOGIC, ms — 0 on a replay, since a replay measured nothing */ ms: number }
70
37
 
71
38
  export type CheckSuiteOpts = {
72
39
  name: string
73
- cacheDir: string
40
+ /** the ONE store root (ledger/store.ts storeRoot). Injected rather than
41
+ * resolved here so a test can point a suite at a scratch dir. */
42
+ storeDir: string
74
43
  now?: () => number
75
44
  /** secret backend — consumers inject their own store; default = nothing stored, every secret check skips */
76
45
  getSecret?: GetSecret
77
- /** env var that busts the cache when set (consumer names it) */
46
+ /** TEST SEAM: the env var that busts this suite's replays. Production is
47
+ * always the one house-wide flag (ledger/store.ts FRESH_ENV); a test
48
+ * overrides it so busting one suite cannot poison a neighbour's run. */
78
49
  freshEnv?: string
79
50
  /** per-check completion tap (the TUI streams results in) — cached replays
80
51
  * report cached:true, prep-skips (unstored secret) report status 'skip' */
@@ -85,7 +56,7 @@ export class CheckSuite {
85
56
  readonly name: string
86
57
  private runners: Runner[] = []
87
58
  private toolPaths = new Map<string, string>()
88
- private cacheDir: string
59
+ private storeDir: string
89
60
  private now: () => number
90
61
  private getSecret: GetSecret
91
62
  private freshEnv: string
@@ -93,10 +64,10 @@ export class CheckSuite {
93
64
 
94
65
  constructor(opts: CheckSuiteOpts) {
95
66
  this.name = opts.name
96
- this.cacheDir = opts.cacheDir
67
+ this.storeDir = opts.storeDir
97
68
  this.now = opts.now ?? Date.now
98
69
  this.getSecret = opts.getSecret ?? (() => null)
99
- this.freshEnv = opts.freshEnv ?? 'SHIPKIT_FRESH'
70
+ this.freshEnv = opts.freshEnv ?? FRESH_ENV
100
71
  this.onResult = opts.onResult
101
72
  }
102
73
 
@@ -166,32 +137,12 @@ export class CheckSuite {
166
137
  })
167
138
  }
168
139
 
169
- /** the key is in the file name, so a check whose key moves leaves its old
170
- * file behind forever. A rule keyed on installed versions writes one per
171
- * upgrade, so the dir accretes without bound in a repo nobody clears.
172
- * Sweeping by AGE needs no knowledge of the current check set, which is
173
- * what made the old prune wrong: a run narrowed by --only knows about one
174
- * check and would have deleted the other twenty four. */
175
- private sweepSuperseded(): void {
176
- const dir = join(this.cacheDir, slug(this.name))
177
- const cutoff = this.now() - SUPERSEDED_KEEP_MS
178
- try {
179
- for (const f of readdirSync(dir)) {
180
- if (!f.endsWith('.json')) continue
181
- const p = join(dir, f)
182
- try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { force: true }) } catch { /* raced with another runner: it is gone either way */ }
183
- }
184
- } catch {
185
- // no dir yet, or unreadable: housekeeping never fails a run
186
- }
187
- }
188
-
189
- /** where this check's answer for THIS key lives. The key is in the file
190
- * NAME, so a run whose key moved writes a new file rather than rewriting
191
- * the old one, and two runners with different keys never collide. */
192
- private entryPath(r: Runner, key: string): string {
193
- const h = new Bun.CryptoHasher('sha256').update(`${r.name}\0${r.version}\0${key}`).digest('hex').slice(0, 12)
194
- return join(this.cacheDir, slug(this.name), `${slug(r.name)}.${h}.json`)
140
+ /** the key a world answer is remembered under: the check's own version
141
+ * plus whatever the prep declared (a secret's value hash, a rule's
142
+ * cacheKey). A version bump and a rotation both move it, so neither can
143
+ * replay across the change. */
144
+ private cacheKey(r: Runner, extra: string): string {
145
+ return `v${r.version}\0${extra}`
195
146
  }
196
147
 
197
148
  // test seam — run() prints + exits; execute() returns the structured result
@@ -201,15 +152,14 @@ export class CheckSuite {
201
152
  private async runOne(r: Runner): Promise<{ lines: Line[]; skip: string | null; ran: boolean; failed: boolean }> {
202
153
  const prep = r.prep?.() ?? {}
203
154
  if (prep.skip != null) {
204
- this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false })
155
+ this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false, ms: 0 })
205
156
  return { lines: [], skip: prep.skip, ran: false, failed: false }
206
157
  }
207
- const key = prep.cacheExtra ?? ''
208
- const entryPath = this.entryPath(r, key)
209
- const entry = readEntry(entryPath)
158
+ const key = this.cacheKey(r, prep.cacheExtra ?? '')
159
+ const path = entryPath(this.storeDir, this.name, r.name, key)
160
+ const entry = readStored<Line[]>(path, key)
210
161
  const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
211
- && entry != null && entry.version === r.version && entry.key === key
212
- && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
162
+ && entry != null && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
213
163
  if (fresh) {
214
164
  // the stamp was baked in when the check RAN (below): a replay
215
165
  // prints the stored lines VERBATIM, so the live run and every
@@ -219,11 +169,12 @@ export class CheckSuite {
219
169
  // alternation, so neither side annotates
220
170
  // a replayed WORLD negative keeps its severity: it is still the
221
171
  // answer, and demoting it to a pass would hide a real finding
222
- const replayedWarn = entry.lines.some((l) => l.kind === 'warn' || l.kind === 'fail')
223
- this.onResult?.(r.name, { lines: entry.lines, status: replayedWarn ? 'warn' : 'pass', cached: true })
224
- return { lines: entry.lines, skip: null, ran: true, failed: false }
172
+ const replayedWarn = entry.payload.some((l) => l.kind === 'warn' || l.kind === 'fail')
173
+ this.onResult?.(r.name, { lines: entry.payload, status: replayedWarn ? 'warn' : 'pass', cached: true, ms: 0 })
174
+ return { lines: entry.payload, skip: null, ran: true, failed: false }
225
175
  }
226
176
  const t = new CheckCtx(this.toolPaths)
177
+ const started = performance.now()
227
178
  try {
228
179
  await r.logic(t)
229
180
  } catch (e) {
@@ -260,29 +211,23 @@ export class CheckSuite {
260
211
  const lines = cacheable
261
212
  ? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (${clean ? 'cached' : 'measured'} at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + r.ttlMs)})` } : l))
262
213
  : t.lines
263
- this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
214
+ this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false, ms: performance.now() - started })
264
215
  // this check writes its OWN file, and only its own: nothing here reads
265
216
  // or rewrites a neighbour's entry, so a racing runner can lose nothing
266
217
  if (r.ttlMs > 0) {
267
- if (cacheable) writeEntry(entryPath, { version: r.version, key, at: this.now(), lines })
218
+ if (cacheable) writeStored(path, key, lines, this.now())
268
219
  // an unmeasured run evicts whatever was stored: a later run must
269
220
  // re-probe rather than replay an answer recorded before the outage.
270
221
  // A racing runner may have written a good answer into that same
271
222
  // file a moment ago and lose it here, which costs one re-probe and
272
223
  // can never produce a stale green, so it is the safe direction.
273
- else if (entry != null) rmSync(entryPath, { force: true })
224
+ else if (entry != null) dropStored(path)
274
225
  }
275
226
  return { lines, skip: null, ran: true, failed: t.failed }
276
227
  }
277
228
 
278
229
  async execute(): Promise<{ lines: Line[]; exitCode: number }> {
279
- // the aggregate file this replaced. Deleting it here is the migration:
280
- // nothing reads it any more, and leaving it would have a future reader
281
- // believe there are two cache layers. DIES when every consumer repo is
282
- // past rvlib-shipkit 0.34.0, since after that no tree can still carry
283
- // one; it costs an unlink of a path that is not there.
284
- rmSync(join(this.cacheDir, `${this.name}.json`), { force: true })
285
- this.sweepSuperseded()
230
+ sweepStore(this.storeDir, this.name, this.now())
286
231
 
287
232
  // CONCURRENT, bounded. Checks are independent by contract: each answers
288
233
  // one question about the world or the repo and shares nothing with its
@@ -17,8 +17,6 @@ export type DiscoveredCheck = {
17
17
  path: string
18
18
  /** display + cache name — file base, disambiguated by relative path on collision */
19
19
  name: string
20
- /** file runs its own CheckSuite (own TTL cache) — the orchestrator must not double-cache it */
21
- selfCached: boolean
22
20
  /** `export const blocking = true | false`, read statically. null = the file
23
21
  * never said, which is an error: a check that silently does not block is
24
22
  * the failure nobody sees. */
@@ -34,6 +32,12 @@ export type DiscoveredCheck = {
34
32
  /** `export const never = 'why'`, read statically — a DECIDED never
35
33
  * cacheable, which takes the check off the burn-down list. */
36
34
  never: string | null
35
+ /** `export const gate = false`, read statically — this check is NOT part of
36
+ * `shipkit ci`. `ci` answers "is my change good"; a check that answers
37
+ * something else, and that can sit red for months without anything being
38
+ * wrong, does not belong in the answer. `shipkit check` still runs it.
39
+ * Default true: a check is in the gate unless it opts out by name. */
40
+ gate: boolean
37
41
  }
38
42
 
39
43
  // read without importing: checks run as subprocesses, and discovery must stay
@@ -46,6 +50,10 @@ const BLOCKING_RE = /^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(tru
46
50
  const INPUTS_RE = /^\s*export\s+const\s+inputs\s*(?::[^=]*)?=\s*\[([^\]]*)\]/m
47
51
  const TTL_RE = /^\s*export\s+const\s+ttl\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
48
52
  const NEVER_RE = /^\s*export\s+const\s+never\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
53
+ // `export const gate = false` takes a check out of `shipkit ci`. Only the
54
+ // literal false opts out: anything unreadable stays in the gate, because a
55
+ // 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
49
57
  const STRING_ITEM_RE = /['"]([^'"]+)['"]/g
50
58
 
51
59
  export function discoverChecks(root: string): DiscoveredCheck[] {
@@ -67,16 +75,13 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
67
75
  return files.map((f) => {
68
76
  const base = baseOf(f)
69
77
  const name = (seen.get(base) ?? 0) > 1 ? relative(root, f).slice(0, -SUFFIX.length) : base
70
- // a check built on CheckSuite/PageValidator replays from its OWN
71
- // TTL cache — caching it again up here would double the staleness
72
- let selfCached = false
73
78
  let blocking: boolean | null = null
74
79
  let inputs: string[] | null = null
75
80
  let ttl: string | null = null
76
81
  let never: string | null = null
82
+ let gate = true
77
83
  try {
78
84
  const src = readFileSync(f, 'utf8')
79
- selfCached = /PageValidator|CheckSuite/.test(src)
80
85
  const m = src.match(BLOCKING_RE)
81
86
  blocking = m == null ? null : m[1] === 'true'
82
87
  const im = src.match(INPUTS_RE)
@@ -86,7 +91,8 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
86
91
  }
87
92
  ttl = src.match(TTL_RE)?.[1] ?? null
88
93
  never = src.match(NEVER_RE)?.[1] ?? null
94
+ gate = src.match(GATE_RE) == null
89
95
  } catch {}
90
- return { path: f, name, selfCached, blocking, inputs, ttl, never }
96
+ return { path: f, name, blocking, inputs, ttl, never, gate }
91
97
  })
92
98
  }
package/src/core/floor.ts CHANGED
@@ -84,18 +84,27 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
84
84
  for (const id of FLOOR_IDS) {
85
85
  const override = c.defaults?.[id]
86
86
  if (override === false) { disabled.push(id); continue }
87
- if (override != null) { tasks.push(override); sources[id] = 'repo.config override'; continue }
87
+ // a FULL override brings its own body and replaces the slot. One that
88
+ // brings none DECORATES whatever fills it, which is how a repo declares
89
+ // what its `test` script reads without restating the command: the read
90
+ // set is repo knowledge, the command is not.
91
+ if (override != null && (override.cmd != null || override.logic != null)) {
92
+ tasks.push({ ...override, id: override.id ?? id } as TaskDef)
93
+ sources[id] = 'repo.config override'
94
+ continue
95
+ }
88
96
  // ONE WAY IN: `<id>` routes to `shipkit ci --only <id>`, so the TOOL
89
97
  // body lives at `<id>:raw` and that is what the gate runs. Running the
90
98
  // routed script here would spawn the gate from inside itself, forever
99
+ const decorate = (t: TaskDef): TaskDef => (override == null ? t : { ...t, ...override, id })
91
100
  const raw = scripts[rawName(id)]
92
- if (raw != null) { tasks.push({ id, cmd: ['bun', 'run', rawName(id)] }); sources[id] = 'package.json script'; continue }
93
- if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push({ id, cmd: ['bun', 'run', id] }); sources[id] = 'package.json script'; continue }
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 }
94
103
  const b = builtinFloorTask(id, root)
95
104
  // a routed script with nothing behind it measures NOTHING, and the
96
105
  // reason must say that rather than `no '<id>' script`, which is false
97
106
  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 })
98
- else { tasks.push(b.task); sources[id] = b.source }
107
+ else { tasks.push(decorate(b.task)); sources[id] = b.source }
99
108
  }
100
109
  return { tasks, disabled, unfilled, sources }
101
110
  }
@@ -24,6 +24,58 @@ type Row = { name: string; root: string; version: string; mode: string; seen: st
24
24
 
25
25
  const rowLine = (r: Row): string => `- ${r.name} — \`${r.root}\` — shipkit ${r.version} (${r.mode}) — last seen ${r.seen}`
26
26
 
27
+ const git = (cwd: string, args: string[]): { ok: boolean; out: string } => {
28
+ const r = Bun.spawnSync(['git', ...args], { cwd })
29
+ return { ok: r.exitCode === 0, out: `${r.stdout.toString()}${r.stderr.toString()}`.trim() }
30
+ }
31
+
32
+ /**
33
+ * Commit the row we just wrote, and ONLY that file.
34
+ *
35
+ * This page is machine written and rewrites whenever a repo's shipkit version
36
+ * or consumption mode changes, so left alone it dirties the docs repo from a
37
+ * gate run nobody associates with it, and the dirt then rides into an unrelated
38
+ * commit or blocks a release. The row is not worth a human's attention in
39
+ * either direction, so the machine that writes it also lands it.
40
+ *
41
+ * Scoped hard, because several agents share this checkout:
42
+ * - `git commit -- <path>` takes the WORKING TREE version of that one path and
43
+ * never reads the index, so a neighbour's staged work cannot ride along.
44
+ * - it refuses mid merge, mid rebase, on a detached HEAD, and before the first
45
+ * commit, where an automatic commit is never what anyone wants.
46
+ * - it never pushes. Visibility is the human's call.
47
+ * - the hooks run, as they do for any commit. They are text checks of about a
48
+ * second, and this fires only when the row's DATA changed, not every gate.
49
+ *
50
+ * Never throws and never fails the calling command: a registry row is not worth
51
+ * a red gate. `SHIPKIT_NO_AUTOCOMMIT=1` turns it off.
52
+ */
53
+ function autoCommitRow(page: string, name: string): string {
54
+ if (process.env['SHIPKIT_NO_AUTOCOMMIT'] === '1') return ''
55
+ try {
56
+ const dir = resolve(page, '..')
57
+ const top = git(dir, ['rev-parse', '--show-toplevel'])
58
+ if (!top.ok) return '' // the docs tree is not a git repo, nothing to land
59
+ const root = top.out
60
+ // a detached HEAD or a repo with no commit yet has nowhere sane to land
61
+ if (!git(root, ['symbolic-ref', '--quiet', 'HEAD']).ok) return ''
62
+ if (!git(root, ['rev-parse', '--verify', 'HEAD']).ok) return ''
63
+ for (const f of ['MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD'])
64
+ if (existsSync(join(root, '.git', f))) return ''
65
+ const rel = page.startsWith(`${root}/`) ? page.slice(root.length + 1) : page
66
+ // one retry: a neighbour committing right now holds index.lock
67
+ for (let attempt = 0; attempt < 2; attempt++) {
68
+ const r = git(root, ['commit', '-m', `chore(machine): refresh local-projects row (${name})`, '--', rel])
69
+ if (r.ok) return ' + committed'
70
+ if (!r.out.includes('index.lock')) return ''
71
+ Bun.sleepSync(300)
72
+ }
73
+ return ''
74
+ } catch {
75
+ return ''
76
+ }
77
+ }
78
+
27
79
  const parseRow = (line: string): Row | null => {
28
80
  const m = line.match(/^- (.+?) — `(.+?)` — shipkit (\S+) \((.+?)\) — last seen (\d{4}-\d{2}-\d{2})$/)
29
81
  return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
@@ -90,7 +142,8 @@ export function reportToMotherhome(root: string, config: RepoConfig, today: stri
90
142
  // prune rows for repos that no longer exist at their recorded path
91
143
  const next = [...rows.filter((row) => row.root !== me.root && existsSync(row.root)), me].sort((a, z) => a.name.localeCompare(z.name))
92
144
  atomicWrite(page, [...lines.slice(0, b + 1), ...next.map(rowLine), ...lines.slice(e)].join('\n'))
93
- return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})` }
145
+ const committed = autoCommitRow(page, me.name)
146
+ return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})${committed}` }
94
147
  })
95
148
  // lock lost to a racing reporter — self-heals on the next run
96
149
  return r.ok ? r.value : { wrote: false }