rvlib-shipkit 0.32.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 (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +4 -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/canonical-scripts.ts +3 -1
  8. package/src/core/check-suite.ts +95 -33
  9. package/src/core/discover-checks.ts +30 -1
  10. package/src/core/floor.ts +21 -2
  11. package/src/core/locked-write.ts +170 -1
  12. package/src/core/one-way-in-policy.ts +183 -0
  13. package/src/core/run-ci.ts +42 -3
  14. package/src/core/run-fixers.ts +61 -18
  15. package/src/core/task-runner.ts +55 -6
  16. package/src/hooks/one-way-in-guard.ts +39 -0
  17. package/src/hooks/run-hooks.ts +23 -2
  18. package/src/init-repo.ts +19 -1
  19. package/src/ledger/gate-units.ts +58 -0
  20. package/src/ledger/git-key.ts +330 -0
  21. package/src/ledger/git-scan.ts +314 -0
  22. package/src/ledger/toolchain-identity.ts +15 -8
  23. package/src/release/run-bump.ts +50 -5
  24. package/src/release/unit-gate.ts +17 -4
  25. package/src/rules/SK001-package-json-shape.ts +10 -3
  26. package/src/rules/SK002-contracts-fulfillness.ts +4 -0
  27. package/src/rules/SK003-claude-md-shape.ts +7 -2
  28. package/src/rules/SK004-gitignore-policy.ts +11 -2
  29. package/src/rules/SK005-rvlib-versions.ts +28 -1
  30. package/src/rules/SK006-npm-publish-safety.ts +3 -0
  31. package/src/rules/SK008-reviewer-agent.ts +6 -2
  32. package/src/rules/SK009-commit-ai-tells.ts +3 -0
  33. package/src/rules/SK010-undeclared-release-unit.ts +2 -0
  34. package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
  35. package/src/rules/SK012-config-barrel-import.ts +6 -2
  36. package/src/rules/SK013-one-way-in-policy.ts +156 -0
  37. package/src/rules/rule-registry.ts +2 -0
  38. package/src/rules/rule.ts +22 -1
  39. package/src/rules/run-fix.ts +5 -3
  40. package/src/scaffold/files/claude-settings-local.json +0 -3
  41. package/src/scaffold/files/verify-skill.md +1 -1
  42. package/src/ts/config.ts +66 -6
  43. package/src/ts/cycles.ts +7 -92
  44. package/src/ts/source-graph.ts +158 -0
  45. package/src/ts/tsconfig-projects.ts +101 -0
  46. package/src/ts/typecheck-cli.ts +46 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.34.0
4
+
5
+ - **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.
6
+
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+ - **`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.
11
+ - **`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.
12
+ - **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.
13
+ - **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.
14
+ - 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.
15
+ - `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.
16
+
17
+ - **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.
18
+ - **`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.
19
+ - **`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.
20
+
21
+ ## 0.33.0
22
+
23
+ - **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.
24
+ - **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.
25
+ - **`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.
26
+ - **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.
27
+ - **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.
28
+ - `--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.
29
+
3
30
  ## 0.32.0
4
31
 
5
32
  - **`shipkit pb collection <name>` scaffolds a PocketBase collection's whole UI folder.** Building a CRUD screen meant retyping a schema that already exists in the app's generated types, one column at a time, and the boring half of that is exactly where a column goes missing. The command reads `src/pb.types.ts` (`.tsx` and `.d.ts` too), resolves one collection, and writes `src/collections/<name>/`: `columns.tsx` with one entry per response field, `card.tsx`, `grid.tsx` over `LiveGrid`, `form.tsx` (a create/edit modal writing through `db` fire and forget), and an `index.ts` calling `defineCollectionUI`. It then registers the folder in `src/collections/index.ts`, creating that file when it is the first one, because a collection folder nothing imports is dead code whose failure mode is a runtime throw. A field the schema cannot turn into a cell on its own (relation, file, json, raw html) is emitted COMMENTED with the reason rather than dropped, because a missing column is invisible and a commented one takes ten seconds to accept or delete. The card leads on the fields a human reads, not on the first three of an alphabetical record block, and the label picks the first of `name`, `label`, `title`, `number`, `code`, `email`, else `id`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.32.0",
3
+ "version": "0.34.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",
@@ -28,6 +28,9 @@
28
28
  "mobx": "^7.0.0",
29
29
  "mobx-react-lite": "^5.0.0"
30
30
  },
31
+ "scripts": {
32
+ "test:raw": "bun test"
33
+ },
31
34
  "dependencies": {
32
35
  "@napi-rs/canvas": "^1.0.2",
33
36
  "clipanion": "^4.0.0-rc.4",
@@ -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`)
@@ -0,0 +1,107 @@
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 { gateUnits } 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
+ ['machine-readable', 'shipkit keys --json'],
26
+ ],
27
+ })
28
+
29
+ json = Option.Boolean('--json', false, { description: 'machine-readable { units: UnitKey[], burnDown, errors, scanMs }' })
30
+ burnDown = Option.Boolean('--burn-down', false, { description: 'list only the units nothing can cache yet, and why' })
31
+ why = Option.String('--why', { required: false, description: 'print every component that went into ONE unit\'s key' })
32
+ rescan = Option.Boolean('--rescan', false, { description: 'run the post-run re-hash and name every input that moved since the scan' })
33
+
34
+ async execute(): Promise<number> {
35
+ const root = process.cwd()
36
+ const c = await loadRepoConfig(root)
37
+ const decls = gateUnits(root, c)
38
+ const layer = KeyLayer.open(root, decls, { cliEntry: new URL('../cli.ts', import.meta.url).pathname })
39
+ const keys = layer.keys()
40
+ const out = (l: string): void => { this.context.stdout.write(`${l}\n`) }
41
+
42
+ if (this.json) {
43
+ const drift = this.rescan ? layer.rescan() : null
44
+ out(JSON.stringify({ units: keys, burnDown: layer.registration.burnDown, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
45
+ return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
46
+ }
47
+
48
+ if (this.why != null) {
49
+ const k = keys.find((u) => u.id.toLowerCase() === this.why!.toLowerCase())
50
+ if (k == null) {
51
+ out(`FAIL: no gate unit '${this.why}' — run \`shipkit keys\` for the list`)
52
+ return 1
53
+ }
54
+ out(`${k.kind} ${k.id} — ${k.population}`)
55
+ if (k.key == null) {
56
+ out(` no key: ${k.reason ?? 'undeclared'}`)
57
+ return 0
58
+ }
59
+ out(` key ${k.key}`)
60
+ // the excluded block is 44 lines here and it buries the component
61
+ // that actually moved. It stays HASHED line by line, so this is a
62
+ // rendering choice and not a different key: the digest moves with
63
+ // the set, which is what answers "why did this move", and --json
64
+ // still carries every path.
65
+ const excluded = k.components.filter((c) => c.startsWith('excluded='))
66
+ for (const comp of k.components) if (!comp.startsWith('excluded=')) out(` ${comp}`)
67
+ if (excluded.length > 0) {
68
+ const digest = createHash('sha256').update(excluded.join('\n')).digest('hex').slice(0, 8)
69
+ out(` excluded ${excluded.length} path(s), digest ${digest} — not in the key's content, only in its meaning`)
70
+ for (const c of excluded.slice(0, 3)) out(` ${c.slice('excluded='.length)}`)
71
+ if (excluded.length > 3) out(` … ${excluded.length - 3} more (shipkit keys --json)`)
72
+ }
73
+ return 0
74
+ }
75
+
76
+ for (const e of layer.registration.errors) out(`FAIL: ${e}`)
77
+ if (layer.failure != null) {
78
+ out(`FAIL: git scan failed, no unit may be keyed — ${layer.failure}`)
79
+ out(' fix: nothing is cached, so the gate is unaffected. A stale scratch index clears with: rm .git/shipkit/scan.idx')
80
+ }
81
+
82
+ // the burn-down is what nobody has LOOKED at. A unit that declared
83
+ // `never` with a reason has been looked at, and listing it here would
84
+ // put four permanent rows on a list whose whole job is to shrink
85
+ const decided = new Set(layer.decls.filter((d) => d.never != null && d.never !== '').map((d) => d.id))
86
+ const shown = this.burnDown ? keys.filter((k) => k.population === 'none' && !decided.has(k.id)) : keys
87
+ for (const k of shown) out(`${k.population.padEnd(7)} ${k.kind.padEnd(5)} ${k.id.padEnd(28)} ${k.key ?? `— ${k.reason ?? ''}`}`)
88
+
89
+ const content = keys.filter((k) => k.population === 'content').length
90
+ const world = keys.filter((k) => k.population === 'world').length
91
+ const none = keys.filter((k) => k.population === 'none').length
92
+ const todo = layer.registration.burnDown.length
93
+ out(`\n${keys.length} units — ${content} content, ${world} world, ${none} uncacheable (${none - todo} decided, ${todo} still undeclared) · key layer ${Math.round(layer.openMs)}ms, of which ${Math.round(layer.scanMs)}ms of git`)
94
+ if (todo > 0 && !this.burnDown) out(` the burn-down: shipkit keys --burn-down`)
95
+
96
+ if (this.rescan) {
97
+ const d = layer.rescan()
98
+ if (d.error != null) out(`FAIL: re-hash failed — ${d.error}`)
99
+ else if (d.drifted.length === 0) out(`ok: post-run re-hash clean, nothing moved (${Math.round(d.ms)}ms)`)
100
+ else {
101
+ out(`WARN ${d.drifted.length} input(s) moved since the scan — a run would discard its results rather than store them:`)
102
+ for (const p of d.drifted) out(` ${p}`)
103
+ }
104
+ }
105
+ return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
106
+ }
107
+ }
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
@@ -60,7 +60,9 @@ export function routedBody(id: string, opts: ScriptStyle = {}): string {
60
60
  * under a shipkit checkout (a repo calling rvlib's source-graph cli by path)
61
61
  * is not a route back in. */
62
62
  export function routesToShipkit(body: string): boolean {
63
- return /\bshipkit\b/.test(body) && /\bci\b/.test(body)
63
+ // the `ci` verb must belong to the shipkit invocation, not merely appear
64
+ // somewhere in the line: `shipkit contracts && bun run x:ci` routes nothing
65
+ return /\bshipkit\b\S*(?:\s+\S+)*?\s+ci(?:\s|$)/.test(body.split(/&&|\|\||;/)[0] ?? body)
64
66
  }
65
67
 
66
68
  // recover the consumption style from existing scripts: any shipkit command
@@ -2,22 +2,60 @@
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: one immutable file per (check, version, cache key), under
10
+ // `<cacheDir>/<suite>/`. Three agents in one checkout is the normal case, so
11
+ // there is no shared file to lose a write to: a run with a different key
12
+ // writes a DIFFERENT file, a run with the same key replaces its own by tmp +
13
+ // rename (whole answer or nothing, never half), and nothing is pruned,
14
+ // because pruning by "the checks I know about" deletes the entries of a
15
+ // runner that narrowed itself with --only. It replaced one aggregate JSON
16
+ // read at the top of the run and rewritten at the bottom, which lost every
17
+ // entry another runner wrote in between and served a torn read as an empty
18
+ // cache.
8
19
  //
9
20
  // Secret VALUES never enter this module's state beyond the injected
10
21
  // getSecret call + a truncated hash for cache-busting.
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
12
- import { join } from 'path'
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
23
+ import { dirname, join } from 'path'
13
24
  import { CheckCtx, RateLimited, ToolUnresolved } from './check-ctx.ts'
25
+ import { atomicWrite } from './locked-write.ts'
14
26
  import { fmtStamp, formatLine, parseDuration, type Line } from './wire.ts'
15
27
 
16
28
  type Logic = (t: CheckCtx) => unknown
17
29
  type Prep = { skip?: string; cacheExtra?: string }
18
30
  type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic; blocking: boolean }
19
31
  type CacheEntry = { version: number; key: string; at: number; lines: Line[] }
20
- type CacheFile = Record<string, CacheEntry>
32
+
33
+ /** a missing or half-written file reads as "no answer", which is the safe
34
+ * side: the check runs. tmp + rename means half-written cannot happen from a
35
+ * shipkit write, so this only ever catches a truncated file from something
36
+ * else, and treats it the same way. */
37
+ const readEntry = (path: string): CacheEntry | null => {
38
+ try {
39
+ const e = JSON.parse(readFileSync(path, 'utf8')) as Partial<CacheEntry>
40
+ return Array.isArray(e.lines) && typeof e.at === 'number' && typeof e.version === 'number' && typeof e.key === 'string' ? (e as CacheEntry) : null
41
+ } catch { return null }
42
+ }
43
+
44
+ const writeEntry = (path: string, e: CacheEntry): void => {
45
+ try {
46
+ mkdirSync(dirname(path), { recursive: true })
47
+ atomicWrite(path, JSON.stringify(e))
48
+ } catch {
49
+ // a cache that cannot be written is slow, never wrong: the answer was
50
+ // already printed, and the next run measures again
51
+ }
52
+ }
53
+
54
+ const slug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
55
+
56
+ /** how long a superseded key's file survives. Far past the longest TTL any
57
+ * check declares, so a live entry is never swept out from under a replay. */
58
+ const SUPERSEDED_KEEP_MS = 30 * 24 * 60 * 60 * 1000
21
59
 
22
60
  export type GetSecret = (name: string, field: 'get' | 'account') => string | null
23
61
 
@@ -128,18 +166,47 @@ export class CheckSuite {
128
166
  })
129
167
  }
130
168
 
169
+ /** the key is in the file name, so a check whose key moves leaves its old
170
+ * file behind forever. A rule keyed on installed versions writes one per
171
+ * upgrade, so the dir accretes without bound in a repo nobody clears.
172
+ * Sweeping by AGE needs no knowledge of the current check set, which is
173
+ * what made the old prune wrong: a run narrowed by --only knows about one
174
+ * check and would have deleted the other twenty four. */
175
+ private sweepSuperseded(): void {
176
+ const dir = join(this.cacheDir, slug(this.name))
177
+ const cutoff = this.now() - SUPERSEDED_KEEP_MS
178
+ try {
179
+ for (const f of readdirSync(dir)) {
180
+ if (!f.endsWith('.json')) continue
181
+ const p = join(dir, f)
182
+ try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { force: true }) } catch { /* raced with another runner: it is gone either way */ }
183
+ }
184
+ } catch {
185
+ // no dir yet, or unreadable: housekeeping never fails a run
186
+ }
187
+ }
188
+
189
+ /** where this check's answer for THIS key lives. The key is in the file
190
+ * NAME, so a run whose key moved writes a new file rather than rewriting
191
+ * the old one, and two runners with different keys never collide. */
192
+ private entryPath(r: Runner, key: string): string {
193
+ const h = new Bun.CryptoHasher('sha256').update(`${r.name}\0${r.version}\0${key}`).digest('hex').slice(0, 12)
194
+ return join(this.cacheDir, slug(this.name), `${slug(r.name)}.${h}.json`)
195
+ }
196
+
131
197
  // test seam — run() prints + exits; execute() returns the structured result
