rvlib-shipkit 0.33.0 → 0.35.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 (50) hide show
  1. package/CHANGELOG.md +23 -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_Ci.ts +3 -0
  6. package/src/cli/cmd_Keys.ts +113 -0
  7. package/src/cli.ts +2 -0
  8. package/src/core/check-suite.ts +54 -47
  9. package/src/core/discover-checks.ts +30 -7
  10. package/src/core/floor.ts +34 -6
  11. package/src/core/locked-write.ts +170 -1
  12. package/src/core/motherhome.ts +54 -1
  13. package/src/core/one-way-in-policy.ts +8 -8
  14. package/src/core/run-check.ts +92 -12
  15. package/src/core/run-ci.ts +115 -17
  16. package/src/core/run-fixers.ts +61 -18
  17. package/src/core/task-runner.ts +62 -6
  18. package/src/hooks/run-hooks.ts +23 -2
  19. package/src/ledger/gate-units.ts +84 -0
  20. package/src/ledger/git-key.ts +381 -0
  21. package/src/ledger/git-scan.ts +327 -0
  22. package/src/ledger/ledger.ts +202 -0
  23. package/src/ledger/store.ts +122 -0
  24. package/src/ledger/toolchain-identity.ts +45 -8
  25. package/src/pb/collection-scaffold.ts +4 -4
  26. package/src/release/run-bump.ts +50 -5
  27. package/src/release/run-release.ts +5 -0
  28. package/src/release/run-releases.ts +7 -1
  29. package/src/release/unit-gate.ts +17 -4
  30. package/src/repo-config.ts +26 -4
  31. package/src/rules/SK001-package-json-shape.ts +8 -2
  32. package/src/rules/SK002-contracts-fulfillness.ts +4 -0
  33. package/src/rules/SK003-claude-md-shape.ts +7 -2
  34. package/src/rules/SK004-gitignore-policy.ts +12 -4
  35. package/src/rules/SK005-rvlib-versions.ts +28 -1
  36. package/src/rules/SK006-npm-publish-safety.ts +3 -0
  37. package/src/rules/SK008-reviewer-agent.ts +6 -2
  38. package/src/rules/SK009-commit-ai-tells.ts +3 -0
  39. package/src/rules/SK010-undeclared-release-unit.ts +2 -0
  40. package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
  41. package/src/rules/SK012-config-barrel-import.ts +6 -2
  42. package/src/rules/SK013-one-way-in-policy.ts +24 -4
  43. package/src/rules/rule.ts +22 -1
  44. package/src/rules/run-fix.ts +5 -3
  45. package/src/scaffold/files/verify-skill.md +1 -1
  46. package/src/ts/config.ts +66 -6
  47. package/src/ts/cycles.ts +7 -92
  48. package/src/ts/source-graph.ts +158 -0
  49. package/src/ts/tsconfig-projects.ts +101 -0
  50. package/src/ts/typecheck-cli.ts +46 -0
