rvlib-shipkit 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +15 -2
  4. package/src/cli/cmd_Asks.ts +2 -1
  5. package/src/cli/cmd_Keys.ts +107 -0
  6. package/src/cli.ts +2 -0
  7. package/src/core/check-suite.ts +95 -33
  8. package/src/core/discover-checks.ts +30 -1
  9. package/src/core/floor.ts +21 -2
  10. package/src/core/locked-write.ts +170 -1
  11. package/src/core/one-way-in-policy.ts +8 -8
  12. package/src/core/run-ci.ts +25 -1
  13. package/src/core/run-fixers.ts +61 -18
  14. package/src/core/task-runner.ts +55 -6
  15. package/src/hooks/run-hooks.ts +23 -2
  16. package/src/ledger/gate-units.ts +58 -0
  17. package/src/ledger/git-key.ts +330 -0
  18. package/src/ledger/git-scan.ts +314 -0
  19. package/src/ledger/toolchain-identity.ts +15 -8
  20. package/src/release/run-bump.ts +50 -5
  21. package/src/release/unit-gate.ts +17 -4
  22. package/src/rules/SK001-package-json-shape.ts +8 -2
  23. package/src/rules/SK002-contracts-fulfillness.ts +4 -0
  24. package/src/rules/SK003-claude-md-shape.ts +7 -2
  25. package/src/rules/SK004-gitignore-policy.ts +11 -2
  26. package/src/rules/SK005-rvlib-versions.ts +28 -1
  27. package/src/rules/SK006-npm-publish-safety.ts +3 -0
  28. package/src/rules/SK008-reviewer-agent.ts +6 -2
  29. package/src/rules/SK009-commit-ai-tells.ts +3 -0
  30. package/src/rules/SK010-undeclared-release-unit.ts +2 -0
  31. package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
  32. package/src/rules/SK012-config-barrel-import.ts +6 -2
  33. package/src/rules/SK013-one-way-in-policy.ts +24 -4
  34. package/src/rules/rule.ts +22 -1
  35. package/src/rules/run-fix.ts +5 -3
  36. package/src/scaffold/files/verify-skill.md +1 -1
  37. package/src/ts/config.ts +66 -6
  38. package/src/ts/cycles.ts +7 -92
  39. package/src/ts/source-graph.ts +158 -0
  40. package/src/ts/tsconfig-projects.ts +101 -0
  41. package/src/ts/typecheck-cli.ts +46 -0
@@ -56,8 +56,8 @@ function installedNames(nm: string): string[] {
56
56
  return names.sort()
57
57
  }
58
58
 
