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.
@@ -0,0 +1,202 @@
1
+ // THE LEDGER — the CONTENT half of the three populations: a unit's answer,
2
+ // stored under the identity of the question it answered, replayed only when
3
+ // that identity is bit-for-bit the same question again.
4
+ //
5
+ // ledger/git-key.ts owns the identity, ledger/store.ts owns the file. This
6
+ // module owns the POLICY, and the policy is where a cache turns into a false
7
+ // green, so every rule below exists because a specific failure was reproduced
8
+ // during the design pass:
9
+ //
10
+ // 1. DEFAULT DENY. A unit that declared nothing is never replayed and never
11
+ // stored. Forgetting to declare costs speed, never correctness, which is
12
+ // what makes the burn-down a to-do rather than a hazard.
13
+ // 2. POST RUN RE HASH. Results are BUFFERED while the gate runs and written
14
+ // only after the inputs are hashed a second time. A unit whose inputs
15
+ // moved between its run and that second hash answered about a tree that no
16
+ // longer exists, so its result is discarded rather than stored under a key
17
+ // that describes the file as it was BEFORE the edit. Three agents in one
18
+ // checkout is the normal case here, so that window is not theoretical.
19
+ // 3. THE GATE BINARY IS IN THE KEY. Editing shipkit moves every key in every
20
+ // repo (ledger/toolchain-identity.ts content-hashes the cli tree).
21
+ // 4. RELEASE NEVER READS IT. `bump` and `release` pass read:false, not
22
+ // configurably. Every remaining hole is then capped at "an agent saw a
23
+ // stale local green", never "a stale green authorised a publish".
24
+ // 5. NO SILENT REPLAY. Every replayed line is stamped with the word
25
+ // `replayed` and what the measured run cost, and the run prints hit, miss
26
+ // and uncacheable counts. A cached green that cannot be told from a
27
+ // measured green is the bug this whole design exists to not ship.
28
+ // 6. ONLY A GREEN REPLAYS. A red is the one outcome an agent acts on
29
+ // immediately, and acting on it changes content, which moves the key — so
30
+ // replaying a red buys almost nothing, while an undeclared flake would
31
+ // pin its red until something unrelated happened to change. The safe
32
+ // direction here is re-measuring, and it costs only the run that was
33
+ // already going red.
34
+ import type { RvtConfig } from '../ts/config.ts'
35
+ import type { Line } from '../core/wire.ts'
36
+ import { KeyLayer, type UnitDecl, type UnitKey } from './git-key.ts'
37
+ import { entryPath, FRESH_ENV, KEEP_MS, readStored, storeRoot, sweepStore, writeStored } from './store.ts'
38
+
39
+ /** the env var that turns the READ path on or off for a whole session.
40
+ * Writing is unconditional either way: an entry nobody reads breaks nothing. */
41
+ export const LEDGER_ENV = 'SHIPKIT_LEDGER'
42
+
43
+ /** where unit results live inside the one store */
44
+ const NS = 'units'
45
+
46
+ /** whether the read path is on by default, before any flag.
47
+ *
48
+ * It shipped OFF and ran that way while two other efforts gated in this
49
+ * checkout, because a bug here would have handed one of them a green nobody
50
+ * measured. It went ON once eleven scenarios pinned the failure modes, a race
51
+ * of five real gates on one checkout agreed on one verdict, and the dark run
52
+ * had turned up and fixed a false green in the key layer that predates it.
53
+ * `SHIPKIT_LEDGER=0` turns it off for a session, and this line is the revert. */
54
+ export const READ_DEFAULT = true
55
+
56
+ export type UnitOutcome = {
57
+ /** only a green is ever stored (rule 6 above) */
58
+ status: 'pass' | 'warn'
59
+ lines: Line[]
60
+ /** the task-runner headline, which the gate greps for the test count */
61
+ lastLine?: string
62
+ /** what the measured run cost, ms — printed on the replay so a hit says
63
+ * what it saved instead of pretending the work was free */
64
+ measuredMs: number
65
+ }
66
+
67
+ export type LedgerOpts = {
68
+ /** the cli entry driving this run (toolchain identity needs it) */
69
+ cliEntry: string
70
+ /** read the store at all. false = every unit is measured this run. */
71
+ read?: boolean
72
+ /** this run re-measures everything, and re-stores what it measured */
73
+ fresh?: boolean
74
+ /** module-graph config, loaded from the repo when omitted */
75
+ cfg?: RvtConfig
76
+ now?: () => number
77
+ }
78
+
79
+ export type CommitReport = {
80
+ /** unit ids written to the store */
81
+ stored: string[]
82
+ /** unit ids whose result was thrown away because an input moved */
83
+ discarded: string[]
84
+ /** the paths that moved */
85
+ drifted: string[]
86
+ /** the re-hash itself failed; nothing was stored */
87
+ error: string | null
88
+ ms: number
89
+ }
90
+
91
+ /** is the read path on for this process? A run may still turn it off. */
92
+ export function ledgerReadEnabled(): boolean {
93
+ const v = process.env[LEDGER_ENV]
94
+ if (v === '1' || v === 'true') return true
95
+ if (v === '0' || v === 'false') return false
96
+ return READ_DEFAULT
97
+ }
98
+
99
+ export class Ledger {
100
+ private readonly buffered = new Map<string, UnitOutcome>()
101
+ private readonly keys = new Map<string, UnitKey>()
102
+ private hits = 0
103
+ private misses = 0
104
+ private unkeyed = 0
105
+
106
+ private constructor(
107
+ readonly layer: KeyLayer,
108
+ private readonly store: string,
109
+ /** replays are allowed */
110
+ readonly reading: boolean,
111
+ private readonly now: () => number,
112
+ ) {}
113
+
114
+ static open(root: string, decls: UnitDecl[], o: LedgerOpts): Ledger {
115
+ const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg })
116
+ const fresh = o.fresh === true || process.env[FRESH_ENV] != null
117
+ const reading = (o.read ?? ledgerReadEnabled()) && !fresh
118
+ return new Ledger(layer, storeRoot(root), reading, o.now ?? Date.now)
119
+ }
120
+
121
+ /** the key for a unit, computed once per run and remembered: `keyFor`
122
+ * re-derives the component list every call, and the gate asks twice (once
123
+ * to replay, once to record). */
124
+ private key(id: string): UnitKey {
125
+ let k = this.keys.get(id)
126
+ if (k == null) { k = this.layer.keyFor(id); this.keys.set(id, k) }
127
+ return k
128
+ }
129
+
130
+ private path(k: UnitKey): string {
131
+ return entryPath(this.store, NS, `${k.kind}:${k.id}`, k.key!)
132
+ }
133
+
134
+ /** the stored answer for this unit, or null when it must run. Counts what
135
+ * it was asked, so the run can report hit/miss/uncacheable honestly. */
136
+ replay(id: string): UnitOutcome | null {
137
+ const k = this.key(id)
138
+ if (k.key == null) { this.unkeyed++; return null }
139
+ if (!this.reading) { this.misses++; return null }
140
+ const e = readStored<UnitOutcome>(this.path(k), k.key)
141
+ if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; return null }
142
+ this.hits++
143
+ return e.payload
144
+ }
145
+
146
+ /** buffer what a unit just measured. Nothing reaches the store until
147
+ * commit() has re-hashed the inputs (rule 2). */
148
+ record(id: string, outcome: UnitOutcome): void {
149
+ const k = this.key(id)
150
+ if (k.key == null) return
151
+ this.buffered.set(id, outcome)
152
+ }
153
+
154
+ /** re-hash, then write what still describes the tree on disk. */
155
+ commit(): CommitReport {
156
+ const empty: CommitReport = { stored: [], discarded: [], drifted: [], error: null, ms: 0 }
157
+ if (this.buffered.size === 0) return empty
158
+ const started = performance.now()
159
+ const d = this.layer.rescan()
160
+ if (d.error != null) return { ...empty, discarded: [...this.buffered.keys()].sort(), error: d.error, ms: performance.now() - started }
161
+ const touched = this.layer.unitsTouchedBy(d.drifted)
162
+ const stored: string[] = []
163
+ const discarded: string[] = []
164
+ for (const [id, outcome] of this.buffered) {
165
+ if (touched.has(id)) { discarded.push(id); continue }
166
+ const k = this.key(id)
167
+ writeStored(this.path(k), k.key!, outcome, this.now())
168
+ stored.push(id)
169
+ }
170
+ sweepStore(this.store, NS, this.now(), KEEP_MS)
171
+ return { stored: stored.sort(), discarded: discarded.sort(), drifted: d.drifted, error: null, ms: performance.now() - started }
172
+ }
173
+
174
+ get counts(): { hit: number; miss: number; uncacheable: number } {
175
+ return { hit: this.hits, miss: this.misses, uncacheable: this.unkeyed }
176
+ }
177
+
178
+ /** the one line a run prints about its own cache. Never silent: a run that
179
+ * replayed nothing says so, so a green is always attributable. */
180
+ header(commit: CommitReport): Line[] {
181
+ const c = this.counts
182
+ const asked = c.hit + c.miss + c.uncacheable
183
+ if (asked === 0) return []
184
+ const out: Line[] = []
185
+ // `skip:` and not a bare note: the ci wire prefixes are a frozen
186
+ // contract every agent and every parser reads, and these lines are
187
+ // exactly what the run did NOT measure
188
+ const mode = this.reading ? '' : this.layer.failure != null ? ', reads off because the git scan failed' : ', reads off'
189
+ out.push({ kind: 'skip', text: `ledger — ${c.hit} replayed, ${c.miss} measured, ${c.uncacheable} uncacheable${mode}` })
190
+ if (commit.error != null) out.push({ kind: 'warn', text: `ledger stored nothing this run — the post-run re-hash failed: ${commit.error}` })
191
+ else if (commit.discarded.length > 0)
192
+ out.push({ kind: 'skip', text: `ledger stored nothing for ${commit.discarded.length} unit(s) — ${commit.drifted.slice(0, 3).join(', ')}${commit.drifted.length > 3 ? ` +${commit.drifted.length - 3} more` : ''} moved while the gate ran` })
193
+ return out
194
+ }
195
+ }
196
+
197
+ /** stamp a replayed unit's lines. The FIRST line carries the word, because
198
+ * that is the line an agent reads, and a replay that renders identically to a
199
+ * measurement is the one thing this design refuses to ship. */
200
+ export function stampReplayed(lines: Line[], measuredMs: number): Line[] {
201
+ return lines.map((l, i) => (i === 0 ? { ...l, text: `${l.text} (replayed, ${(measuredMs / 1000).toFixed(1)}s saved)` } : l))
202
+ }
@@ -0,0 +1,122 @@
1
+ // ONE STORE. Everything this toolkit remembers between runs lives here, in
2
+ // one shape, under one root, busted by one flag.
3
+ //
4
+ // There used to be two: `tmp/.shipkit-cache` for the check orchestrator and
5
+ // `tmp/.check-cache` for the page checks, each with its own bust env var, each
6
+ // per worktree. Two stores means two staleness windows over one question, and
7
+ // the stamps in this repo's own history went BACKWARDS between checkouts
8
+ // because of it. This module is the single mechanism; what differs between
9
+ // populations is the KEY, never the storage.
10
+ //
11
+ // THE SHAPE, unchanged from the check cache slice 2 landed and proved:
12
+ // one immutable file per key, named by the key, written tmp + rename. No
13
+ // lock, no merge, no read-modify-write, and no prune of another runner's
14
+ // entries. Three agents in one checkout is the normal case, so the only safe
15
+ // aggregate is no aggregate: a run with a different key writes a DIFFERENT
16
+ // file, a run with the same key replaces its own whole answer or none of it,
17
+ // and housekeeping sweeps by AGE, which needs no knowledge of what the
18
+ // current run knows about.
19
+ //
20
+ // WHERE. `<git-common-dir>/shipkit/store/`, which is SHARED by every linked
21
+ // worktree of the repo. That is safe only because no key component is a path:
22
+ // the toolchain identity content-hashes the gate binary and every installed
23
+ // package, so two worktrees carrying different installs produce different
24
+ // keys and simply miss each other (ledger/toolchain-identity.ts owns the
25
+ // measurement). It is also outside the work tree, so nothing here can dirty a
26
+ // diff or reach a commit.
27
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
28
+ import { dirname, join } from 'path'
29
+ import { atomicWrite } from '../core/locked-write.ts'
30
+
31
+ /** the storage FORMAT. Bumped when the envelope changes, so an old file can
32
+ * never be handed to a new reader. The KEY carries everything about the
33
+ * question; this is only about the file around it. */
34
+ export const STORE_VERSION = 1
35
+
36
+ /** the ONE env var that busts every remembered answer, content or TTL. */
37
+ export const FRESH_ENV = 'SHIPKIT_FRESH'
38
+
39
+ /** one remembered answer. `key` is stored as well as hashed into the file
40
+ * name, and re-checked on read: a name collision must miss, never serve a
41
+ * neighbour's answer. */
42
+ export type Stored<T> = {
43
+ v: number
44
+ key: string
45
+ /** when it was written, ms since epoch */
46
+ at: number
47
+ payload: T
48
+ }
49
+
50
+ /** how long a superseded entry survives. Far past the longest TTL any check
51
+ * declares, so a live entry is never swept out from under a replay. */
52
+ export const KEEP_MS = 30 * 24 * 60 * 60 * 1000
53
+
54
+ /** where this repo's remembered answers live.
55
+ *
56
+ * `--git-common-dir` rather than `--git-dir`: a linked worktree has its own
57
+ * git dir but shares the common one, and slice 3 measured that sharing is
58
+ * SAFE here because every key component is content or a version string.
59
+ * A tree that is not a git repo at all still gets a store, under `tmp/`,
60
+ * because a scaffold in progress must not crash on it. */
61
+ export function storeRoot(root: string): string {
62
+ const p = Bun.spawnSync(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
63
+ const dir = p.exitCode === 0 ? p.stdout.toString().trim() : ''
64
+ return dir === '' ? join(root, 'tmp/.shipkit-store') : join(dir, 'shipkit/store')
65
+ }
66
+
67
+ const slug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
68
+
69
+ /** where ONE answer lives. The key is in the file NAME, so a run whose key
70
+ * moved writes a new file instead of rewriting the old one, and two runners
71
+ * with different keys never collide. `ns` keeps populations apart so a sweep
72
+ * or a manual `rm` can be scoped. */
73
+ export function entryPath(store: string, ns: string, name: string, key: string): string {
74
+ const h = new Bun.CryptoHasher('sha256').update(`${ns}\0${name}\0${key}`).digest('hex').slice(0, 12)
75
+ return join(store, slug(ns), `${slug(name)}.${h}.json`)
76
+ }
77
+
78
+ /** a missing or half-written file reads as "no answer", which is the safe
79
+ * side: the unit runs. tmp + rename means half-written cannot happen from a
80
+ * shipkit write, so this only ever catches a truncated file from something
81
+ * else, and treats it the same way. */
82
+ export function readStored<T>(path: string, key: string): Stored<T> | null {
83
+ try {
84
+ const e = JSON.parse(readFileSync(path, 'utf8')) as Partial<Stored<T>>
85
+ if (e.v !== STORE_VERSION || e.key !== key || typeof e.at !== 'number' || e.payload == null) return null
86
+ return e as Stored<T>
87
+ } catch { return null }
88
+ }
89
+
90
+ export function writeStored<T>(path: string, key: string, payload: T, at: number): void {
91
+ try {
92
+ mkdirSync(dirname(path), { recursive: true })
93
+ atomicWrite(path, JSON.stringify({ v: STORE_VERSION, key, at, payload }))
94
+ } catch {
95
+ // a store that cannot be written is slow, never wrong: the answer was
96
+ // already printed, and the next run measures again
97
+ }
98
+ }
99
+
100
+ export function dropStored(path: string): void {
101
+ try { rmSync(path, { force: true }) } catch { /* raced with another runner: it is gone either way */ }
102
+ }
103
+
104
+ /** age-based housekeeping. Sweeping by "the units I know about" is what made
105
+ * the old prune wrong: a run narrowed with --only knows about one unit and
106
+ * would delete the other sixty-five. */
107
+ export function sweepStore(store: string, ns: string, now: number, keepMs = KEEP_MS): void {
108
+ const dir = join(store, slug(ns))
109
+ const cutoff = now - keepMs
110
+ try {
111
+ for (const f of readdirSync(dir)) {
112
+ // `.<name>.tmp-<pid>` is what atomicWrite lands beside its target,
113
+ // and a writer killed between the write and the rename leaves one
114
+ // behind. Sweeping only *.json accreted them without bound.
115
+ if (!f.endsWith('.json') && !/\.tmp-\d+$/.test(f)) continue
116
+ const p = join(dir, f)
117
+ try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { force: true }) } catch { /* raced: gone either way */ }
118
+ }
119
+ } catch {
120
+ // no dir yet, or unreadable: housekeeping never fails a run
121
+ }
122
+ }
@@ -75,6 +75,36 @@ function hashCliTree(h: ReturnType<typeof createHash>, cli: string): void {
75
75
  h.update(`clifiles=${files.length}\n`)
76
76
  }