package/CHANGELOG.md CHANGED
@@ -1,12 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.0
4
+
5
+ - **`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.
6
+
7
+ ## 0.34.0
8
+
9
+ - **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.
10
+
11
+ - **Three agents in ONE checkout is the normal case, and shipkit is now correct under it.** Everything a run WRITES to the worktree (the fix pass, SK005's `bun update --latest`, hooks arming, the asks archive) takes a blocking lease named for that concern, under the gitignored `tmp/`. The loser waits, because a skipped repair never happens; a holder that is gone or wedged is stolen; a live one is refused by NAME with the lock path and the flag that skips the phase, capped at 60s so nothing hangs. Measured on five real gates racing one repository: with the lease, five of five read-modify-writes survive; without it, four of five are lost and nothing on screen says so. Both lock primitives were raced before they were trusted, and both first versions failed: `writeFileSync(..., 'wx')` publishes a NAME before a body, so a reader in that window steals a live holder's lease (6 of 20 trials overlapped), and a `read, rmSync, grab` steal lets every racer win it (18 of 40 overlapped, peak 3 inside the phase at once). The lock is `linkSync`d into place from a complete body, and a steal is a single `renameSync` that exactly one racer can perform.
12
+ - **The check cache is one immutable file per check, version and cache key**, under `<cacheDir>/<suite>/`, replacing one aggregate json read at the top of a run and rewritten at the bottom, unlocked. Three reproduced failures die with it: two overlapping runs where the second's write drops every entry the first recorded, a torn read that a bare catch turned into an empty cache, and a prune step that deleted "entries for checks I do not know about", which is exactly what a run narrowed by `--only` is. Nothing merges, nothing prunes, no lock is taken, and no check touches a neighbour's entry. Superseded key files are swept by AGE after 30 days, which needs no knowledge of the current check set.
13
+ - **Every task run owns `tmp/shipkit-ci/run-<pid>-<started>-<seq>/`.** The runner used to wipe the shared log dir at the top of every run, so a second gate deleted the logs the first was still streaming into and its FAIL line pointed at a path that no longer existed. Old dirs, and the per run release survey files a killed run leaves behind, are swept only when the pid is gone and the grace window has passed.
14
+ - **`shipkit bump` and `shipkit release` commit with a PATHSPEC.** A bare `git commit` takes the whole index, and agents in one checkout share one index, so a neighbour who staged a file a second earlier got it committed under "release 0.4.0" and tagged. Bump also asks `git worktree list` before merging and refuses by name when the release branch is checked out in another worktree, instead of dying on a raw git fatal or moving another agent's HEAD.
15
+ - **`core.hooksPath` retries on lock contention.** Two worktrees share one `.git/config`, so two runs arming at once made one of them report "hooks will not fire" about a repo whose hooks were fine.
16
+ - **Under CI, a fix pass that never ran now FAILs.** In verify mode the fix pass IS the check, so reporting "not run" and exiting 0 passed a commit nobody verified.
17
+ - **BREAKING, one export**: `checkAsks(dir, print)` is now `checkAsks(root, dir, print)` and returns a Promise, because the archive takes the repo's lease. Two concurrent runs used to spawn every probe twice and then throw an uncaught ENOENT when the loser renamed a file the winner had already moved.
18
+ - Every rule fixer writes through `atomicWrite`, so a killed run cannot leave a half written `package.json`, `.gitignore`, `CLAUDE.md` or `.claude/settings.json`. SK004's managed block gains `.*.tmp-*` in the same change, because that is the debris such a kill leaves anywhere in the tree.
19
+ - `run-fix.ts`'s unchanged-fix line carries a wire prefix. `shipkit ci` promises every line it emits is parseable, and that one was bare.
20
+
21
+ - **The typecheck slot could report green over a file that does not compile.** A solution-style `tsconfig.json` (`"files": []` plus `references`, which is the standard vite shape) makes `tsc --noEmit` compile ZERO files and exit 0, so the built-in typecheck floor printed `ok: typecheck (0.1s)` with a type error sitting in `src/`. Every other failure in this tool is loud; this one was a green indistinguishable from a measured one. `ts/tsconfig-projects.ts` now resolves the set of projects a typecheck must visit: it walks `references`, and it keeps a referencing config in that set whenever the config compiles inputs of its OWN, because `references` does not suppress them. Only `"files": []` with no `include`, or an empty `include`, makes a config compile nothing; a bare `{ references: [...] }` still compiles the whole tree, since tsc defaults `include` to `**/*`. A reference may name a directory, cycles terminate, and a config reaching nothing that compiles is reported as VACUOUS. The floor spawns `ts/typecheck-cli.ts`, which resolves at RUN time so a tsconfig edited after resolution still counts, runs every project instead of stopping at the first red, and on success NAMES the projects it checked. A vacuous config or a dangling reference registers that runner too and exits non-zero, rather than leaving the slot unfilled: an unfilled slot is a skip, the gate still exits 0, and a repo with no typecheck coverage would read exactly like one that passed. `release/unit-gate.ts` ran the same vacuous command as a PUBLISH gate and got the same treatment.
22
+ - **`parseJsonc` is a quote-aware scanner instead of two regexes**, because tsconfig is the one dialect where the regex version cannot work: `"src/**/*"` is a `/*` carrying its own `*/`, so a block-comment regex eats an arbitrary span of a valid file. Measured over the real tsconfigs on this machine, the old version THREW on 8 of them and silently rewrote `"src/**/*.ts"` to `"src*.ts"` in 8 more. It parses 80 of 80 now. This matters more than it did yesterday: the same parser gates typecheck and publish.
23
+ - **`bun run test:raw` takes a FILE filter, not a name filter.** Measured: bun's positional argument matches the test file path, and a single test by name is `bun run test:raw -- -t "<name>"`. The wrong form was in 0.33.0's own notes, in two verify-skill templates, on `build/shipkit.md`, and in the refusal message an agent reads at the exact moment it is blocked.
24
+
3
25
  ## 0.33.0
4
26
 
5
27
  - **ONE WAY IN, enforced. A wired repo's `test`, `lint` and `typecheck` scripts route through shipkit, and the harness refuses the raw runner.** Measured in a real wired repo before this: `"lint": "eslint ."`, `"test": "bun test"`, no ci script, an empty `.claude/`. An agent doing the obvious thing ran the tool, shipkit never heard the call, and the cache it was building was worthless. **This changes the shape of every wired repo's package.json**, and 0.32.0 already shipped the first half of it without saying so, which is the gap this note closes. Rule SK001 (blocking, autofixable) now FAILs a governed script that runs the tool directly; the fix slides the body to `<id>:raw` and makes the name `shipkit ci --only <id>`. `<id>:raw` is what the gate itself runs, and what you use to iterate on one suite. Those THREE names only: `imports:check` and `cycles` are shipkit's own vocabulary, and `fmt` is the slot the fix pass invokes by script, so routing it would make the fixer call itself. `resolveFloor` moved to `core/floor.ts` and refuses to run a routed script, which spawns the gate from inside the gate.