59
- /** the gate binary's own source, content-hashed. Only reached when the cli
60
- * lives OUTSIDE the repo, where the git tree key cannot see it. */
59
+ /** the gate binary's own source, content-hashed. This is what makes editing
60
+ * shipkit invalidate every key in every repo, in-repo copy or dependency. */
61
61
  function hashCliTree(h: ReturnType<typeof createHash>, cli: string): void {
62
62
  const srcRoot = resolve(cli, '..')
63
63
  const files: string[] = []
@@ -94,12 +94,19 @@ export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain
94
94
  // the runtime. Free, and a bun upgrade genuinely changes behaviour
95
95
  h.update(`bun=${Bun.version}\n`)
96
96
 
97
- // the gate binary. In-repo, the git tree key already covers every byte of
98
- // it, so the literal is enough and the walk is skipped
99
- const cli = realpathSync(cliEntry)
100
- const cliInRepo = inRepo(root, cli)
101
- h.update(`cli=${cliInRepo ? 'in-repo' : cli}\n`)
102
- if (!cliInRepo) hashCliTree(h, cli)
97
+ // the gate binary, ALWAYS content-hashed, and its path never folded in.
98
+ //
99
+ // The earlier shape wrote `cli=in-repo` for a binary inside the repo and
100
+ // leaned on the git tree key to cover it. Both halves were wrong, and both
101
+ // were measured. In a CONSUMER repo the binary sits in `node_modules`,
102
+ // which is path-inside the root and inside no git tree, so the tree key
103
+ // covered none of it. And between a checkout that HOSTS the binary and a
104
+ // worktree that runs the same one through the global shim, one wrote
105
+ // `in-repo` and the other wrote an absolute path, so two checkouts running
106
+ // one identical program could never agree on a key. Content-hashing costs
107
+ // 3.5 to 9.2ms on a 163-file tree, and it is the only form that is the
108
+ // same value wherever the caller stands.
109
+ hashCliTree(h, realpathSync(cliEntry))
103
110
 
104
111
  // what is installed. The lockfile states the intent; the tree states the fact
105
112
  const lock = join(root, 'bun.lock')
@@ -26,7 +26,7 @@
26
26
  // every selected unit's package.json and changelog. The publish part is
27
27
  // RESUMABLE instead, because an npm publish cannot be undone and a rollback
28
28
  // that cannot roll back is a lie.
29
- import { existsSync, readFileSync, writeFileSync } from 'fs'
29
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'fs'
30
30
  import { homedir } from 'os'
31
31
  import { join, relative } from 'path'
32
32
  import { declaredKind, hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
@@ -136,6 +136,26 @@ function mergeIntoReleaseBranch(
136
136
  }
137
137
  }
138
138
 
139
+ /** where a branch is checked out, or null. `git worktree list` is the only
140
+ * source that sees the OTHER checkouts: a linked worktree and its main
141
+ * checkout share one object store and one branch namespace, and git refuses
142
+ * to check a branch out twice. Three agents in one checkout, one of them in a
143
+ * worktree, is the normal case here, so this is asked before any ref moves. */
144
+ function branchCheckedOutAt(root: string, git: Git, branch: string): string | null {
145
+ const r = git(['worktree', 'list', '--porcelain'])
146
+ if (r.code !== 0) return null
147
+ let path: string | null = null
148
+ for (const line of r.out.split('\n')) {
149
+ if (line.startsWith('worktree ')) path = line.slice('worktree '.length).trim()
150
+ else if (line.trim() === `branch refs/heads/${branch}`) {
151
+ if (path == null) continue
152
+ try { if (realpathSync(path) === realpathSync(root)) return null } catch { /* a stale worktree entry: treat it as foreign */ }
153
+ return path
154
+ }
155
+ }
156
+ return null
157
+ }
158
+
139
159
  /** the rehearsal's answer to the same two questions, from the object database:
140
160
  * has the release branch diverged from its remote, and would the merge
141
161
  * conflict. Nothing is checked out and no ref moves, which is the whole point
@@ -189,6 +209,10 @@ function probeMergeIntoReleaseBranch(
189
209
  return { refuse: false, inconclusive: stale }
190
210
  }
191
211
 
212
+ /** a rehearsal moves no ref and checks nothing out, so a branch held by
213
+ * another worktree cannot stop it */
214
+ const rehearsalOnly = (o: BumpOpts): boolean => o.dryRun === true
215
+
192
216
  type Selected = { unit: ReleaseUnit; version: string; changelogRel: string; kind: BumpKind }
193
217
 
194
218
  export async function runBump(root: string, config: RepoConfig, kind: BumpKind | null, o: BumpOpts = {}): Promise<number> {
@@ -242,7 +266,11 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
242
266
  const paths = dirtyPathList(preDirty)
243
267
  if (paths.every((p) => machine.has(p))) {
244
268
  gitAt(root, ['add', '--', ...paths])
245
- const c = gitAt(root, ['commit', '-m', 'machine rows refresh before release'])
269
+ // PATHSPEC on the commit too, not just the add. Three agents in
270
+ // one checkout share one index, so a bare `git commit` here takes
271
+ // whatever a neighbour staged a second ago and ships it under a
272
+ // message about machine rows.
273
+ const c = gitAt(root, ['commit', '-m', 'machine rows refresh before release', '--', ...paths])
246
274
  if (c.code === 0) print('ok: machine-written rows committed (they re-dirty on every gate run)')
247
275
  else print(`WARN machine-row auto-commit failed (exit ${c.code}), the refusal below says what to do:\n${c.out.split('\n').slice(-6).join('\n')}`)
248
276
  } else {
@@ -263,6 +291,20 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
263
291
 
264
292
  const wantMerge = cur !== branch && o.noMerge !== true && (config.release?.mergeFromBranch ?? true)
265
293
  if (cur !== branch && !wantMerge) { print(`FAIL: releases ship from '${branch}', HEAD is on '${cur}'`); return 1 }
294
+ // the merge CHECKS OUT the release branch here, and git refuses to have
295
+ // one branch checked out in two worktrees. Left to git the run dies on a
296
+ // raw `fatal: 'main' is already used by worktree at …`, which reads like a
297
+ // shipkit bug. Worse, in the other direction, a bump run in the checkout
298
+ // that DOES hold the branch moves the HEAD of an agent working in it.
299
+ // Refuse by name, and say which checkout owns the branch.
300
+ if (wantMerge && !rehearsalOnly(o)) {
301
+ const held = branchCheckedOutAt(root, git, branch)
302
+ if (held != null) {
303
+ print(`FAIL: '${branch}' is checked out at ${held}, so this worktree cannot merge into it`)
304
+ print(` fix: bump from ${held}, or \`shipkit bump --no-merge\` after merging '${cur}' into '${branch}' by hand there`)
305
+ return 1
306
+ }
307
+ }
266
308
  const remote = deriveRemote(root)
267
309
  if ('error' in remote) { print(`FAIL: ${remote.error}`); return 1 }
268
310
  if (wantMerge) print(`note: on '${cur}', so '${cur}' merges into '${branch}' first`)
@@ -482,12 +524,15 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
482
524
  }
483
525
  }
484
526
 
485
- const add = git(['add', ...files])
527
+ const add = git(['add', '--', ...files])
486
528
  if (add.code !== 0) return failKeep('git add failed', 'the version files are written but unstaged. git status, then commit them')
487
529
  const label = selected.map((s) => (s.unit.id === 'root' ? s.version : `${s.unit.id}@${s.version}`)).join(', ')
488
530
  // the hooks already ran through the gate above; --no-verify is NOT used,
489
- // so the repo's own commit gates still apply to this commit
490
- const commit = git(['commit', '-m', `release ${label}`])
531
+ // so the repo's own commit gates still apply to this commit.
532
+ // PATHSPEC, because the index is shared with every other agent in this
533
+ // checkout: a bare commit here tags whatever a neighbour happened to have
534
+ // staged, and the tag then describes a tree nobody gated.
535
+ const commit = git(['commit', '-m', `release ${label}`, '--', ...files])
491
536
  if (commit.code !== 0)
492
537
  return failKeep(`commit failed — ${commit.out}`, 'the version files are written but uncommitted. fix the gate, commit, then `shipkit release`')
493
538
  print(`ok: committed release ${label}`)
@@ -6,6 +6,7 @@
6
6
  import { existsSync } from 'fs'
7
7
  import { join } from 'path'
8
8
  import { dirtyPaths } from './git-facts.ts'
9
+ import { resolveTypecheckProjects } from '../ts/tsconfig-projects.ts'
9
10
  import type { ReleaseUnit } from './units.ts'
10
11
  import type { RepoConfig } from '../repo-config.ts'
11
12
 
@@ -56,10 +57,22 @@ export function unitBlockers(root: string, config: RepoConfig, unit: ReleaseUnit
56
57
  const tsc = resolveTsc(root, unit)
57
58
  if (tsc == null) out.push(`unit '${unit.id}': tsc not resolvable — bun add -d typescript at the repo root`)
58
59
  else {
59
- const r = Bun.spawnSync([tsc, '--noEmit', '-p', unit.tsconfig], { cwd: root })
60
- if (r.exitCode !== 0) {
61
- const lines = `${r.stdout.toString()}${r.stderr.toString()}`.trim().split('\n').slice(0, 8).join('\n')
62
- out.push(`unit '${unit.id}': does not typecheck under its own tsconfig — it cannot be published\n${lines}`)
60
+ // a solution-style tsconfig (`files: []` plus `references`) makes
61
+ // tsc compile ZERO files and exit 0, which would publish a package
62
+ // whose only proof of soundness was that nobody looked
63
+ let projects: ReturnType<typeof resolveTypecheckProjects> | null = null
64
+ try { projects = resolveTypecheckProjects(unit.tsconfig) }
65
+ catch (e) { out.push(`unit '${unit.id}': ${String((e as Error)?.message ?? e)}`) }
66
+ for (const m of projects?.missing ?? [])
67
+ out.push(`unit '${unit.id}': its tsconfig references '${m}', which does not exist, so whatever that project covered is unchecked`)
68
+ if (projects != null && projects.vacuous)
69
+ out.push(`unit '${unit.id}': its tsconfig compiles no files of its own and reaches no project that does, so a typecheck over it proves nothing — it cannot be published`)
70
+ for (const p of projects?.projects ?? []) {
71
+ const r = Bun.spawnSync([tsc, '--noEmit', '-p', p], { cwd: root })
72
+ if (r.exitCode !== 0) {
73
+ const lines = `${r.stdout.toString()}${r.stderr.toString()}`.trim().split('\n').slice(0, 8).join('\n')
74
+ out.push(`unit '${unit.id}': does not typecheck under ${p} — it cannot be published\n${lines}`)
75
+ }
63
76
  }
64
77
  }
65
78
  }
@@ -8,13 +8,14 @@
8
8
  // tool body they used to hold sits at `<id>:raw`. That is what makes a cache
9
9
  // worth having: `bun run test` is the reflex, and the reflex has to land in
10
10
  // shipkit rather than beside it.
11
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'
11
+ import { existsSync, readdirSync, readFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import { canonicalBodies, conditionalBodies, inferBin, ROUTED_SCRIPTS, rawName, routedBody, routesToShipkit } from '../core/canonical-scripts.ts'
14
14
  import type { CheckCtx } from '../core/check-ctx.ts'
15
15
  import { resolveFloor } from '../core/floor.ts'
16
16
  import type { RepoConfig } from '../repo-config.ts'
17
17
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
18
+ import { atomicWrite } from '../core/locked-write.ts'
18
19
 
19
20
  type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string> }
20
21
 
@@ -166,6 +167,11 @@ export const packageJsonShape: Rule = {
166
167
  blocking: true,
167
168
  title: 'package.json',
168
169
  owner: OWNER,
170
+ // the whole tree: package.json is most of it, but the baseline-script
171
+ // check also walks the repo for a `*.test.ts` and probes `.rv-comfy`
172
+ // and `deploy.sh`, so naming three files would key it on less than it
173
+ // reads
174
+ inputs: ['.'],
169
175
  lint: (t: CheckCtx, env: RuleEnv) => evaluatePackageJson(t, env.root, env.config),
170
176
  // mechanical repairs only: field flips + scripts whose canonical body the
171
177
  // vocabulary page states. Bodies it does NOT state (assets, comfy:up) are
@@ -230,7 +236,7 @@ export const packageJsonShape: Rule = {
230
236
  }
231
237
  if (changes.length > 0) {
232
238
  const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
233
- writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
239
+ atomicWrite(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
234
240
  }
235
241
  const summary = [
236
242
  changes.length > 0 ? changes.join(', ') : 'nothing mechanical to change',
@@ -14,6 +14,10 @@ export const contractsFulfillness: Rule = {
14
14
  blocking: true,
15
15
  title: 'contracts',
16
16
  owner: 'meta/contracts.md',
17
+ // the contract engine is pure filesystem (no fetch, no spawn anywhere in
18
+ // contracts/), and WHICH folders it reads depends on the venues declared,
19
+ // so the honest input is the work tree
20
+ inputs: ['.'],
17
21
  lint: async (t, env) => {
18
22
  applyContractsResult(t, await evaluateContractsOnce(activeContracts(env.config), contractsConfigFrom(env.config, env.root)), contractsFulfillness)
19
23
  },
@@ -7,10 +7,11 @@
7
7
  // start --include` flags own which base include a machine wires in. A repo
8
8
  // with neither CLAUDE.md nor agent/ is not a two-line-contract repo and
9
9
  // passes.
10
- import { existsSync, readFileSync, writeFileSync } from 'fs'
10
+ import { existsSync, readFileSync } from 'fs'
11
11
  import { join } from 'path'
12
12
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
13
13
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
14
+ import { atomicWrite } from '../core/locked-write.ts'
14
15
 
15
16
  const AGENT_LINE = '@./agent/index.md'
16
17
  const LOCAL_LINE = '@./CLAUDE.local.md'
@@ -20,6 +21,10 @@ export const claudeMdShape: Rule = {
20
21
  blocking: true,
21
22
  title: 'CLAUDE.md',
22
23
  owner: 'meta/agent-folder.md',
24
+ // CLAUDE.md plus whether agent/index.md exists — and a repo that has
25
+ // neither is the case this rule exists for, so the tree rather than two
26
+ // paths that may not be there
27
+ inputs: ['.'],
23
28
  lint: async (t, env) => {
24
29
  const claudePath = join(env.root, 'CLAUDE.md')
25
30
  const hasAgent = existsSync(join(env.root, 'agent/index.md'))
@@ -59,7 +64,7 @@ export const claudeMdShape: Rule = {
59
64
  if (!existsSync(claudePath)) return { changed: false, summary: 'CLAUDE.md missing — creation needs the base include (shipkit start --include), not autofixable' }
60
65
  const cur = readFileSync(claudePath, 'utf8')
61
66
  if (cur.split('\n').includes(AGENT_LINE)) return { changed: false, summary: `${AGENT_LINE} already routed — remaining findings need a human` }
62
- writeFileSync(claudePath, `${cur.endsWith('\n') || cur === '' ? cur : `${cur}\n`}${AGENT_LINE}\n`)
67
+ atomicWrite(claudePath, `${cur.endsWith('\n') || cur === '' ? cur : `${cur}\n`}${AGENT_LINE}\n`)
63
68
  return { changed: true, summary: `${AGENT_LINE} appended as the last line` }
64
69
  },
65
70
  }
@@ -8,11 +8,12 @@
8
8
  // PLACE — lines below it (e.g. a live venue's `!.rv-itch/` override, where
9
9
  // last-match-wins) keep their position; everything outside the block is
10
10
  // never touched. `shipkit init` writes the same block through this fixer.
11
- import { existsSync, readFileSync, writeFileSync } from 'fs'
11
+ import { existsSync, readFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import type { RepoConfig } from '../repo-config.ts'
14
14
  import { GENERATED_IGNORE_LINES, trackedGeneratedPaths, untrackGenerated } from './untrack-generated.ts'
15
15
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
16
+ import { atomicWrite } from '../core/locked-write.ts'
16
17
 
17
18
  const BEGIN = '# >>> shipkit gitignore policy (SK004) — managed block, edit outside only'
18
19
  const END = '# <<< shipkit gitignore policy'
@@ -33,6 +34,11 @@ const POLICY_LINES = [
33
34
  '.rv-discord/',
34
35
  'CLAUDE.local.md',
35
36
  'tmp/',
37
+ // atomicWrite lands every file via `.<name>.tmp-<pid>` next to its target,
38
+ // so a run killed mid write leaves one behind ANYWHERE in the tree, and an
39
+ // agent finds untracked debris it did not create. The ignore row ships in
40
+ // the same policy that started writing them.
41
+ '.*.tmp-*',
36
42
  // comfy-ts state (schema dumps, drafts) and per-workflow lora tags name a
37
43
  // private model collection; shipkit itself writes .comfy-ts/ at hub boot,
38
44
  // so every consumer repo needs both ignored before its first commit
@@ -92,7 +98,7 @@ function writeBlock(root: string, config: RuleEnv['config']): void {
92
98
  out = kept.length > 0 ? [...kept, '', ...block] : block
93
99
  }
94
100
  const eol = f?.crlf === true ? '\r\n' : '\n'
95
- writeFileSync(p, `${out.join(eol)}${out.at(-1) === '' ? '' : eol}`)
101
+ atomicWrite(p, `${out.join(eol)}${out.at(-1) === '' ? '' : eol}`)
96
102
  }
97
103
 
98
104
  export const gitignorePolicy: Rule = {
@@ -100,6 +106,9 @@ export const gitignorePolicy: Rule = {
100
106
  blocking: true,
101
107
  title: '.gitignore',
102
108
  owner: 'meta/contracts.md',
109
+ // .gitignore plus what git currently TRACKS, which the tree carries and a
110
+ // single path does not
111
+ inputs: ['.'],
103
112
  lint: (t, env) => {
104
113
  // a generated path still in the index is the half-applied state the
105
114
  // managed block cannot fix on its own
@@ -16,6 +16,12 @@
16
16
  // never touch
17
17
  // - package.json/lockfile git-dirty (or no git) → WARN deferred,
18
18
  // a tool must not mix its writes into human-staged work
19
+ // - another run already holds the repo's mutate lease → the rule
20
+ // WAITS for it, re-reads the dirty probe inside the lease, and
21
+ // defers with a WARN if that run already did the work. Two
22
+ // `bun update --latest` calls against one node_modules is how a
23
+ // tree ends up half installed, and demoting the rule to report
24
+ // only instead would break "machine clearable red clears ITSELF"
19
25
  // - the update itself failing splits by what it LEFT: a half-
20
26
  // written package.json/lockfile is this repo and FAILs, an
21
27
  // untouched tree is the registry and only WARNs
@@ -31,6 +37,7 @@
31
37
  import { existsSync, readFileSync } from 'fs'
32
38
  import { join } from 'path'
33
39
  import type { CheckCtx } from '../core/check-ctx.ts'
40
+ import { mutateLease, withMutateLease } from '../core/locked-write.ts'
34
41
  import type { Rule, RuleEnv } from './rule.ts'
35
42
 
36
43
  type Pkg = { version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
@@ -105,7 +112,27 @@ export const rvlibVersions: Rule = {
105
112
  return
106
113
  }
107
114
  const names = stale.map((s) => s.name)
108
- const up = await t.exec(['bun', 'update', '--latest', ...names], { cwd: env.root, timeoutMs: 120_000 })
115
+ // this rule WRITES, and three agents in one checkout is the normal
116
+ // case: two `bun update --latest` runs against one node_modules and
117
+ // one lockfile is how a tree ends up half installed. The lease is what
118
+ // lets the rule keep repairing itself instead of being demoted to
119
+ // report only, which would break "machine clearable red clears ITSELF".
120
+ // The dirty probe above is re-run INSIDE the lease, because the holder
121
+ // we waited for may have just done this exact update.
122
+ const leased = await withMutateLease(mutateLease(env.root, 'fix'), { label: 'self-update', print: () => {}, escape: 'SHIPKIT_FROZEN=1' }, async () => {
123
+ const again = await t.exec(['git', 'status', '--porcelain', '--', 'package.json', 'bun.lock', 'bun.lockb'], { cwd: env.root })
124
+ if (again.code === 0 && again.out.trim() !== '') return null
125
+ return await t.exec(['bun', 'update', '--latest', ...names], { cwd: env.root, timeoutMs: 120_000 })
126
+ })
127
+ if (!leased.ok) {
128
+ t.warn(`newer rvlib live: ${drift} — self-update deferred (${leased.warn}); re-run check once that run finishes`)
129
+ return
130
+ }
131
+ if (leased.value == null) {
132
+ t.warn(`newer rvlib live: ${drift} — self-update deferred (package.json/lockfile went dirty while waiting for the write lease); commit first, then re-run check`)
133
+ return
134
+ }
135
+ const up = leased.value
109
136
  if (up.code !== 0) {
110
137
  // which axis the failure sits on is measurable, so measure it: the
111
138
  // tree is either half-written (this repo, blocks) or untouched, and
@@ -37,6 +37,9 @@ export const npmPublishSafety: Rule = {
37
37
  blocking: true,
38
38
  title: 'npm publish safety',
39
39
  owner: 'meta/rv-conventions/package-json.md',
40
+ // pure filesystem: the root package.json plus every declared unit's, at
41
+ // dirs the config picks, so the tree rather than a list of paths
42
+ inputs: ['.'],
40
43
  lint: (t, env) => {
41
44
  const p = join(env.root, 'package.json')
42
45
  if (!existsSync(p)) return t.ok('no package.json — nothing publishable')
@@ -7,11 +7,12 @@
7
7
  // that edits by proxy. An absent `tools:` line is the worst case of all: the
8
8
  // harness then inherits every tool. The brief itself must exist. No
9
9
  // declaration = not a reviewer repo, and the rule passes silently.
10
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
10
+ import { existsSync, mkdirSync, readFileSync } from 'fs'
11
11
  import { homedir } from 'os'
12
12
  import { dirname, isAbsolute, join } from 'path'
13
13
  import type { RepoConfig } from '../repo-config.ts'
14
14
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
15
+ import { atomicWrite } from '../core/locked-write.ts'
15
16
 
16
17
  /** default read-only toolset — an ALLOWLIST, not a denylist: a tool that can
17
18
  * edit by proxy (spawning a subagent, an MCP writer) is exactly what a
@@ -72,6 +73,9 @@ export const reviewerAgent: Rule = {
72
73
  blocking: true,
73
74
  title: 'reviewer agent',
74
75
  owner: 'identity/how-we-work.md',
76
+ // the brief is a CONFIG path, and a `~/...` or absolute one puts it
77
+ // outside the repo, where no tree id can see whether it still exists
78
+ never: 'the reviewer brief may live outside the repo, where no tree id reaches it',
75
79
  lint: (t, env) => {
76
80
  const r = env.config.reviewer
77
81
  if (r == null) return t.ok('no reviewer agent declared (repo.config reviewer:) — skipped')
@@ -93,7 +97,7 @@ export const reviewerAgent: Rule = {
93
97
  const p = join(env.root, rel)
94
98
  if (existsSync(p)) return { changed: false, summary: `${rel} exists — remaining findings are about its content, fix them by hand` }
95
99
  mkdirSync(dirname(p), { recursive: true })
96
- writeFileSync(p, canonicalAgent(r))
100
+ atomicWrite(p, canonicalAgent(r))
97
101
  return { changed: true, summary: `${rel} written — points at ${r.brief}, no edit tools` }
98
102
  },
99
103
  }
@@ -29,6 +29,9 @@ export const commitAiTells: Rule = {
29
29
  blocking: true,
30
30
  title: 'commit ai tells',
31
31
  owner: 'identity/how-we-commit.md',
32
+ // it scans the full `git log` and every ref name. History and refs are not
33
+ // work-tree content, so nothing a scan can produce describes this answer
34
+ never: 'reads the full commit history and every ref name, neither of which is work-tree content',
32
35
  lint: async (t, env) => {
33
36
  const declared = env.config.visibility ?? 'private'
34
37
  if (declared !== 'public')
@@ -65,6 +65,8 @@ export const undeclaredReleaseUnit: Rule = {
65
65
  blocking: true,
66
66
  title: 'release units declared',
67
67
  owner: 'build/shipkit.md',
68
+ // `git ls-files` plus a walk for every package.json in the repo
69
+ inputs: ['.'],
68
70
  lint: (t, env) => {
69
71
  const { units, errors } = resolveUnits(env.root, env.config)
70
72
  // a DECLARED unit that does not resolve is a declaration bug and
@@ -59,6 +59,8 @@ export const siblingUnitRanges: Rule = {
59
59
  blocking: true,
60
60
  title: 'sibling unit ranges',
61
61
  owner: 'build/shipkit.md',
62
+ // every declared unit's package.json, at dirs the config picks
63
+ inputs: ['.'],
62
64
  lint: (t, env) => {
63
65
  const { units } = resolveUnits(env.root, env.config)
64
66
  const version = new Map(units.map((u) => [u.name, u.version]))
@@ -10,9 +10,10 @@
10
10
  // A barrel is a fine PUBLIC surface and a terrible one to import from a config
11
11
  // file that exists to be read before anything else happens. This rule is
12
12
  // narrow on purpose: it is about repo.config.ts, not about barrels in general.
13
- import { existsSync, readFileSync, writeFileSync } from 'fs'
13
+ import { existsSync, readFileSync } from 'fs'
14
14
  import { join } from 'path'
15
15
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
16
+ import { atomicWrite } from '../core/locked-write.ts'
16
17
 
17
18
  const CONFIG = 'repo.config.ts'
18
19
  const BARREL = /(^|\n)(\s*import\s+(?:type\s+)?\{[^}]*\}\s+from\s+)['"]rvlib-shipkit['"]/
@@ -31,6 +32,9 @@ export const configBarrelImport: Rule = {
31
32
  blocking: true,
32
33
  title: 'config import path',
33
34
  owner: 'build/shipkit.md',
35
+ // ONE file, and the one file shipkit guarantees exists: loadRepoConfig
36
+ // refuses a repo without it
37
+ inputs: ['repo.config.ts'],
34
38
  lint: (t, env) => {
35
39
  const p = join(env.root, CONFIG)
36
40
  if (!existsSync(p)) return void t.ok(`${CONFIG} absent — nothing to narrow`)
@@ -54,7 +58,7 @@ export const configBarrelImport: Rule = {
54
58
  // only rewrite what the narrow module actually exports: silently
55
59
  // repointing an import it cannot serve turns a slow gate into a broken one
56
60
  if (importedSymbols(line).some((s) => !CONFIG_SYMBOLS.has(s))) return { changed: false, summary: 'imports symbols the narrow module does not export — move them by hand' }
57
- writeFileSync(p, src.replace(BARREL, `$1$2'${NARROW}'`))
61
+ atomicWrite(p, src.replace(BARREL, `$1$2'${NARROW}'`))
58
62
  return { changed: true, summary: `${CONFIG} now imports ${NARROW} (~400ms saved per run)` }
59
63
  },
60
64
  }
@@ -12,12 +12,13 @@
12
12
  // Both are generated from ONE table (core/one-way-in-policy.ts). The file is
13
13
  // TRACKED on purpose: `.claude/settings.local.json` is gitignored by the house
14
14
  // scaffold, so a policy written there would bind exactly one checkout.
15
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
15
+ import { existsSync, mkdirSync, readFileSync } from 'fs'
16
16
  import { dirname, join } from 'path'
17
17
  import { inferBin } from '../core/canonical-scripts.ts'
18
18
  import { denyRules, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
19
19
  import type { RepoConfig } from '../repo-config.ts'
20
20
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
21
+ import { atomicWrite } from '../core/locked-write.ts'
21
22
 
22
23
  const OWNER = 'build/shipkit.md'
23
24
  export const SETTINGS_PATH = '.claude/settings.json'
@@ -44,6 +45,11 @@ export function guardCommand(bin?: string): string {
44
45
  return `bun "$CLAUDE_PROJECT_DIR/${path}"`
45
46
  }
46
47
 
48
+ /** a guard under `node_modules/` that is not there yet says the dependency is
49
+ * not installed, which `bun install` fixes and no edit to this repo can. Any
50
+ * other unresolvable path is a config this repo chose and got wrong. */
51
+ const isUninstalledDep = (rel: string): boolean => rel.startsWith('node_modules/')
52
+
47
53
  const readSettings = (root: string): Settings | null => {
48
54
  const p = join(root, SETTINGS_PATH)
49
55
  if (!existsSync(p)) return null
@@ -81,6 +87,9 @@ export const oneWayInPolicy: Rule = {
81
87
  blocking: true,
82
88
  title: 'one way in policy',
83
89
  owner: OWNER,
90
+ // .claude/settings.json, package.json, and whether the declared guard
91
+ // script resolves — the last one is a config-chosen path
92
+ inputs: ['.'],
84
93
  lint: (t, env) => {
85
94
  const want = denyRules(exemptSlots(env.config))
86
95
  if (want.length === 0) return t.ok('every governed slot is exempt (repo.config oneWayIn/defaults) — nothing to deny')
@@ -98,10 +107,21 @@ export const oneWayInPolicy: Rule = {
98
107
  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`)
99
108
  else {
100
109
  const missing = guardScriptMissing(env.root, guard)
101
- if (missing != null)
110
+ // the hook is DECLARED either way, so it starts working the moment
111
+ // the dependency lands: no second fix run, nothing to remember. An
112
+ // uninstalled dependency is therefore a WARN naming the command
113
+ // that fixes it, not a red gate on a repo nobody has installed yet,
114
+ // and the deny list is the floor throughout. A path this repo chose
115
+ // and got wrong is still a FAIL: it will never resolve on its own.
116
+ if (missing != null && isUninstalledDep(missing))
117
+ t.warn(
118
+ `${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which is not installed yet — until then a refusal names no replacement, and the deny list is the whole floor`,
119
+ `bun install (or bun add -d rvlib-shipkit)`,
120
+ )
121
+ else if (missing != null)
102
122
  t.fail(
103
123
  `${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which does not exist — every Bash call errors and no refusal ever names the shipkit command`,
104
- `bun install (or bun add -d rvlib-shipkit)`,
124
+ `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`,
105
125
  )
106
126
  }
107
127
  if (!t.failed) t.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s), with the replacement named`)
@@ -130,7 +150,7 @@ export const oneWayInPolicy: Rule = {
130
150
  }
131
151
  if (changes.length === 0) return { changed: false, summary: `${SETTINGS_PATH} already closes every raw runner` }
132
152
  mkdirSync(dirname(p), { recursive: true })
133
- writeFileSync(p, JSON.stringify(s, null, 2) + '\n')
153
+ atomicWrite(p, JSON.stringify(s, null, 2) + '\n')
134
154
  return { changed: true, summary: `${SETTINGS_PATH}: ${changes.join(', ')}` }
135
155
  },
136
156
  }
package/src/rules/rule.ts CHANGED
@@ -28,8 +28,29 @@ export type Rule = {
28
28
  * as the fix: pointer whenever a failing/warning lint attached none */
29
29
  owner: string
30
30
  /** clean-pass cache duration (check-suite TTL, e.g. '24h') — for rules that
31
- * probe the network; skip/WARN/FAIL runs are never cached */
31
+ * probe the network; skip/WARN/FAIL runs are never cached. Declares the
32
+ * WORLD population (ledger/git-key.ts). */
32
33
  ttl?: string
34
+ /** CONTENT population: the repo-relative paths this rule reads. A rule with
35
+ * neither `inputs` nor `ttl` is never cached, which is the safe default.
36
+ * A rule declared by a REPO rather than by shipkit must list the dir its
37
+ * own code lives in too: shipkit's `src` tree id covers the built-ins and
38
+ * cannot see a repo's. Declaring both `inputs` and `ttl` is a registration
39
+ * failure — a rule reads files or it reads the world, never both. */
40
+ inputs?: string[]
41
+ /** repo-relative entry file(s) of this rule's OWN code, walked through the
42
+ * module graph so a shared helper the rule imports is in its key too. A
43
+ * built-in SK rule needs none: the toolchain identity content-hashes
44
+ * shipkit's whole source, so editing any of it moves every key. A rule a
45
+ * REPO declares is invisible to that, so it names its module here, or
46
+ * includes the dir holding it in `inputs`. Without either, fixing a bug
47
+ * in the rule does not move its key. */
48
+ code?: string[]
49
+ /** a DECIDED never-cacheable, with the reason: this rule reads git history,
50
+ * a path that may live outside the repo, or the wall clock, so no key can
51
+ * describe it. Says somebody looked, which is what takes it off the
52
+ * burn-down list. */
53
+ never?: string
33
54
  /** cache-identity contribution for TTL'd rules whose verdict depends on
34
55
  * config — a changed key busts the cache instantly instead of replaying
35
56
  * a stale assertion for the TTL */
@@ -47,9 +47,11 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
47
47
  continue
48
48
  }
49
49
  const f = await rule.fix!(env)
50
- // an unchanged fix is the DESIGNED outcome for judgment findings —
51
- // a note, not a warning
52
- print(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `[${rule.id}] fix changed nothing — ${f.summary}`)
50
+ // an unchanged fix is the DESIGNED outcome for judgment findings, so
51
+ // it is an ok, not a warning. It carries a frozen wire prefix like
52
+ // every other line the fix pass emits: `shipkit ci` promises its whole
53
+ // output is parseable, and a bare line breaks that for every reader.
54
+ print(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `ok: [${rule.id}] fix changed nothing — ${f.summary}`)
53
55
  const after = new CheckCtx(new Map())
54
56
  await runRuleLint(rule, after, env)
55
57
  // the fix already ran: advertising it again on surviving findings
@@ -11,7 +11,7 @@ description: Prove a change in THIS project actually works — drive the real fl
11
11
 
12
12
  ## The rules (house-wide)
13
13
  - Failing repro test BEFORE the fix. Green checks ≠ verification.
14
- - Logic → fast headless bun tests, run through `shipkit ci --only test` (tiny ~10-line test files reusing fixtures). While you fix one bug, `bun run test:raw <name filter>` is the narrow way in.
14
+ - Logic → fast headless bun tests, run through `shipkit ci --only test` (tiny ~10-line test files reusing fixtures). While you fix one bug, `bun run test:raw <test file>` is the narrow way in, and one test by name is `bun run test:raw -- -t "<name>"`.
15
15
  - Look & feel → NEVER screenshot-self-verify; the repo owner playtests it.
16
16
  - Verify as the end user would: drive the real flow, observe the real signal.
17
17