77
77
 
78
+ /**
79
+ * what ONE install holds, as a hash: every top-level package's name, version
80
+ * and link target.
81
+ *
82
+ * Exported because a repo's own install is not the only one a gate can run
83
+ * against. Sub-packages carry their own `node_modules`, gitignored like every
84
+ * other, so no git key can see them and the root toolchain does not describe
85
+ * them either. A unit whose declared inputs COVER such a directory folds this
86
+ * in beside the path, or the toolchain component stops at the repo root while
87
+ * the task it is meant to describe does not.
88
+ *
89
+ * ~2ms per install, and only for a path a scan already reported as ignored.
90
+ */
91
+ export function installIdentity(nodeModules: string): string {
92
+ if (!existsSync(nodeModules)) return 'install=absent'
93
+ const h = createHash('sha256')
94
+ let names: string[] = []
95
+ try { names = installedNames(nodeModules) } catch { return 'install=unreadable' }
96
+ for (const n of names) {
97
+ const dir = join(nodeModules, n)
98
+ let version = 'MISSING'
99
+ try { version = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { version?: string }).version ?? '?' } catch {}
100
+ let link = ''
101
+ try { if (lstatSync(dir).isSymbolicLink()) link = `->${realpathSync(dir)}` } catch {}
102
+ h.update(`${n}@${version}${link}\n`)
103
+ }
104
+ h.update(`n=${names.length}\n`)
105
+ return `install=${h.digest('hex').slice(0, 16)}`
106
+ }
107
+
78
108
  /**
79
109
  * the identity of the program about to run, for `repoRoot`, driven by `cliEntry`.
80
110
  *
@@ -101,7 +101,7 @@ function columnsFile(col: PbCollection): string {
101
101
  lines.push('// means the cell is derived and the database cannot order by it.')
102
102
  lines.push('// labels are plain text, swap them for i18n.t.<collection>.<field> when the')
103
103
  lines.push('// app carries a dict.')
104
- lines.push(`import type { Column } from "rvlib-pb-shell"`)
104
+ lines.push(`import type { Column } from "rvlib-pb/ui"`)
105
105
  lines.push(`import type { ${col.prefix}Response } from "${TYPES}"`)
106
106
  lines.push('')
107
107
  lines.push(`export const columns: Column<${col.prefix}Response>[] = [`)
@@ -144,8 +144,8 @@ function gridFile(col: PbCollection): string {
144
144
  return [
145
145
  `// the default grid for \`${col.name}\`. A page that wants the plain table renders`,
146
146
  '// this; a page that wants extra columns passes its own to LiveGrid directly.',
147
- `import type { LiveList } from "rvlib-pb-mobx"`,
148
- `import { LiveGrid } from "rvlib-pb-shell"`,
147
+ `import type { LiveList } from "rvlib-pb"`,
148
+ `import { LiveGrid } from "rvlib-pb/ui"`,
149
149
  `import type { ${col.prefix}Response } from "${TYPES}"`,
150
150
  `import { columns } from "./columns"`,
151
151
  '',
@@ -247,7 +247,7 @@ function indexFile(col: PbCollection, hasForm: boolean): string {
247
247
  lines.push('// registration: after this import, a LiveGrid or LiveCards over')
248
248
  lines.push(`// \`${col.name}\` with no columns of its own knows what to render, and any`)
249
249
  lines.push('// relation cell pointing AT one knows how to name it.')
250
- lines.push(`import { defineCollectionUI } from "rvlib-pb-shell"`)
250
+ lines.push(`import { defineCollectionUI } from "rvlib-pb/ui"`)
251
251
  lines.push(`import { db } from "${PB}"`)
252
252
  lines.push(`import { ${col.prefix}Card } from "./card"`)
253
253
  lines.push(`import { columns } from "./columns"`)
@@ -110,6 +110,11 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
110
110
  frozen: true,
111
111
  status: false,
112
112
  config,
113
+ // THE CAP ON EVERY REMAINING HOLE IN THE KEY. Not a flag, not a config
114
+ // field: a release re-measures every unit, so the worst a wrong key
115
+ // can ever produce is an agent looking at a stale local green, never a
116
+ // stale green that authorised a publish (ledger/ledger.ts rule 4).
117
+ ledgerRead: false,
113
118
  // the FULL tier, always. A release runs on a clean tree with time to
114
119
  // spare, and the suites the working gate skips (the ones that build
115
120
  // real repositories) are exactly the ones a publish must not be wrong
@@ -204,7 +204,13 @@ export function attentionLines(r: UnitReport): string[] {
204
204
  // shape of a command
205
205
  const KIND = '<patch|minor|major>'
206
206
 
207
- if (r.commitsAfterNotes != null && r.commitsAfterNotes > 0 && !r.tagLocal) {
207
+ if (r.npmTarget && r.npmLatest === 'unpublished') {
208
+ // NO version of this name is on the registry, which every drift branch
209
+ // below reads as "a bit behind" and none of them says. Every `bun add
210
+ // <name>` in a doc, a seed or a scaffold answers 404 while this holds,
211
+ // and that is the one state where the docs are not stale but dead
212
+ out.push(`WARN ${r.id}: nothing is published under the name ${r.name} — every "bun add ${r.name}" 404s until ${notesClause}shipkit bump ${KIND} ${r.id}`)
213
+ } else if (r.commitsAfterNotes != null && r.commitsAfterNotes > 0 && !r.tagLocal) {
208
214
  // the trap: a version stamped into package.json while work kept
209
215
  // landing. Publishing it ships that work under notes describing none
210
216
  // of it, so the answer is a NEW version, never this one.
@@ -23,7 +23,7 @@ import type { Rule } from './rules/rule.ts'
23
23
  import type { HooksConfig } from './hooks/run-hooks.ts'
24
24
  import type { ReleaseConfig } from './release/run-release.ts'
25
25
  import { UNIT_DECL_KEYS, type ReleaseUnitDecl } from './release/units.ts'
26
- import type { TaskDef } from './core/task-runner.ts'
26
+ import { TASK_DEF_KEYS, type TaskDef } from './core/task-runner.ts'
27
27
  import type { TuiPanelDef } from './tui/tui-panel.ts'
28
28
  import type { MdGraph } from './wiki/md-graph.ts'
29
29
 
@@ -51,8 +51,13 @@ export type RepoConfig = {
51
51
  social?: string[]
52
52
  /** prefix for contract fix: pointers (docs checkout or URL) — written by init, never baked in */