132
198
  /** ONE check: cache replay, run, classify, and what it wants remembered.
133
199
  * Pure with respect to its neighbours, which is what lets execute() run
134
200
  * 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 }> {
201
+ private async runOne(r: Runner): Promise<{ lines: Line[]; skip: string | null; ran: boolean; failed: boolean }> {
136
202
  const prep = r.prep?.() ?? {}
137
203
  if (prep.skip != null) {
138
204
  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 }
205
+ return { lines: [], skip: prep.skip, ran: false, failed: false }
140
206
  }
141
207
  const key = prep.cacheExtra ?? ''
142
- const entry = cache[r.name]
208
+ const entryPath = this.entryPath(r, key)
209
+ const entry = readEntry(entryPath)
143
210
  const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
144
211
  && entry != null && entry.version === r.version && entry.key === key
145
212
  && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
@@ -154,7 +221,7 @@ export class CheckSuite {
154
221
  // answer, and demoting it to a pass would hide a real finding
155
222
  const replayedWarn = entry.lines.some((l) => l.kind === 'warn' || l.kind === 'fail')
156
223
  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 }
224
+ return { lines: entry.lines, skip: null, ran: true, failed: false }
158
225
  }
159
226
  const t = new CheckCtx(this.toolPaths)
160
227
  try {
@@ -194,23 +261,28 @@ export class CheckSuite {
194
261
  ? 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
262
  : t.lines
196
263
  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,
264
+ // this check writes its OWN file, and only its own: nothing here reads
265
+ // or rewrites a neighbour's entry, so a racing runner can lose nothing
266
+ if (r.ttlMs > 0) {
267
+ if (cacheable) writeEntry(entryPath, { version: r.version, key, at: this.now(), lines })
203
268
  // 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,
269
+ // re-probe rather than replay an answer recorded before the outage.
270
+ // A racing runner may have written a good answer into that same
271
+ // file a moment ago and lose it here, which costs one re-probe and
272
+ // can never produce a stale green, so it is the safe direction.
273
+ else if (entry != null) rmSync(entryPath, { force: true })
206
274
  }
275
+ return { lines, skip: null, ran: true, failed: t.failed }
207
276
  }
208
277
 
209
278
  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
279
+ // the aggregate file this replaced. Deleting it here is the migration:
280
+ // nothing reads it any more, and leaving it would have a future reader
281
+ // believe there are two cache layers. DIES when every consumer repo is
282
+ // past rvlib-shipkit 0.34.0, since after that no tree can still carry
283
+ // one; it costs an unlink of a path that is not there.
284
+ rmSync(join(this.cacheDir, `${this.name}.json`), { force: true })
285
+ this.sweepSuperseded()
214
286
 
215
287
  // CONCURRENT, bounded. Checks are independent by contract: each answers
216
288
  // one question about the world or the repo and shares nothing with its
@@ -227,7 +299,7 @@ export class CheckSuite {
227
299
  for (;;) {
228
300
  const i = next++
229
301
  if (i >= this.runners.length) return
230
- results[i] = await this.runOne(this.runners[i]!, cache)
302
+ results[i] = await this.runOne(this.runners[i]!)
231
303
  }
232
304
  }
233
305
  await Promise.all(Array.from({ length: Math.min(cap, this.runners.length) }, () => worker()))
@@ -238,13 +310,10 @@ export class CheckSuite {
238
310
  let anyFail = false
239
311
  for (let i = 0; i < this.runners.length; i++) {
240
312
  const r = results[i]!
241
- const name = this.runners[i]!.name
242
313
  out.push(...r.lines)
243
314
  if (r.skip != null) skipped.push(r.skip)
244
315
  if (r.ran) ran++
245
316
  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
317
  }
249
318
 
250
319
  // contract: `skip:` lines ONLY when no check ran at all (whole probe is
@@ -253,13 +322,6 @@ export class CheckSuite {
253
322
  if (ran === 0) out.push(...skipped.map((s): Line => ({ kind: 'skip', text: `${s} (creds gate reports it)` })))
254
323
  else out.push(...skipped.map((s): Line => ({ kind: 'note', text: `${s} — validation skipped (creds gate reports it)` })))
255
324
 
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
325
  return { lines: out, exitCode: anyFail ? 1 : 0 }
264
326
  }
265
327
 
@@ -23,11 +23,30 @@ export type DiscoveredCheck = {
23
23
  * never said, which is an error: a check that silently does not block is
24
24
  * the failure nobody sees. */