6
28
  - **New rule SK013 (blocking, autofixable) writes the harness half**, because a package.json cannot reach it: `bun test` is a bun builtin and no script can intercept it. A tracked `.claude/settings.json` gets `permissions.deny` for every raw runner plus a PreToolUse guard, both rendered from ONE table. The deny is the floor, unbypassable in either direction; the guard is the messenger, because a bare deny tells the model only that it was denied and it then goes hunting for another runner. Every refusal names the shipkit command, the narrow way in, and the exact line that lifts it. The guard reads the deny list out of the settings FILE, so deleting one `Bash(...)` line lifts both mechanisms at once. It also catches what a prefix rule cannot (`CI=1 bun test`, `./node_modules/.bin/tsc`) and segments at quote depth zero, so a commit message quoting a runner is data. SK013 FAILs when the declared guard script does not resolve: a hook that errors refuses nothing.
7
29
  - **`shipkit init` repairs an existing repo, not just a fresh one**, calling the same fixers `shipkit check --fix` runs. That is the half that matters, because the repos that need this most already exist. `shipkit start` inherits it.
8
30
  - **A `--only` run repairs the mechanical slots ALONE.** The structural rule fixers rewrite package.json and repo config, and arming rewrites `hooks/`. That is the right price for the full gate and the wrong one for `bun run test`, which now routes here: the reflex verb must not leave files in an agent's diff that it never wrote, and under CI a formatting rewrite came back as a test failure.
9
- - **Escaping it**: delete the `Bash(<runner>*)` line from `.claude/settings.json`; or `repo.config.ts oneWayIn: { exempt: ['test'] }`, which drops the script requirement and the shell rules together (an unknown name throws at config load); a slot turned off with `defaults: { lint: false }` is exempt already. `bun run test:raw <name filter>` stays open for iterating on one suite. A repo with sub-packages gives each one its own `test:raw`, or the escape does not reach where the work is. The formatter is deliberately not denied at all.
31
+ - **Escaping it**: delete the `Bash(<runner>*)` line from `.claude/settings.json`; or `repo.config.ts oneWayIn: { exempt: ['test'] }`, which drops the script requirement and the shell rules together (an unknown name throws at config load); a slot turned off with `defaults: { lint: false }` is exempt already. `bun run test:raw <test file>` stays open for iterating on one suite, and one test by name is `bun run test:raw -- -t "<name>"`. A repo with sub-packages gives each one its own `test:raw`, or the escape does not reach where the work is. The formatter is deliberately not denied at all.
10
32
  - `--allow-dirty` now says WHY it is safe in one place and not the other: `npm publish` packs the working tree, not the commit, so dirt in other paths cannot reach the tarball and dirt inside the unit ships a half-written file under a tag that describes something else.
11
33
 
12
34
  ## 0.32.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
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",
@@ -6,6 +6,7 @@
6
6
  // Stdout contract (FROZEN — consumers parse the prefixes): `closed +
7
7
  // archived <file>` / `archived <file>` / `still <status> <file>` / `N closed`.
8
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'fs'
9
+ import { atomicWrite, mutateLease, withMutateLease } from '../core/locked-write.ts'
9
10
  import { join } from 'path'
10
11
 
11
12
  export type Ask = { file: string; status: string; kind: string; created: string; blocks: string; check: string | null; title: string }
@@ -53,7 +54,19 @@ export function doneAsks(dir: string): Ask[] {
53
54
  return readdirSync(done).filter((f) => f.endsWith('.md')).sort().reverse().map((f) => parse(done, f))
54
55
  }
55
56
 
