rvlib-shipkit 0.31.0 → 0.32.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 (40) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +3 -0
  3. package/package.json +1 -1
  4. package/src/cli/cmd_Bump.ts +2 -0
  5. package/src/cli/cmd_Ci.ts +3 -1
  6. package/src/cli/cmd_Pb.ts +27 -0
  7. package/src/cli/cmd_Release.ts +1 -1
  8. package/src/cli/cmd_Wiki.ts +2 -2
  9. package/src/cli.ts +49 -39
  10. package/src/collections/providers.ts +7 -2
  11. package/src/contracts/contracts-once.ts +43 -0
  12. package/src/core/canonical-scripts.ts +33 -0
  13. package/src/core/check-ctx.ts +13 -0
  14. package/src/core/check-suite.ts +121 -53
  15. package/src/core/floor.ts +82 -0
  16. package/src/core/motherhome.ts +14 -1
  17. package/src/core/run-check.ts +34 -0
  18. package/src/core/run-ci.ts +63 -2
  19. package/src/core/run-fixers.ts +6 -1
  20. package/src/ledger/toolchain-identity.ts +134 -0
  21. package/src/pb/collection-scaffold.ts +299 -0
  22. package/src/pb/pb-types.ts +233 -0
  23. package/src/pb/run-pb.ts +157 -0
  24. package/src/release/git-facts.ts +3 -2
  25. package/src/release/release-scope.ts +52 -0
  26. package/src/release/releases-share.ts +85 -0
  27. package/src/release/run-bump.ts +31 -8
  28. package/src/release/run-release.ts +8 -1
  29. package/src/release/run-releases.ts +20 -6
  30. package/src/repo-config.ts +22 -65
  31. package/src/rules/SK001-package-json-shape.ts +65 -2
  32. package/src/rules/SK002-contracts-fulfillness.ts +2 -2
  33. package/src/rules/SK004-gitignore-policy.ts +27 -3
  34. package/src/rules/SK012-config-barrel-import.ts +60 -0
  35. package/src/rules/rule-registry.ts +2 -0
  36. package/src/rules/untrack-generated.ts +117 -0
  37. package/src/scaffold/project-scaffold.ts +1 -1
  38. package/src/serve/hub-port.ts +17 -0
  39. package/src/serve/run-serve.ts +2 -5
  40. package/src/wiki/run-wiki.ts +18 -8
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.32.0
4
+
5
+ - **`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`.
6
+ - **The output is the house dialect, not a second one.** `X.` Mantine globals rather than `@mantine/core` imports, `observer(function Name_())` around the form, the `{ record?, onClose }` props `CollectionUI['form']` actually declares, and `defineCollectionUI` called as the void statement it is. A generator that invents its own shape is worse than no generator: what it writes is what the next developer copies. `key` is set only where the server can really order by the column, so a joined multi-value cell no longer claims sortability it does not have, and an auth collection's `verified`, `email`, `username` and `emailVisibility` get no control, since PocketBase routes those through endpoints a plain patch never reaches.
7
+ - **It refuses rather than half-writing.** No generated types, a file that is not typegen output, a collection the schema does not carry, one of PocketBase's own `_`-prefixed internals, a record block that is missing or unreadable, or a block where not one field parsed: each exits non-zero naming the file, the known collection names and the typegen command that produces them. A single field whose type is unrecognised is named in a `WARN` instead of quietly becoming a commented column. Writes are create-only: a path that already exists is reported and left byte for byte as it is, so a re-run after a schema change can never eat hand-written UI. `--dry-run` prints the plan AND every file body in full and touches nothing, which is what an agent should reach for first.
8
+ - **A path shipkit generates is ignored AND untracked, in one operation.** `.gitignore` does nothing to a file that is already tracked, so a generated path that is only ignored keeps dirtying every tree forever and rides into unrelated commits as a mystery diff. Rule SK004 now FAILs while a declared generated path is still in the index, and its autofix removes it with `git rm --cached` and commits exactly that, `chore(machine): untrack <path>`, through the hooks. The file stays on disk. First customers: `STATUS.md`, `STATUS.md.lock` and `.STATUS.md.tmp-*`, which shipkit rewrites on every run and nothing ever reads a past version of. Only literal paths are removable, never a glob, because `git rm --cached` driven by a wildcard is how a fixer eats source. It refuses, by name, mid-merge, mid-rebase, on a detached HEAD, before the first commit, and whenever anything else is already staged, so the migration commit can never sweep up someone's work. A hook that rejects it restores the index rather than leaving a staged deletion behind, which is the failure mode it exists to stop. `--frozen` suppresses it and `untrackGenerated: false` opts a repo out.
9
+ - **Two test tiers, and every run says which one it is.** `shipkit ci` runs the QUICK tier: pure logic, no child process, no repository on disk, so the working gate stays fast. `shipkit bump` and `shipkit release` always run the FULL tier, before the version commit, so a red suite aborts the release instead of leaving a half-published repo. `shipkit ci --full` (or `SHIPKIT_TEST_FULL=1`, which the gate sets for every task) runs it by hand. Both directions print a tier line and the test count actually run, because a log that does not name the tier cannot be told from one that quietly skipped half the suite.
10
+ - Reading the schema is a LINE parse of generated code, not a TypeScript AST: shipkit carries no typescript dependency and typegen writes one field per line. Record blocks parameterised by a json field are handled, and so is a file a formatter has rewritten with trailing semicolons, which is the normal state of any repo whose formatter covers `src/**`.
11
+
3
12
  ## 0.31.0