53
53
  docsBase?: string
54
- /** override a floor task's def, or `false` to disable (always visible) */
55
- defaults?: Record<string, TaskDef | false>
54
+ /** a floor slot: `false` disables it (always visible), a def with a
55
+ * `cmd` or `logic` REPLACES it, and a def with neither DECORATES whatever
56
+ * fills it. The decoration is how a repo declares what its own `test`
57
+ * script reads (`{ test: { inputs: ['tests', 'src'] } }`) without
58
+ * restating the command: the read set is repo knowledge, the command is
59
+ * already in package.json, and restating it is a second source of truth. */
60
+ defaults?: Record<string, Partial<TaskDef> | false>
56
61
  /** repo-specific commit-gate tasks; deps double as race guards */
57
62
  tasks?: TaskDef[]
58
63
  /** emptyText: the `shipkit asks` all-clear line, in the repo's own voice */
@@ -158,9 +163,19 @@ export function defineRepo(c: RepoConfig): RepoConfig {
158
163
  if (isAbsolute(d) || rel === '.' || rel === '' || rel === '..' || rel.startsWith('../'))
159
164
  throw new Error(`defineRepo: notReleasable '${d}' is the repo root or outside it, and would excuse every package at once — name one package dir per entry`)
160
165
  }
161
- for (const k of Object.keys(c.defaults ?? {}))
166
+ for (const [k, slot] of Object.entries(c.defaults ?? {})) {
162
167
  if (!(FLOOR_IDS as readonly string[]).includes(k))
163
168
  throw new Error(`defineRepo: defaults.${k} is not a floor task (floor: ${FLOOR_IDS.join(', ')})`)
169
+ // the slot's own FIELDS, not just its name. A slot with no cmd or logic
170
+ // decorates the resolved task, so a typo there (`input` for `inputs`)
171
+ // is not a type error at any call site and lands as a task that is
172
+ // simply never cached, silently, which is the one direction this file
173
+ // promises a typo cannot go.
174
+ if (slot === false || slot == null) continue
175
+ for (const f of Object.keys(slot))
176
+ if (!(TASK_DEF_KEYS as readonly string[]).includes(f))
177
+ throw new Error(`defineRepo: defaults.${k}.${f} is not a task field (fields: ${TASK_DEF_KEYS.join(', ')})`)
178
+ }
164
179
  for (const n of c.oneWayIn?.exempt ?? [])