56
- export function checkAsks(dir: string, print: (line: string) => void = console.log): { closed: number } {
57
+ /** probe every open ask and archive the ones that now pass. Under the repo's
58
+ * mutate lease: two runs racing here each spawn every probe (which reach the
59
+ * world), and then one of them renames a file the other already moved, which
60
+ * is an uncaught ENOENT rather than a message. The loser waits and finds
61
+ * nothing left to close, which is the correct answer. */
62
+ export async function checkAsks(root: string, dir: string, print: (line: string) => void = console.log): Promise<{ closed: number }> {
63
+ const leased = await withMutateLease(mutateLease(root, 'asks'), { label: 'asks archive', print, maxWaitMs: 45_000 }, () => checkAsksBody(dir, print))
64
+ if (leased.ok) return leased.value
65
+ print(`WARN asks check skipped — ${leased.warn}`)
66
+ return { closed: 0 }
67
+ }
68
+
69
+ function checkAsksBody(dir: string, print: (line: string) => void): { closed: number } {
57
70
  const done = join(dir, 'done')
58
71
  mkdirSync(done, { recursive: true })
59
72
  let closed = 0
@@ -63,7 +76,7 @@ export function checkAsks(dir: string, print: (line: string) => void = console.l
63
76
  const p = Bun.spawnSync(['bash', '-lc', a.check])
64
77
  if (p.exitCode === 0) {
65
78
  const md = readFileSync(join(dir, a.file), 'utf8').replace(/^status:.*$/m, `status: done\nclosed: ${new Date().toISOString().slice(0, 10)}`)
66
- writeFileSync(join(dir, a.file), md)
79
+ atomicWrite(join(dir, a.file), md)
67
80
  renameSync(join(dir, a.file), join(done, a.file))
68
81
  print(`closed + archived ${a.file}`)
69
82
  closed++
@@ -41,7 +41,8 @@ export class AsksCheckCommand extends Command {
41
41
 
42
42
  async execute(): Promise<number> {
43
43
  try {
44
- checkAsks(await asksDir(process.cwd()))
44
+ const root = process.cwd()
45
+ await checkAsks(root, await asksDir(root))
45
46
  return 0
46
47
  } catch (e) {
47
48
  this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
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
  })
@@ -0,0 +1,113 @@
1
+ // shipkit keys — what every gate unit is keyed on, and what it is NOT.
2
+ //
3
+ // Nothing is cached yet. This command exists so the key layer is auditable by
4
+ // eye BEFORE anything replays off it: a cache whose keys nobody can read is a
5
+ // cache nobody can debug the day it serves a wrong answer. `--why <id>` prints
6
+ // the exact component list a key was hashed from, which is the only honest
7
+ // answer to "why did this key move".
8
+ import { createHash } from 'crypto'
9
+ import { Command, Option } from 'clipanion'
10
+ import { loadRepoConfig } from '../repo-config.ts'
11
+ import { envTier, FULL_TESTS_ENV, gateUnits, tieredTasks } from '../ledger/gate-units.ts'
12
+ import { KeyLayer } from '../ledger/git-key.ts'
13
+
14
+ export class KeysCommand extends Command {
15
+ static override paths = [['keys']]
16
+
17
+ static override usage = Command.Usage({
18
+ category: 'Health',
19
+ description: 'the cache key of every gate unit (task, rule, check), and the reason the undeclared ones have none. Reports, never gates',
20
+ examples: [
21
+ ['every unit and its key', 'shipkit keys'],
22
+ ['only the ones nothing can cache yet', 'shipkit keys --burn-down'],
23
+ ['why this key is what it is', 'shipkit keys --why typecheck'],
24
+ ['re-hash after the fact and report drift', 'shipkit keys --rescan'],
25
+ ['the keys a --full gate asks for', 'shipkit keys --full'],
26
+ ['machine-readable', 'shipkit keys --json'],
27
+ ],
28
+ })
29
+
30
+ json = Option.Boolean('--json', false, { description: 'machine-readable { units: UnitKey[], burnDown, errors, scanMs }' })
31
+ burnDown = Option.Boolean('--burn-down', false, { description: 'list only the units nothing can cache yet, and why' })
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` })
34
+ rescan = Option.Boolean('--rescan', false, { description: 'run the post-run re-hash and name every input that moved since the scan' })
35
+
36
+ async execute(): Promise<number> {
37
+ const root = process.cwd()
38
+ const c = await loadRepoConfig(root)
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))
44
+ const layer = KeyLayer.open(root, decls, { cliEntry: new URL('../cli.ts', import.meta.url).pathname })
45
+ const keys = layer.keys()
46
+ const out = (l: string): void => { this.context.stdout.write(`${l}\n`) }
47
+
48
+ if (this.json) {
49
+ const drift = this.rescan ? layer.rescan() : null
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 }))
51
+ return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
52
+ }
53
+
54
+ if (this.why != null) {
55
+ const k = keys.find((u) => u.id.toLowerCase() === this.why!.toLowerCase())
56
+ if (k == null) {
57
+ out(`FAIL: no gate unit '${this.why}' — run \`shipkit keys\` for the list`)
58
+ return 1
59
+ }
60
+ out(`${k.kind} ${k.id} — ${k.population}`)
61
+ if (k.key == null) {
62
+ out(` no key: ${k.reason ?? 'undeclared'}`)
63
+ return 0
64
+ }
65
+ out(` key ${k.key}`)
66
+ // the excluded block is 44 lines here and it buries the component
67
+ // that actually moved. It stays HASHED line by line, so this is a
68
+ // rendering choice and not a different key: the digest moves with
69
+ // the set, which is what answers "why did this move", and --json
70
+ // still carries every path.
71
+ const excluded = k.components.filter((c) => c.startsWith('excluded='))
72
+ for (const comp of k.components) if (!comp.startsWith('excluded=')) out(` ${comp}`)
73
+ if (excluded.length > 0) {
74
+ const digest = createHash('sha256').update(excluded.join('\n')).digest('hex').slice(0, 8)
75
+ out(` excluded ${excluded.length} path(s), digest ${digest} — not in the key's content, only in its meaning`)
76
+ for (const c of excluded.slice(0, 3)) out(` ${c.slice('excluded='.length)}`)
77
+ if (excluded.length > 3) out(` … ${excluded.length - 3} more (shipkit keys --json)`)
78
+ }
79
+ return 0
80
+ }
81
+
82
+ for (const e of layer.registration.errors) out(`FAIL: ${e}`)
83
+ if (layer.failure != null) {
84
+ out(`FAIL: git scan failed, no unit may be keyed — ${layer.failure}`)
85
+ out(' fix: nothing is cached, so the gate is unaffected. A stale scratch index clears with: rm .git/shipkit/scan.idx')
86
+ }
87
+
88
+ // the burn-down is what nobody has LOOKED at. A unit that declared
89
+ // `never` with a reason has been looked at, and listing it here would
90
+ // put four permanent rows on a list whose whole job is to shrink
91
+ const decided = new Set(layer.decls.filter((d) => d.never != null && d.never !== '').map((d) => d.id))
92
+ const shown = this.burnDown ? keys.filter((k) => k.population === 'none' && !decided.has(k.id)) : keys
93
+ for (const k of shown) out(`${k.population.padEnd(7)} ${k.kind.padEnd(5)} ${k.id.padEnd(28)} ${k.key ?? `— ${k.reason ?? ''}`}`)
94
+
95
+ const content = keys.filter((k) => k.population === 'content').length
96
+ const world = keys.filter((k) => k.population === 'world').length
97
+ const none = keys.filter((k) => k.population === 'none').length
98
+ const todo = layer.registration.burnDown.length
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`)
100
+ if (todo > 0 && !this.burnDown) out(` the burn-down: shipkit keys --burn-down`)
101
+
102
+ if (this.rescan) {
103
+ const d = layer.rescan()
104
+ if (d.error != null) out(`FAIL: re-hash failed — ${d.error}`)
105
+ else if (d.drifted.length === 0) out(`ok: post-run re-hash clean, nothing moved (${Math.round(d.ms)}ms)`)
106
+ else {
107
+ out(`WARN ${d.drifted.length} input(s) moved since the scan — a run would discard its results rather than store them:`)
108
+ for (const p of d.drifted) out(` ${p}`)
109
+ }
110
+ }
111
+ return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
112
+ }
113
+ }
package/src/cli.ts CHANGED
@@ -13,6 +13,7 @@ import { CiCommand } from './cli/cmd_Ci.ts'
13
13
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
14
14
  import { HooksCommitMsgCommand, HooksInstallCommand, HooksListCommand, HooksPreCommitCommand } from './cli/cmd_Hooks.ts'
15
15
  import { InitCommand } from './cli/cmd_Init.ts'
16
+ import { KeysCommand } from './cli/cmd_Keys.ts'
16
17
  import { MvCommand } from './cli/cmd_Mv.ts'
17
18
  import { NeedsCommand } from './cli/cmd_Needs.ts'
18
19
  import { PbCollectionCommand } from './cli/cmd_Pb.ts'
@@ -70,6 +71,7 @@ export function buildCli(): Cli {
70
71
  cli.register(PbCollectionCommand)
71
72
  cli.register(ReflectCommand)
72
73
  cli.register(RulesCommand)
74
+ cli.register(KeysCommand)
73
75
  cli.register(CommandsCommand)
74
76
 
75
77
  return cli
@@ -2,22 +2,27 @@
2
2
  // renders the v0 wire contract (wire.ts) with exit code.
3
3
  //
4
4
  // The TTL cache is the API-spam guard: a passing check with `remainValidFor`
5
- // replays from `<cacheDir>/<name>.json` without touching the network. Only
6
- // clean passes are cached; WARN/FAIL evict. Busts: version bump, secret
7
- // rotation (key hashes the secret value), `<freshEnv>=1`.
5
+ // replays without touching the network. Only clean passes are cached;
6
+ // WARN/FAIL evict. Busts: version bump, secret rotation (key hashes the
7
+ // secret value), `<freshEnv>=1`.
8
+ //
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.
8
15
  //
9
16
  // Secret VALUES never enter this module's state beyond the injected
10
17
  // getSecret call + a truncated hash for cache-busting.
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
12
- import { join } from 'path'
18
+ import { existsSync } from 'fs'
13
19
  import { CheckCtx, RateLimited, ToolUnresolved } from './check-ctx.ts'
20
+ import { dropStored, entryPath, FRESH_ENV, readStored, sweepStore, writeStored } from '../ledger/store.ts'
14
21
  import { fmtStamp, formatLine, parseDuration, type Line } from './wire.ts'
15
22
 
16
23
  type Logic = (t: CheckCtx) => unknown
17
24
  type Prep = { skip?: string; cacheExtra?: string }
18
25
  type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic; blocking: boolean }
19
- type CacheEntry = { version: number; key: string; at: number; lines: Line[] }
20
- type CacheFile = Record<string, CacheEntry>
21
26
 
22
27
  export type GetSecret = (name: string, field: 'get' | 'account') => string | null
23
28
 
@@ -28,15 +33,19 @@ export type SecretSpec = {
28
33
  skipHint: string
29
34
  }
30
35
 
31
- 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 }
32
37
 
33
38
  export type CheckSuiteOpts = {
34
39
  name: string
35
- 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
36
43
  now?: () => number
37
44
  /** secret backend — consumers inject their own store; default = nothing stored, every secret check skips */
38
45
  getSecret?: GetSecret
39
- /** 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. */
40
49
  freshEnv?: string
41
50
  /** per-check completion tap (the TUI streams results in) — cached replays
42
51
  * report cached:true, prep-skips (unstored secret) report status 'skip' */
@@ -47,7 +56,7 @@ export class CheckSuite {
47
56
  readonly name: string
48
57
  private runners: Runner[] = []
49
58
  private toolPaths = new Map<string, string>()
50
- private cacheDir: string
59
+ private storeDir: string
51
60
  private now: () => number
52
61
  private getSecret: GetSecret
53
62
  private freshEnv: string
@@ -55,10 +64,10 @@ export class CheckSuite {
55
64
 
56
65
  constructor(opts: CheckSuiteOpts) {
57
66
  this.name = opts.name
58
- this.cacheDir = opts.cacheDir
67
+ this.storeDir = opts.storeDir
59
68
  this.now = opts.now ?? Date.now
60
69
  this.getSecret = opts.getSecret ?? (() => null)
61
- this.freshEnv = opts.freshEnv ?? 'SHIPKIT_FRESH'
70
+ this.freshEnv = opts.freshEnv ?? FRESH_ENV
62
71
  this.onResult = opts.onResult
63
72
  }
64
73
 
@@ -128,21 +137,29 @@ export class CheckSuite {
128
137
  })
129
138
  }
130
139
 
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}`
146
+ }
147
+
131
148
  // test seam — run() prints + exits; execute() returns the structured result
132
149
  /** ONE check: cache replay, run, classify, and what it wants remembered.
133
150
  * Pure with respect to its neighbours, which is what lets execute() run
134
151
  * them together. */
135
- private async runOne(r: Runner, cache: CacheFile): Promise<{ lines: Line[]; skip: string | null; ran: boolean; failed: boolean; store: CacheEntry | null; evict: boolean }> {
152
+ private async runOne(r: Runner): Promise<{ lines: Line[]; skip: string | null; ran: boolean; failed: boolean }> {
136
153
  const prep = r.prep?.() ?? {}
137
154
  if (prep.skip != null) {
138
- this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false })
139
- return { lines: [], skip: prep.skip, ran: false, failed: false, store: null, evict: false }
155
+ this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false, ms: 0 })
156
+ return { lines: [], skip: prep.skip, ran: false, failed: false }
140
157
  }