25
25
  blocking: boolean | null
26
+ /** `export const inputs = ['a', 'b']`, read statically — the repo-relative
27
+ * paths this check reads. Declares the CONTENT population for the ledger
28
+ * key layer (ledger/git-key.ts). null = never cached, which is the safe
29
+ * default and the whole point of default deny. */
30
+ inputs: string[] | null
31
+ /** `export const ttl = '24h'`, read statically — declares the WORLD
32
+ * population: this check probes something no file on disk describes. */
33
+ ttl: string | null
34
+ /** `export const never = 'why'`, read statically — a DECIDED never
35
+ * cacheable, which takes the check off the burn-down list. */
36
+ never: string | null
26
37
  }
27
38
 
28
39
  // read without importing: checks run as subprocesses, and discovery must stay
29
40
  // pure-fs. `export const blocking = true` (or false), nothing cleverer.
30
41
  const BLOCKING_RE = /^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)\s*$/m
42
+ // `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
43
+ // (a computed list, a spread) is deliberately unreadable here and lands as no
44
+ // declaration, which means never cached: a scanner guessing at a program is
45
+ // how a key comes to describe files the unit does not read.
46
+ const INPUTS_RE = /^\s*export\s+const\s+inputs\s*(?::[^=]*)?=\s*\[([^\]]*)\]/m
47
+ const TTL_RE = /^\s*export\s+const\s+ttl\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
48
+ const NEVER_RE = /^\s*export\s+const\s+never\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
49
+ const STRING_ITEM_RE = /['"]([^'"]+)['"]/g
31
50
 