165
180
  if (!(ROUTED_SCRIPTS as readonly string[]).includes(n))
166
181
  throw new Error(`defineRepo: oneWayIn.exempt '${n}' is not a governed script name (governed: ${ROUTED_SCRIPTS.join(', ')})`)
@@ -181,6 +196,13 @@ export async function loadRepoConfig(root: string): Promise<RepoConfig> {
181
196
  // the contracts commit-gate task — venue deploy tasks should dep on it.
182
197
  // Same SK002-tagged rendering as `shipkit check` and `shipkit contracts`
183
198
  // (contracts/apply-contracts.ts is the one bridge).
199
+ //
200
+ // DELIBERATELY UNDECLARED for the ledger, and this is where that decision
201
+ // lives rather than in a commit message. It runs in process, shares one
202
+ // evaluation with rule SK002 through `withContractsScope`, and measures 0.0s,
203
+ // so a key would be risk against no reward. It is not `never:` either: the
204
+ // contract dirs describe it perfectly well, so claiming nothing can is false,
205
+ // and `never` has to keep meaning "somebody looked and no key exists".
184
206
  export function contractsTask(c: RepoConfig, root: string): TaskDef {
185
207
  return {
186
208
  id: 'contracts',
@@ -1,6 +1,5 @@
1
1
  // SK004 — the .gitignore policy block (meta/contracts.md git policy; tmp/
2
- // included because shipkit itself writes tmp/.shipkit-cache in every
3
- // repo). A marker-keyed managed block so the always-gitignored contract
2
+ // included because shipkit writes its per-run logs there in every repo). A marker-keyed managed block so the always-gitignored contract
4
3
  // surfaces can never silently rot per repo. Ecosystem-specific names the
5
4
  // published lib may not carry ride as repo-contributed lines:
6
5
  // `defineRepo({ gitignore: [...] })` renders them INSIDE the block, so the
@@ -103,10 +103,12 @@ export const oneWayInPolicy: Rule = {
103
103
  if (missing.length > 0)
104
104
  t.fail(`${SETTINGS_PATH}: ${missing.length} raw runner(s) still permitted (${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''}) — those calls never reach shipkit, so nothing is cached and no run can be attached to`)
105
105
  const guard = guardHookCommand(s)
106
+ let guardRuns = false
106
107
  if (guard == null)
107
108
  t.fail(`${SETTINGS_PATH}: no PreToolUse guard — a bare deny tells the model only that it was denied, so it goes looking for another runner instead of the shipkit command`)
108
109
  else {
109
110
  const missing = guardScriptMissing(env.root, guard)
111
+ guardRuns = missing == null
110
112
  // the hook is DECLARED either way, so it starts working the moment
111
113
  // the dependency lands: no second fix run, nothing to remember. An
112
114
  // uninstalled dependency is therefore a WARN naming the command
@@ -124,7 +126,11 @@ export const oneWayInPolicy: Rule = {
124
126
  `point it at this repo's own copy of hooks/one-way-in-guard.ts, or delete the hook and let the deny list stand alone`,
125
127
  )
126
128
  }
127
- if (!t.failed) t.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s), with the replacement named`)
129
+ // the summary states only what the file actually delivers: claiming the
130
+ // replacement is named while the guard cannot run is the green that
131
+ // measured nothing, one line under the WARN that says otherwise
132
+ if (!t.failed)
133
+ t.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s)${guardRuns ? ', with the replacement named' : ' — the guard that names the replacement is declared, not yet runnable'}`)
128
134
  },
129
135
  // MERGES, never rewrites: this file also carries the attribution silencer,
130
136
  // a repo's own allow list and its own hooks, and clobbering those to fix a