141
- const key = prep.cacheExtra ?? ''
142
- const entry = cache[r.name]
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)
143
161
  const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
144
- && entry != null && entry.version === r.version && entry.key === key
145
- && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
162
+ && entry != null && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
146
163
  if (fresh) {
147
164
  // the stamp was baked in when the check RAN (below): a replay
148
165
  // prints the stored lines VERBATIM, so the live run and every
@@ -152,11 +169,12 @@ export class CheckSuite {
152
169
  // alternation, so neither side annotates
153
170
  // a replayed WORLD negative keeps its severity: it is still the
154
171
  // answer, and demoting it to a pass would hide a real finding
155
- const replayedWarn = entry.lines.some((l) => l.kind === 'warn' || l.kind === 'fail')
156
- this.onResult?.(r.name, { lines: entry.lines, status: replayedWarn ? 'warn' : 'pass', cached: true })
157
- return { lines: entry.lines, skip: null, ran: true, failed: false, store: null, evict: 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 }
158
175
  }
159
176
  const t = new CheckCtx(this.toolPaths)
177
+ const started = performance.now()
160
178
  try {
161
179
  await r.logic(t)
162
180
  } catch (e) {
@@ -193,24 +211,23 @@ export class CheckSuite {
193
211
  const lines = cacheable
194
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))
195
213
  : t.lines
196
- this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
197
- return {
198
- lines,
199
- skip: null,
200
- ran: true,
201
- failed: t.failed,
202
- store: cacheable && r.ttlMs > 0 ? { version: r.version, key, at: this.now(), lines } : null,
214
+ this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false, ms: performance.now() - started })
215
+ // this check writes its OWN file, and only its own: nothing here reads
216
+ // or rewrites a neighbour's entry, so a racing runner can lose nothing
217
+ if (r.ttlMs > 0) {
218
+ if (cacheable) writeStored(path, key, lines, this.now())
203
219
  // an unmeasured run evicts whatever was stored: a later run must
204
- // re-probe rather than replay an answer recorded before the outage
205
- evict: r.ttlMs > 0 && !cacheable && entry != null,
220
+ // re-probe rather than replay an answer recorded before the outage.
221
+ // A racing runner may have written a good answer into that same
222
+ // file a moment ago and lose it here, which costs one re-probe and
223
+ // can never produce a stale green, so it is the safe direction.
224
+ else if (entry != null) dropStored(path)
206
225
  }