32
51
  export function discoverChecks(root: string): DiscoveredCheck[] {
33
52
  const files: string[] = []
@@ -52,12 +71,22 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
52
71
  // TTL cache — caching it again up here would double the staleness
53
72
  let selfCached = false
54
73
  let blocking: boolean | null = null
74
+ let inputs: string[] | null = null
75
+ let ttl: string | null = null
76
+ let never: string | null = null
55
77
  try {
56
78
  const src = readFileSync(f, 'utf8')
57
79
  selfCached = /PageValidator|CheckSuite/.test(src)
58
80
  const m = src.match(BLOCKING_RE)
59
81
  blocking = m == null ? null : m[1] === 'true'
82
+ const im = src.match(INPUTS_RE)
83
+ if (im != null) {
84
+ const items = [...im[1]!.matchAll(STRING_ITEM_RE)].map((x) => x[1]!)
85
+ if (items.length > 0) inputs = items
86
+ }
87
+ ttl = src.match(TTL_RE)?.[1] ?? null
88
+ never = src.match(NEVER_RE)?.[1] ?? null
60
89
  } catch {}
61
- return { path: f, name, selfCached, blocking }
90
+ return { path: f, name, selfCached, blocking, inputs, ttl, never }
62
91
  })
63
92
  }
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
  }