rvlib-shipkit 0.30.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 (44) hide show
  1. package/CHANGELOG.md +14 -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/graphs.ts +17 -19
  11. package/src/collections/providers.ts +7 -2
  12. package/src/contracts/contracts-once.ts +43 -0
  13. package/src/core/canonical-scripts.ts +33 -0
  14. package/src/core/check-ctx.ts +13 -0
  15. package/src/core/check-suite.ts +121 -53
  16. package/src/core/floor.ts +82 -0
  17. package/src/core/motherhome.ts +14 -1
  18. package/src/core/run-check.ts +34 -0
  19. package/src/core/run-ci.ts +63 -2
  20. package/src/core/run-fixers.ts +6 -1
  21. package/src/ledger/toolchain-identity.ts +134 -0
  22. package/src/pb/collection-scaffold.ts +299 -0
  23. package/src/pb/pb-types.ts +233 -0
  24. package/src/pb/run-pb.ts +157 -0
  25. package/src/release/git-facts.ts +3 -2
  26. package/src/release/release-scope.ts +52 -0
  27. package/src/release/releases-share.ts +85 -0
  28. package/src/release/run-bump.ts +31 -8
  29. package/src/release/run-release.ts +8 -1
  30. package/src/release/run-releases.ts +20 -6
  31. package/src/repo-config.ts +22 -65
  32. package/src/rules/SK001-package-json-shape.ts +65 -2
  33. package/src/rules/SK002-contracts-fulfillness.ts +2 -2
  34. package/src/rules/SK004-gitignore-policy.ts +27 -3
  35. package/src/rules/SK012-config-barrel-import.ts +60 -0
  36. package/src/rules/rule-registry.ts +2 -0
  37. package/src/rules/untrack-generated.ts +117 -0
  38. package/src/scaffold/project-scaffold.ts +1 -1
  39. package/src/serve/hub-port.ts +17 -0
  40. package/src/serve/run-serve.ts +54 -20
  41. package/src/serve/serve-hub.ts +13 -15
  42. package/src/shipkit.ts +3 -2
  43. package/src/wiki/graph-mount.ts +21 -0
  44. package/src/wiki/run-wiki.ts +18 -8
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
12
+ ## 0.31.0
13
+
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.
15
+ - **The graphs section stops advertising a graph the server does not serve.** A repo without a `wiki/` dir, which every fresh `shipkit start` is, still got a `wiki graph` card, and opening it embedded the hub's own 404 body ("not found — the hub page is at /") inside the card. Routes, `/hub.json` tools and cards now read ONE `GraphMount[]` that `resolveHubDeps` resolves (`HubDeps.wikiGraph` and `HubDeps.agentGraph` are gone, `HubDeps.graphs` replaces both), instead of the collection re-deriving the wiki graph from the repo root. A graph that is not mounted has no card, and the test asserts every href the section advertises answers 200.
16
+
3
17
  ## 0.30.0
4
18
 
5
19
  - **The studio asset page is tools on one shared canvas, not tabs on a drawer.** Every metadata family is a toggleable TOOL: `oval`, `legs`, `size` (the geometry editor split into its parts), each repo `customPanel`, `asset json`, `roll meta`. Several can be on at once, the URL records the set (`#<assetId>?tools=a,b`, shareable, restored on load), and the last set per kind is remembered. Kit-declared TABS (`meta.tabs: [{ id, tools }]`) are one-click presets, validated at kit load (an undeclared panel OR an unknown tool id refuses, loud). Panel tools float as draggable cards ON the canvas (a drag clamps inside the stage), `customPanel({ anchor: 'bl' })` starts a card bottom-left, so switching a tab never reflows the stage, and the drawer, its tab row and the bottom json body are deleted. Toggling a custom editor on does fold the variants bar, the one deliberate reflow. Custom panels can draw on the art in place: `api.stageOverlay()` returns an element covering the displayed roll (the tint-mask painter paints THERE, controls stay in its floating card), `api.dropOverlays()` cleans up on unmount, and the host calls it. Variants (rolls) move to a thin resizable right bar (drag handle, width remembered, slim-rail collapse, auto-folds when a custom editor turns on). The prompt keeps its fold. The stage remeasures through a ResizeObserver, so overlays track the image through any layout change, not only window resizes.
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.30.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))
@@ -1,34 +1,32 @@
1
- // graphs collection — the mounted md graphs as CHECKED section entries:
2
- // each graph instance carries its lint state (orphans + dangling refs =
3
- // yellow with counts; clean = quiet). Custom wiki-graph providers (repos
4
- // with their own loader + their own lint) get a link-only instance — their
5
- // repo lint owns correctness, never double-judged here.
6
- import { existsSync } from 'fs'
7
- import { join } from 'path'
1
+ // graphs collection — the MOUNTED md graphs as CHECKED section entries. The
2
+ // mounts arrive as data from the hub's own route resolution (serve/run-serve),
3
+ // never re-derived here: a card linking at an unmounted route renders the 404
4
+ // body inside its iframe. Each instance carries its lint state (orphans +
5
+ // dangling refs = yellow with counts; clean = quiet). A graph whose lint
6
+ // belongs to another repo is link-only, never double-judged here.
8
7
  import { lintMd } from '../wiki/lint-md.ts'
8
+ import type { GraphMount } from '../wiki/graph-mount.ts'
9
9
  import type { Collection, Instance } from './collection-types.ts'
10
10
 
11
- export function graphsCollection(root: string, o: { customWiki: boolean; agentIndex?: string }): Collection {
11
+ export function graphsCollection(mounts: GraphMount[]): Collection {
12
12
  return {
13
13
  id: 'graphs',
14
14
  title: 'graphs',
15
15
  media: 'data',
16
- list: async () => {
17
- const out: Instance[] = []
18
- const checked = (id: string, title: string, href: string, dir: string, index?: string): Instance => {
19
- const r = lintMd({ root: dir, index })
16
+ list: async () =>
17
+ mounts.map((m): Instance => {
18
+ const id = `graph:${m.id}`
19
+ const href = `/graph/${m.id}`
20
+ const label = m.note != null ? `${m.label} (${m.note})` : m.label
21
+ if (m.lint == null) return { id, text: label, href }
22
+ const r = lintMd({ root: m.lint.root, index: m.lint.index })
20
23
  const issues = r.orphans.length + r.dangling.length
21
24
  return {
22
25
  id,
23
- text: issues === 0 ? title : `${title} — ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s)`,
26
+ text: issues === 0 ? label : `${label} — ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s)`,
24
27
  severity: issues === 0 ? undefined : 'yellow',
25
28
  href,
26
29
  }
27
- }
28
- if (o.customWiki) out.push({ id: 'graph:wiki', text: 'wiki graph (repo-owned lint)', href: '/graph/wiki' })
29
- else out.push(checked('graph:wiki', 'wiki graph', '/graph/wiki', root))
30
- if (existsSync(join(root, 'agent'))) out.push(checked('graph:agent', 'agent graph', '/graph/agent', join(root, 'agent'), o.agentIndex))
31
- return out
32
- },
30
+ }),
33
31
  }
34
32
  }
@@ -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 })