226
+ return { lines, skip: null, ran: true, failed: t.failed }
207
227
  }
208
228
 
209
229
  async execute(): Promise<{ lines: Line[]; exitCode: number }> {
210
- const cachePath = join(this.cacheDir, `${this.name}.json`)
211
- let cache: CacheFile = {}
212
- try { cache = JSON.parse(readFileSync(cachePath, 'utf8')) } catch {}
213
- let cacheDirty = false
230
+ sweepStore(this.storeDir, this.name, this.now())
214
231
 
215
232
  // CONCURRENT, bounded. Checks are independent by contract: each answers
216
233
  // one question about the world or the repo and shares nothing with its
@@ -227,7 +244,7 @@ export class CheckSuite {
227
244
  for (;;) {
228
245
  const i = next++
229
246
  if (i >= this.runners.length) return
230
- results[i] = await this.runOne(this.runners[i]!, cache)
247
+ results[i] = await this.runOne(this.runners[i]!)
231
248
  }
232
249
  }
233
250
  await Promise.all(Array.from({ length: Math.min(cap, this.runners.length) }, () => worker()))
@@ -238,13 +255,10 @@ export class CheckSuite {
238
255
  let anyFail = false
239
256
  for (let i = 0; i < this.runners.length; i++) {
240
257
  const r = results[i]!
241
- const name = this.runners[i]!.name
242
258
  out.push(...r.lines)
243
259
  if (r.skip != null) skipped.push(r.skip)
244
260
  if (r.ran) ran++
245
261
  if (r.failed) anyFail = true
246
- if (r.store != null) { cache[name] = r.store; cacheDirty = true }
247
- else if (r.evict) { delete cache[name]; cacheDirty = true }
248
262
  }
249
263
 
250
264
  // contract: `skip:` lines ONLY when no check ran at all (whole probe is
@@ -253,13 +267,6 @@ export class CheckSuite {
253
267
  if (ran === 0) out.push(...skipped.map((s): Line => ({ kind: 'skip', text: `${s} (creds gate reports it)` })))
254
268
  else out.push(...skipped.map((s): Line => ({ kind: 'note', text: `${s} — validation skipped (creds gate reports it)` })))
255
269
 
256
- if (cacheDirty) {
257
- // prune entries for renamed/removed checks — the file must not accrete
258
- const known = new Set(this.runners.map((r) => r.name))
259
- for (const k of Object.keys(cache)) if (!known.has(k)) delete cache[k]
260
- mkdirSync(this.cacheDir, { recursive: true })
261
- writeFileSync(cachePath, JSON.stringify(cache))
262
- }
263
270
  return { lines: out, exitCode: anyFail ? 1 : 0 }
264
271
  }
265
272
 
@@ -17,17 +17,34 @@ 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. */
25
23
  blocking: boolean | null
24
+ /** `export const inputs = ['a', 'b']`, read statically — the repo-relative
25
+ * paths this check reads. Declares the CONTENT population for the ledger
26
+ * key layer (ledger/git-key.ts). null = never cached, which is the safe
27
+ * default and the whole point of default deny. */
28
+ inputs: string[] | null
29
+ /** `export const ttl = '24h'`, read statically — declares the WORLD
30
+ * population: this check probes something no file on disk describes. */
31
+ ttl: string | null
32
+ /** `export const never = 'why'`, read statically — a DECIDED never
33
+ * cacheable, which takes the check off the burn-down list. */
34
+ never: string | null
26
35
  }
27
36
 
28
37
  // read without importing: checks run as subprocesses, and discovery must stay
29
38
  // pure-fs. `export const blocking = true` (or false), nothing cleverer.
30
39
  const BLOCKING_RE = /^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)\s*$/m