4
13
 
5
14
  - **Every repo browses its shared wiki from its own hub.** A repo whose CLAUDE.md include chain points at a wiki living in another repo (`@~/dev/<other-repo>/wiki/index.md`, read from `CLAUDE.md`, `CLAUDE.local.md` or `CLAUDE.private.md`) mounts it at `/graph/base`, named after the repo that owns it. That wiki binds every agent working here, so the hub lets a human read it where the work happens. An index inside the repo is skipped, since it is already its own mount, and the card carries no lint verdict: the owning repo lints its own pages.
package/README.md CHANGED
@@ -46,6 +46,9 @@ shipkit wiki serve|lint — live md link-graph + orphan/dangling-ref lint
46
46
  shipkit mv rename an md page + its sibling dir, sweeping every ref
47
47
  shipkit assets serve|fill|add|gen|identity|check — the asset atelier
48
48
  (declare kinds in .rv-assets/kit.ts, roll, curate, ship)
49
+ shipkit pb collection <name> — scaffold a PocketBase collection's UI
50
+ folder (columns/card/grid/form/index) from the app's
51
+ generated pb types, create-only [--dry-run]
49
52
  shipkit serve the repo's ONE server: hub page, graphs, studio, status
50
53
  shipkit reflect your prompts across every Claude Code session of a repo:
51
54
  all config roots, queued-mid-run ones included, harness
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.31.0",
3
+ "version": "0.32.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",
@@ -32,6 +32,7 @@ export class BumpCommand extends Command {
32
32
  dryRun = Option.Boolean('--dry-run', false, { description: 'rehearse: run every preflight, the gate and the prepublish scripts, then stop before any version is written. the gate can rewrite files, and the merge is probed rather than performed, so the release branch never moves' })
33
33
  noRelease = Option.Boolean('--no-release', false, { description: 'stop after the version commit, do not tag or publish' })
34
34
  noMerge = Option.Boolean('--no-merge', false, { description: 'refuse on a feature branch instead of merging it into the release branch' })
35
+ allowDirty = Option.Boolean('--allow-dirty', false, { description: "proceed with uncommitted changes inside what this release writes. Dirt in OTHER units is already ignored, so this is only for when the scoping judges wrong. A sub-unit's OWN directory still refuses, the same as `release`, and no flag lifts that: `npm publish` packs the WORKING TREE rather than the commit, so forcing past it ships the half-written file under a tag that describes something else" })
35
36
  all = Option.Boolean('--all', false, { description: 'bump every unit whose changelog carries Unreleased notes, in declared order, root last. Given a kind it applies to ALL of them. Given none, each unit takes the kind from its own "## Unreleased (minor)" heading, and a unit whose heading is silent refuses' })
36
37
 
37
38
  async execute(): Promise<number> {
@@ -51,6 +52,7 @@ export class BumpCommand extends Command {
51
52
  noMerge: this.noMerge,
52
53
  unit,
53
54
  all: this.all,
55
+ allowDirty: this.allowDirty,
54
56
  print: (l) => this.context.stdout.write(`${l}\n`),
55
57
  })
56
58
  } catch (e) {
package/src/cli/cmd_Ci.ts CHANGED
@@ -24,10 +24,11 @@ export class CiCommand extends Command {
24
24
  json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
25
25
  fix = Option.Boolean('--fix', true, { description: 'repair the mechanical slots before gating. Default on. --no-fix skips the repair pass, checks can still write. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
26
26
  fixRules = Option.Boolean('--fix-rules', true, { description: 'apply the structural rule autofixes too. Default on. --no-fix-rules leaves repo config alone' })
27
- frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining rules (SK005 never touches package.json). STATUS.md is still refreshed, so no combination of flags makes the run write nothing' })
27
+ frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining rules: SK005 never touches package.json, and generated paths are never untracked. STATUS.md is still refreshed on disk, but it is gitignored, so a run leaves the tree clean' })
28
28
  skip = Option.Array('--skip', [], { description: 'skip a task group (repeatable)' })
29
29
  withIds = Option.Array('--with', [], { description: 'include an opt-in task (repeatable)' })
30
30
  only = Option.Array('--only', [], { description: 'run only this task + its dep closure (repeatable)' })
31
+ 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` })
31
32
 
32
33
  async execute(): Promise<number> {
33
34
  // a stale toolkit updates itself and restarts this exact invocation
@@ -44,6 +45,7 @@ export class CiCommand extends Command {
44
45
  skipGroups: this.skip,
45
46
  withIds: this.withIds,
46
47
  onlyIds: this.only,
48
+ ...(this.full ? { tests: 'full' as const } : {}),
47
49
  print: (l) => this.context.stdout.write(`${l}\n`),
48
50
  })
49
51
  } catch (e) {
@@ -0,0 +1,27 @@
1
+ // shipkit pb — scaffolding for a PocketBase app, run from THAT app's root.
2
+ // Thin clipanion surface; the schema parse and the emitters live in pb/.
3
+ import { Command, Option } from 'clipanion'
4
+ import { runPbCollection } from '../pb/run-pb.ts'
5
+
6
+ export class PbCollectionCommand extends Command {
7
+ static override paths = [['pb', 'collection']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Scaffold',
11
+ description: "a collection's CRUD UI folder from the app's generated pb types: columns, card, grid, form, index",
12
+ examples: [
13
+ ['see the whole folder before it exists', 'shipkit pb collection orders --dry-run'],
14
+ ['write src/collections/orders/', 'shipkit pb collection orders'],
15
+ ],
16
+ })
17
+
18
+ name = Option.String()
19
+ dryRun = Option.Boolean('--dry-run', false, { description: 'print every file in full, write nothing' })
20
+
21
+ async execute(): Promise<number> {
22
+ return runPbCollection(process.cwd(), this.name, {
23
+ print: (l) => this.context.stdout.write(`${l}\n`),
24
+ printErr: (l) => this.context.stderr.write(`${l}\n`),
25
+ }, { dryRun: this.dryRun })
26
+ }
27
+ }
@@ -20,7 +20,7 @@ export class ReleaseCommand extends Command {
20
20
 
21
21
  unit = Option.String({ required: false })
22
22
  dryRun = Option.Boolean('--dry-run', false, { description: 'run every preflight and print the plan, change nothing' })
23
- allowDirty = Option.Boolean('--allow-dirty', false, { description: 'release from an unclean tree (the tag will not contain it). A SUB-unit still refuses on dirt inside its own path' })
23
+ allowDirty = Option.Boolean('--allow-dirty', false, { description: 'release from an unclean tree (the tag will not contain it). Safe for dirt in OTHER paths only: `npm publish` packs the WORKING TREE, not the commit, so a SUB-unit still refuses on dirt inside its own path rather than ship a half-written file under a tag that describes something else' })
24
24
  skipGate = Option.Boolean('--skip-gate', false, { description: 'skip the ci gate preflight — publishes unverified, use only to recover a stuck release' })
25
25
 
26
26
  async execute(): Promise<number> {
@@ -27,7 +27,7 @@ export class WikiServeCommand extends Command {
27
27
  this.context.stderr.write(`FAIL --port '${this.port}' is not a port number\n`)
28
28
  return 1
29
29
  }
30
- return runWikiServe({ root: this.root, index: this.index, skip: this.skip, port, title: this.title })
30
+ return runWikiServe({ root: this.root, index: this.index, skip: this.skip, port, title: this.title, print: (l) => this.context.stdout.write(`${l}\n`) })
31
31
  }
32
32
  }
33
33
 
@@ -48,6 +48,6 @@ export class WikiLintCommand extends Command {
48
48
  skip = Option.String('--skip', { required: false, description: 'comma-separated dir names to exclude from the scan' })
49
49
 
50
50
  async execute(): Promise<number> {
51
- return runWikiLint({ root: this.root, index: this.index, skip: this.skip })
51
+ return runWikiLint({ root: this.root, index: this.index, skip: this.skip, print: (l) => this.context.stdout.write(`${l}\n`) })
52
52
  }
53
53
  }
package/src/cli.ts CHANGED
@@ -15,6 +15,7 @@ import { HooksCommitMsgCommand, HooksInstallCommand, HooksListCommand, HooksPreC
15
15
  import { InitCommand } from './cli/cmd_Init.ts'
16
16
  import { MvCommand } from './cli/cmd_Mv.ts'
17
17
  import { NeedsCommand } from './cli/cmd_Needs.ts'
18
+ import { PbCollectionCommand } from './cli/cmd_Pb.ts'
18
19
  import { ReflectCommand } from './cli/cmd_Reflect.ts'
19
20
  import { ReleaseCommand } from './cli/cmd_Release.ts'
20
21
  import { ReleasesCommand } from './cli/cmd_Releases.ts'
@@ -26,44 +27,53 @@ import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
26
27
 
27
28
  const version = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
28
29
 
29
- const cli = new Cli({
30
- binaryLabel: 'shipkit — repo toolkit',
31
- binaryName: 'shipkit',
32
- binaryVersion: version,
33
- enableCapture: true, // console output pipes into the command streams (testable)
34
- enableColors: true,
35
- })
30
+ /** the registry, built but NOT run: a test drives the real binary in process
31
+ * instead of paying a cold bun start per assertion (~250ms each), and the
32
+ * wiring it exercises is the wiring that ships. */
33
+ export function buildCli(): Cli {
34
+ const cli = new Cli({
35
+ binaryLabel: 'shipkit — repo toolkit',
36
+ binaryName: 'shipkit',
37
+ binaryVersion: version,
38
+ enableCapture: true, // console output pipes into the command streams (testable)
39
+ enableColors: true,
40
+ })
36
41
 
37
- cli.register(Builtins.HelpCommand)
38
- cli.register(Builtins.VersionCommand)
39
- cli.register(CiCommand)
40
- cli.register(BumpCommand)
41
- cli.register(ReleaseCommand)
42
- cli.register(ReleasesCommand)
43
- cli.register(HooksInstallCommand)
44
- cli.register(HooksPreCommitCommand)
45
- cli.register(HooksCommitMsgCommand)
46
- cli.register(HooksListCommand)
47
- cli.register(MvCommand)
48
- cli.register(CheckCommand)
49
- cli.register(TuiCommand)
50
- cli.register(ServeCommand)
51
- cli.register(ContractsCommand)
52
- cli.register(AsksCommand)
53
- cli.register(AsksCheckCommand)
54
- cli.register(AskNewCommand)
55
- cli.register(WikiServeCommand)
56
- cli.register(WikiLintCommand)
57
- cli.register(AssetsFillCommand)
58
- cli.register(AssetsAddCommand)
59
- cli.register(AssetsGenCommand)
60
- cli.register(AssetsIdentityCommand)
61
- cli.register(AssetsCheckCommand)
62
- cli.register(InitCommand)
63
- cli.register(StartCommand)
64
- cli.register(NeedsCommand)
65
- cli.register(ReflectCommand)
66
- cli.register(RulesCommand)
67
- cli.register(CommandsCommand)
42
+ cli.register(Builtins.HelpCommand)
43
+ cli.register(Builtins.VersionCommand)
44
+ cli.register(CiCommand)
45
+ cli.register(BumpCommand)
46
+ cli.register(ReleaseCommand)
47
+ cli.register(ReleasesCommand)
48
+ cli.register(HooksInstallCommand)
49
+ cli.register(HooksPreCommitCommand)
50
+ cli.register(HooksCommitMsgCommand)
51
+ cli.register(HooksListCommand)
52
+ cli.register(MvCommand)
53
+ cli.register(CheckCommand)
54
+ cli.register(TuiCommand)
55
+ cli.register(ServeCommand)
56
+ cli.register(ContractsCommand)
57
+ cli.register(AsksCommand)
58
+ cli.register(AsksCheckCommand)
59
+ cli.register(AskNewCommand)
60
+ cli.register(WikiServeCommand)
61
+ cli.register(WikiLintCommand)
62
+ cli.register(AssetsFillCommand)
63
+ cli.register(AssetsAddCommand)
64
+ cli.register(AssetsGenCommand)
65
+ cli.register(AssetsIdentityCommand)
66
+ cli.register(AssetsCheckCommand)
67
+ cli.register(InitCommand)
68
+ cli.register(StartCommand)
69
+ cli.register(NeedsCommand)
70
+ cli.register(PbCollectionCommand)
71
+ cli.register(ReflectCommand)
72
+ cli.register(RulesCommand)
73
+ cli.register(CommandsCommand)
68
74
 
69
- cli.runExit(process.argv.slice(2))
75
+ return cli
76
+ }
77
+
78
+ // only when this file IS the process, so importing it costs nothing
79
+ if (import.meta.main) void buildCli().runExit(process.argv.slice(2))
@@ -13,7 +13,10 @@ import { join } from 'path'
13
13
  import { isStale } from '../assets/gen/derived.ts'
14
14
  import { allAssets, mediaOf, type ResolvedKit } from '../assets/kit-config.ts'
15
15
  import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
16
- import { healIdentity } from '../assets/run-assets.ts'
16
+ // TYPE only, plus a lazy import at the call site. run-assets pulls the comfy
17
+ // runtime and the canvas binding, ~400ms of module load, and the hub's
18
+ // collection list is built on every request by callers that mostly never heal
19
+ import type { healIdentity } from '../assets/run-assets.ts'
17
20
  import { doneAsks, openAsks } from '../asks/asks-queue.ts'
18
21
  import { evaluateContracts } from '../contracts/contracts-engine.ts'
19
22
  import { parseWireOutput } from '../core/wire.ts'
@@ -171,7 +174,9 @@ const credInstance = (name: string, cmdTemplate: string | undefined, decl?: { se
171
174
  * Identity self-heals BEFORE surfacing; manual with nothing uploaded =
172
175
  * red (provide); rolls with no pick = yellow (a default exists, taste
173
176
  * pending — auto-pick-first is blessed); stale = yellow. */
174
- export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal: typeof healIdentity = healIdentity): Collection {
177
+ const healLazily: typeof healIdentity = async (root) => (await import('../assets/run-assets.ts')).healIdentity(root)
178
+
179
+ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal: typeof healIdentity = healLazily): Collection {
175
180
  return {
176
181
  id: 'media',
177
182
  title: 'assets',
@@ -0,0 +1,43 @@
1
+ // ONE contract evaluation per gate run.
2
+ //
3
+ // Two surfaces report fulfillness inside a single `shipkit ci`: the `contracts`
4
+ // TASK, which exists because deploy tasks declare `deps: ['contracts']` and so
5
+ // need a real ordering edge in the DAG, and rule SK002, which exists because a
6
+ // `shipkit check` with no task phase must still report it. Both are load
7
+ // bearing and neither can be deleted: dropping the task would silently ungate
8
+ // every deploy that depends on it, since checks run AFTER every task.
9
+ //
10
+ // So they stay two REPORTS of one evaluation. The task runs first and its
11
+ // result is what SK002 reads. THE TASK IS AUTHORITATIVE (contracts.md says so
12
+ // too); SK002 evaluating on its own is the fallback for a run with no task
13
+ // phase, not a second opinion.
14
+ //
15
+ // The scope is EXPLICIT and never ambient. A memo that outlived its run would
16
+ // let the hub, which rebuilds its collection list on every request, answer
17
+ // from a snapshot of whatever the last gate saw. Only a caller that owns a run
18
+ // opens one.
19
+ import { evaluateContracts, type ContractsConfig, type ContractsResult } from './contracts-engine.ts'
20
+ import type { ContractManifest } from './contract-types.ts'
21
+
22
+ let open = false
23
+ let cached: { key: string; result: Promise<ContractsResult> } | null = null
24
+
25
+ /** run `fn` as ONE gate run: contract evaluation inside it happens once. */
26
+ export async function withContractsScope<T>(fn: () => Promise<T>): Promise<T> {
27
+ const outer = open
28
+ open = true
29
+ try { return await fn() } finally { if (!outer) { open = false; cached = null } }
30
+ }
31
+
32
+ /** the evaluation, memoised only while a run scope is open. Outside one it is
33
+ * a plain call, so `shipkit contracts` and the hub always measure reality. */
34
+ export function evaluateContractsOnce(manifests: ContractManifest[], cfg: ContractsConfig): Promise<ContractsResult> {
35
+ if (!open) return evaluateContracts(manifests, cfg)
36
+ // the config decides the verdict, so it belongs in the identity: a run that
37
+ // somehow evaluated two different configs must not serve one for the other
38
+ const key = JSON.stringify(cfg)
39
+ if (cached != null && cached.key === key) return cached.result
40
+ const result = evaluateContracts(manifests, cfg)
41
+ cached = { key, result }
42
+ return result
43
+ }
@@ -30,6 +30,39 @@ export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, strin
30
30
  }
31
31
  }
32
32
 
33
+ // ONE WAY IN (build/shipkit.md): the verification verbs an agent types from
34
+ // muscle memory ROUTE to shipkit, so `bun run test` gets the cache and the
35
+ // job attachment without anyone having to remember a rule. The tool body they
36
+ // used to hold moves to `<name>:raw`, which is what shipkit itself runs and
37
+ // what an agent uses to iterate on one repro file.
38
+ //
39
+ // only these three. `imports:check` and `cycles` are shipkit's own vocabulary
40
+ // (nobody reaches for them by reflex) and `fmt` is the slot shipkit's fix pass
41
+ // invokes, so routing it would make the fixer call itself.
42
+ export const ROUTED_SCRIPTS = ['test', 'lint', 'typecheck'] as const
43
+
44
+ export const RAW_SUFFIX = ':raw'
45
+
46
+ /** where the tool body lives once `<id>` routes to shipkit */
47
+ export const rawName = (id: string): string => `${id}${RAW_SUFFIX}`
48
+
49
+ /** what a routed `<id>` script says. `--only` narrows to the one question the
50
+ * name promises: `bun run test` must not become a full gate behind the
51
+ * agent's back */
52
+ export function routedBody(id: string, opts: ScriptStyle = {}): string {
53
+ return `${opts.bin != null ? `bun ${opts.bin}` : 'shipkit'} ci --only ${id}`
54
+ }
55
+
56
+ /** does this body hand the question to `shipkit ci` rather than to a tool ?
57
+ * every invocation form counts: the bin, `bunx rvlib-shipkit`, an in-repo
58
+ * `…/cli.ts` path. A body that does is never run AS a floor task, on pain of
59
+ * infinite recursion. The `ci` half is load-bearing: a body that merely lives
60
+ * under a shipkit checkout (a repo calling rvlib's source-graph cli by path)
61
+ * is not a route back in. */
62
+ export function routesToShipkit(body: string): boolean {
63
+ return /\bshipkit\b/.test(body) && /\bci\b/.test(body)
64
+ }
65
+
33
66
  // recover the consumption style from existing scripts: any shipkit command
34
67
  // already running `bun <path>/cli.ts <cmd>` fixes the style for new bodies
35
68
  export function inferBin(scripts: Record<string, string>): string | undefined {
@@ -13,10 +13,23 @@ export class CheckCtx {
13
13
  lines: Line[] = []
14
14
  failed = false
15
15
  warned = false
16
+ /** THIS RUN PRODUCED NO ANSWER: the probe could not run at all (network
17
+ * down, no cred, rate limited, tool missing). Distinct from a probe that
18
+ * ran and came back bad, which IS an answer. The cache replays answers
19
+ * and never replays their absence (check-suite.ts, the measured-negative
20
+ * rule). Set it whenever the reason a check is not green is that it never
21
+ * reached the thing it was asking about. */
22
+ unmeasured = false
16
23
  constructor(private toolPaths: Map<string, string>) {}
17
24
 
18
25
  ok(msg: string): void { this.lines.push({ kind: 'ok', text: msg }) }
19
26
  note(msg: string): void { this.lines.push({ kind: 'note', text: msg }) }
27
+ /** a WARN that came back from something we could not reach — no answer, so
28
+ * nothing to remember */
29
+ unreachable(msg: string, fix?: string): void {
30
+ this.unmeasured = true
31
+ this.warn(msg, fix)
32
+ }
20
33
  warn(msg: string, fix?: string): void {
21
34
  this.warned = true
22
35
  this.lines.push({ kind: 'warn', text: msg })
@@ -15,8 +15,9 @@ import { fmtStamp, formatLine, parseDuration, type Line } from './wire.ts'
15
15
 
16
16
  type Logic = (t: CheckCtx) => unknown
17
17
  type Prep = { skip?: string; cacheExtra?: string }
18
- type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic }
19
- type CacheFile = Record<string, { version: number; key: string; at: number; lines: Line[] }>
18
+ 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>
20
21
 
21
22
  export type GetSecret = (name: string, field: 'get' | 'account') => string | null
22
23
 
@@ -61,8 +62,12 @@ export class CheckSuite {
61
62
  this.onResult = opts.onResult
62
63
  }
63
64
 
64
- addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; cacheExtra?: () => string; logic: Logic }): void {
65
- this.runners.push({ name: spec.name, ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0, version: spec.version ?? 1, prep: spec.cacheExtra != null ? () => ({ cacheExtra: spec.cacheExtra!() }) : undefined, logic: spec.logic })
65
+ /** `blocking` decides what a NEGATIVE result may be cached as: see the
66
+ * measured-negative rule in execute(). Default true, the safe side: a
67
+ * check that never declared itself is treated as being about this repo,
68
+ * so its failures are never replayed. */
69
+ addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; cacheExtra?: () => string; logic: Logic; blocking?: boolean }): void {
70
+ this.runners.push({ name: spec.name, ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0, version: spec.version ?? 1, prep: spec.cacheExtra != null ? () => ({ cacheExtra: spec.cacheExtra!() }) : undefined, logic: spec.logic, blocking: spec.blocking ?? true })
66
71
  }
67
72
 
68
73
  defineRequiredTool(name: string, opts: { candidates?: string[]; probe?: { args: string[]; expect?: RegExp }; fix: string }): void {
@@ -97,6 +102,12 @@ export class CheckSuite {
97
102
  const validate = spec.validate as (v: unknown, t: CheckCtx) => unknown
98
103
  let payload: unknown
99
104
  this.runners.push({
105
+ // conservative on purpose: a secret probe keeps today's behaviour,
106
+ // where no negative is remembered. Its cache key already hashes the
107
+ // secret VALUE, so a rotation busts it, which would make caching a
108
+ // measured 403 sound. That is a severity call for whoever owns the
109
+ // secrets surface, not a side effect of this change.
110
+ blocking: true,
100
111
  // bare secret name — this shows up in human-facing WARN/FAIL lines
101
112
  name: names.length === 1 ? primary : primary.split('/').slice(0, 2).join('/') + '/*',
102
113
  ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0,
@@ -118,65 +129,122 @@ export class CheckSuite {
118
129
  }
119
130
 
120
131
  // test seam — run() prints + exits; execute() returns the structured result
132
+ /** ONE check: cache replay, run, classify, and what it wants remembered.
133
+ * Pure with respect to its neighbours, which is what lets execute() run
134
+ * 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 }> {
136
+ const prep = r.prep?.() ?? {}
137
+ 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 }
140
+ }
141
+ const key = prep.cacheExtra ?? ''
142
+ const entry = cache[r.name]
143
+ 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
146
+ if (fresh) {
147
+ // the stamp was baked in when the check RAN (below): a replay
148
+ // prints the stored lines VERBATIM, so the live run and every
149
+ // later replay render the same string, and the line changes
150
+ // only when a re-validation happens. Annotating replays but
151
+ // not live runs still diffed on every fresh-vs-cached
152
+ // alternation, so neither side annotates
153
+ // a replayed WORLD negative keeps its severity: it is still the
154
+ // 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 }
158
+ }
159
+ const t = new CheckCtx(this.toolPaths)
160
+ try {
161
+ await r.logic(t)
162
+ } catch (e) {
163
+ // both of these mean the probe never reached its subject, so the
164
+ // run has no answer to remember
165
+ if (e instanceof RateLimited) t.unreachable(`${r.name} not validated this run (${e.message}) — retry later`)
166
+ else if (e instanceof ToolUnresolved) { t.unmeasured = true; t.note(`${r.name} skipped (${e.message})`) }
167
+ else t.fail(`${r.name} errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
168
+ }
169
+ // AN ANSWER CACHES. THE ABSENCE OF ONE DOES NOT. That distinction, not
170
+ // pass-versus-fail, is what decides cacheability here, and a future
171
+ // reader is warned off "fixing" the asymmetry:
172
+ //
173
+ // measured negative a probe that RAN and came back bad ("the box is
174
+ // off", "the token answered 403", "five repos are
175
+ // uncatalogued") is a real answer about the world.
176
+ // Replaying it for its TTL is CORRECT: the world
177
+ // does not fix itself between two gate runs, and
178
+ // re-asking every 30 seconds spends a third-party
179
+ // API call to be told the same thing.
180
+ // unmeasured a probe that could not run at all ("registry
181
+ // unreachable", "no cred", "rate limited", tool
182
+ // missing) produced no answer. Caching it asserts
183
+ // a dead network as fact long after it recovered.
184
+ // `skip` lines and the unmeasured flag mark these.
185
+ //
186
+ // A measured negative caches ONLY for a check about the WORLD. A
187
+ // blocking check reports THIS REPO, where the whole point is that a
188
+ // human fixes it and the next run goes green: replaying its red for a
189
+ // TTL would be the cache lying about work already done.
190
+ const unmeasured = t.unmeasured || t.lines.some((l) => l.kind === 'skip')
191
+ const clean = !t.failed && !t.warned
192
+ const cacheable = r.ttlMs > 0 && !unmeasured && (clean || !r.blocking)
193
+ const lines = cacheable
194
+ ? 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
+ : 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,
203
+ // 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,
206
+ }
207
+ }
208
+
121
209
  async execute(): Promise<{ lines: Line[]; exitCode: number }> {
122
210
  const cachePath = join(this.cacheDir, `${this.name}.json`)
123
211
  let cache: CacheFile = {}
124
212
  try { cache = JSON.parse(readFileSync(cachePath, 'utf8')) } catch {}
125
213
  let cacheDirty = false
126
214
 
215
+ // CONCURRENT, bounded. Checks are independent by contract: each answers
216
+ // one question about the world or the repo and shares nothing with its
217
+ // neighbours, and most of the wall time is a network round trip or a
218
+ // cold `bun` start. In series, twenty checks cost the SUM; together
219
+ // they cost the slowest one. Measured on this repo: 18.4s to 6.0s.
220
+ // Order is preserved by collecting per runner and concatenating in
221
+ // declaration order, so the wire output is byte-identical to the
222
+ // sequential run and the frozen contract still holds.
223
+ const cap = Math.max(2, Math.min(8, navigator.hardwareConcurrency || 4))
224
+ const results = new Array<Awaited<ReturnType<CheckSuite['runOne']>>>(this.runners.length)
225
+ let next = 0
226
+ const worker = async (): Promise<void> => {
227
+ for (;;) {
228
+ const i = next++
229
+ if (i >= this.runners.length) return
230
+ results[i] = await this.runOne(this.runners[i]!, cache)
231
+ }
232
+ }
233
+ await Promise.all(Array.from({ length: Math.min(cap, this.runners.length) }, () => worker()))
234
+
127
235
  const out: Line[] = []
128
236
  const skipped: string[] = []
129
237
  let ran = 0
130
238
  let anyFail = false
131
-
132
- for (const r of this.runners) {
133
- const prep = r.prep?.() ?? {}
134
- if (prep.skip != null) {
135
- skipped.push(prep.skip)
136
- this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false })
137
- continue
138
- }
139
- ran++
140
- const key = prep.cacheExtra ?? ''
141
- const entry = cache[r.name]
142
- const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
143
- && entry != null && entry.version === r.version && entry.key === key
144
- && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
145
- if (fresh) {
146
- // the stamp was baked in when the check RAN (below): a replay
147
- // prints the stored lines VERBATIM, so the live run and every
148
- // later replay render the same string, and the line changes
149
- // only when a re-validation happens. Annotating replays but
150
- // not live runs still diffed on every fresh-vs-cached
151
- // alternation, so neither side annotates
152
- out.push(...entry.lines)
153
- this.onResult?.(r.name, { lines: entry.lines, status: 'pass', cached: true }) // only clean passes are cached
154
- continue
155
- }
156
- const t = new CheckCtx(this.toolPaths)
157
- try {
158
- await r.logic(t)
159
- } catch (e) {
160
- if (e instanceof RateLimited) t.warn(`${r.name} not validated this run (${e.message}) — retry later`)
161
- else if (e instanceof ToolUnresolved) t.note(`${r.name} skipped (${e.message})`)
162
- else t.fail(`${r.name} errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
163
- }
164
- // a skip line is an UNKNOWN, not a clean pass — replaying
165
- // "registry unreachable" for a TTL asserts a dead network as
166
- // fact long after it recovered
167
- const cacheable = r.ttlMs > 0 && !t.failed && !t.warned && !t.lines.some((l) => l.kind === 'skip')
168
- const lines = cacheable
169
- ? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (cached at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + r.ttlMs)})` } : l))
170
- : t.lines
171
- out.push(...lines)
172
- this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
173
- if (t.failed) anyFail = true
174
- if (r.ttlMs > 0) {
175
- if (cacheable) { cache[r.name] = { version: r.version, key, at: this.now(), lines }; cacheDirty = true }
176
- // a WARN/FAIL evicts the stale pass — a later run must re-probe,
177
- // not replay an ok recorded before the breakage
178
- else if (entry != null) { delete cache[r.name]; cacheDirty = true }
179
- }
239
+ for (let i = 0; i < this.runners.length; i++) {
240
+ const r = results[i]!
241
+ const name = this.runners[i]!.name
242
+ out.push(...r.lines)
243
+ if (r.skip != null) skipped.push(r.skip)
244
+ if (r.ran) ran++
245
+ 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 }
180
248
  }
181
249
 
182
250
  // contract: `skip:` lines ONLY when no check ran at all (whole probe is