40
+ // `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
41
+ // (a computed list, a spread) is deliberately unreadable here and lands as no
42
+ // declaration, which means never cached: a scanner guessing at a program is
43
+ // how a key comes to describe files the unit does not read.
44
+ const INPUTS_RE = /^\s*export\s+const\s+inputs\s*(?::[^=]*)?=\s*\[([^\]]*)\]/m
45
+ const TTL_RE = /^\s*export\s+const\s+ttl\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
46
+ const NEVER_RE = /^\s*export\s+const\s+never\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
47
+ const STRING_ITEM_RE = /['"]([^'"]+)['"]/g
31
48
 
32
49
  export function discoverChecks(root: string): DiscoveredCheck[] {
33
50
  const files: string[] = []
@@ -48,16 +65,22 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
48
65
  return files.map((f) => {
49
66
  const base = baseOf(f)
50
67
  const name = (seen.get(base) ?? 0) > 1 ? relative(root, f).slice(0, -SUFFIX.length) : base
51
- // a check built on CheckSuite/PageValidator replays from its OWN
52
- // TTL cache — caching it again up here would double the staleness
53
- let selfCached = false
54
68
  let blocking: boolean | null = null
69
+ let inputs: string[] | null = null
70
+ let ttl: string | null = null
71
+ let never: string | null = null
55
72
  try {
56
73
  const src = readFileSync(f, 'utf8')
57
- selfCached = /PageValidator|CheckSuite/.test(src)
58
74
  const m = src.match(BLOCKING_RE)
59
75
  blocking = m == null ? null : m[1] === 'true'
76
+ const im = src.match(INPUTS_RE)
77
+ if (im != null) {
78
+ const items = [...im[1]!.matchAll(STRING_ITEM_RE)].map((x) => x[1]!)
79
+ if (items.length > 0) inputs = items
80
+ }
81
+ ttl = src.match(TTL_RE)?.[1] ?? null
82
+ never = src.match(NEVER_RE)?.[1] ?? null
60
83
  } catch {}
61
- return { path: f, name, selfCached, blocking }
84
+ return { path: f, name, blocking, inputs, ttl, never }
62
85
  })
63
86
  }
package/src/core/floor.ts CHANGED
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from 'fs'
9
9
  import { join } from 'path'
10
10
  import { fileURLToPath } from 'url'
11
11
  import { rawName, routesToShipkit } from './canonical-scripts.ts'
12
+ import { resolveTypecheckProjects } from '../ts/tsconfig-projects.ts'
12
13
  import type { TaskDef } from './task-runner.ts'
13
14
  import type { RepoConfig } from '../repo-config.ts'
14
15
 
@@ -40,11 +41,29 @@ function builtinFloorTask(id: string, root: string): { task: TaskDef; source: Fl
40
41
  }
41
42
  }
42
43
  if (id === 'typecheck') {
43
- if (!existsSync(join(root, 'tsconfig.json'))) return `no 'typecheck' script and no tsconfig.json`
44
+ const tsconfig = join(root, 'tsconfig.json')
45
+ if (!existsSync(tsconfig)) return `no 'typecheck' script and no tsconfig.json`
44
46
  const local = join(root, 'node_modules/.bin/tsc')
45
47
  const tsc = existsSync(local) ? local : Bun.which('tsc')
46
48
  if (tsc == null) return `no 'typecheck' script and tsc not found — add typescript or a typecheck script`
47
- return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
49
+ // `tsc --noEmit` over a solution-style config (`files: []` plus
50
+ // `references`) compiles ZERO files and exits 0, so the slot reports a
51
+ // green nobody measured. only a config that is its OWN single project
52
+ // keeps the plain command; everything else goes through the runner,
53
+ // which resolves and REPORTS what it checked.
54
+ //
55
+ // a config that compiles nothing, or references a path that is gone,
56
+ // registers the runner too rather than leaving the slot unfilled: an
57
+ // unfilled slot is a skip, `shipkit ci` still exits 0, and a repo with
58
+ // no typecheck coverage would look the same as a repo that passed.
59
+ let plain = false
60
+ try {
61
+ const projects = resolveTypecheckProjects(tsconfig)
62
+ plain = projects.projects.length === 1 && projects.projects[0] === tsconfig && projects.missing.length === 0
63
+ } catch { plain = false }
64
+ if (plain) return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
65
+ const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/typecheck-cli.ts'))
66
+ return { task: { id, cmd: ['bun', cli, tsc, tsconfig] }, source: 'built-in (tsc)' }
48
67
  }
49
68
  return `no package.json '${id}' script`
50
69
  }
@@ -65,18 +84,27 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
65
84
  for (const id of FLOOR_IDS) {
66
85
  const override = c.defaults?.[id]
67
86
  if (override === false) { disabled.push(id); continue }
68
- 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
+ }
69
96
  // ONE WAY IN: `<id>` routes to `shipkit ci --only <id>`, so the TOOL
70
97
  // body lives at `<id>:raw` and that is what the gate runs. Running the
71
98
  // routed script here would spawn the gate from inside itself, forever
99
+ const decorate = (t: TaskDef): TaskDef => (override == null ? t : { ...t, ...override, id })
72
100
  const raw = scripts[rawName(id)]
73
- if (raw != null) { tasks.push({ id, cmd: ['bun', 'run', rawName(id)] }); sources[id] = 'package.json script'; continue }
74
- 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 }
75
103
  const b = builtinFloorTask(id, root)
76
104
  // a routed script with nothing behind it measures NOTHING, and the
77
105
  // reason must say that rather than `no '<id>' script`, which is false
78
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 })
79
- else { tasks.push(b.task); sources[id] = b.source }
107
+ else { tasks.push(decorate(b.task)); sources[id] = b.source }
80
108
  }
81
109
  return { tasks, disabled, unfilled, sources }
82
110
  }