rvlib-shipkit 0.13.0 → 0.16.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 (68) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/README.md +6 -22
  3. package/package.json +2 -2
  4. package/src/assets/kits/identity-kit.ts +1 -1
  5. package/src/assets/panels/geometry-rects.ts +3 -17
  6. package/src/assets/server/studio-server.ts +1 -1
  7. package/src/cli/cmd_Bump.ts +52 -0
  8. package/src/cli/cmd_Check.ts +2 -2
  9. package/src/cli/cmd_Ci.ts +10 -2
  10. package/src/cli/cmd_Commands.ts +126 -0
  11. package/src/cli/cmd_Hooks.ts +66 -0
  12. package/src/cli/cmd_Mv.ts +29 -6
  13. package/src/cli/cmd_Release.ts +41 -0
  14. package/src/cli/cmd_Releases.ts +42 -0
  15. package/src/cli/cmd_Rules.ts +20 -6
  16. package/src/cli/cmd_Start.ts +2 -2
  17. package/src/cli.ts +14 -1
  18. package/src/collections/posts.ts +1 -1
  19. package/src/collections/providers.ts +1 -1
  20. package/src/contracts/contract-types.ts +5 -2
  21. package/src/contracts/contracts-engine.ts +13 -3
  22. package/src/contracts/manifests/discord.contract.ts +2 -2
  23. package/src/contracts/manifests/identity.contract.ts +1 -1
  24. package/src/contracts/manifests/itch.contract.ts +17 -2
  25. package/src/core/canonical-scripts.ts +9 -14
  26. package/src/core/check-suite.ts +1 -1
  27. package/src/core/discover-checks.ts +21 -6
  28. package/src/core/run-check.ts +69 -21
  29. package/src/core/run-ci.ts +75 -9
  30. package/src/core/run-fixers.ts +144 -0
  31. package/src/core/write-status.ts +14 -5
  32. package/src/hooks/commit-message.ts +42 -0
  33. package/src/hooks/leak-guard.ts +0 -0
  34. package/src/hooks/run-hooks.ts +131 -0
  35. package/src/init-repo.ts +13 -5
  36. package/src/reflect/prompt-corpus.ts +3 -4
  37. package/src/release/changelog.ts +53 -0
  38. package/src/release/git-facts.ts +133 -0
  39. package/src/release/run-bump.ts +419 -0
  40. package/src/release/run-release.ts +267 -0
  41. package/src/release/run-releases.ts +230 -0
  42. package/src/release/unit-gate.ts +67 -0
  43. package/src/release/units.ts +246 -0
  44. package/src/repo-config.ts +66 -2
  45. package/src/rules/SK001-package-json-shape.ts +39 -17
  46. package/src/rules/SK002-contracts-fulfillness.ts +1 -0
  47. package/src/rules/SK003-claude-md-shape.ts +2 -1
  48. package/src/rules/SK004-gitignore-policy.ts +1 -0
  49. package/src/rules/SK005-rvlib-versions.ts +22 -3
  50. package/src/rules/SK006-npm-publish-safety.ts +1 -0
  51. package/src/rules/SK007-github-visibility.ts +5 -1
  52. package/src/rules/SK008-reviewer-agent.ts +99 -0
  53. package/src/rules/SK009-commit-ai-tells.ts +66 -0
  54. package/src/rules/SK010-undeclared-release-unit.ts +114 -0
  55. package/src/rules/rule-registry.ts +28 -6
  56. package/src/rules/rule.ts +8 -1
  57. package/src/rules/run-fix.ts +15 -8
  58. package/src/serve/hub-app/col-card.tsx +2 -3
  59. package/src/serve/hub-app/hub-app.tsx +6 -8
  60. package/src/serve/hub-app/hub-st.ts +4 -7
  61. package/src/serve/hub-app/media-view.tsx +2 -2
  62. package/src/serve/run-serve.ts +4 -5
  63. package/src/serve/serve-hub.ts +2 -3
  64. package/src/shipkit.ts +9 -1
  65. package/src/tui/checks-panel.ts +1 -1
  66. package/src/tui/run-tui.ts +2 -3
  67. package/src/tui/tasks-panel.ts +10 -4
  68. package/src/tui/tui-store.ts +2 -2
@@ -0,0 +1,41 @@
1
+ // shipkit release, the one release path, public or private. Thin clipanion
2
+ // surface; logic in release/run-release.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { loadRepoConfig } from '../repo-config.ts'
5
+ import { runRelease } from '../release/run-release.ts'
6
+
7
+ export class ReleaseCommand extends Command {
8
+ static override paths = [['release']]
9
+
10
+ static override usage = Command.Usage({
11
+ category: 'Gate',
12
+ description: 'cut a release for ONE unit (default the repo root): ci gate → npm publish (non-private only) → git tag → push → GitHub release, notes from that unit CHANGELOG section. Resumable after a partial failure, which is why it takes no --all.',
13
+ examples: [
14
+ ['rehearse — touches nothing', 'shipkit release --dry-run'],
15
+ ['release the version in package.json', 'shipkit release'],
16
+ ['finish a sub-unit bump stopped part way (ids: shipkit releases)', 'shipkit release rvlib-shipkit'],
17
+ ['release with dirt elsewhere in the worktree', 'shipkit release --allow-dirty'],
18
+ ],
19
+ })
20
+
21
+ unit = Option.String({ required: false })
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' })
24
+ skipGate = Option.Boolean('--skip-gate', false, { description: 'skip the ci gate preflight — publishes unverified, use only to recover a stuck release' })
25
+
26
+ async execute(): Promise<number> {
27
+ try {
28
+ const config = await loadRepoConfig(process.cwd())
29
+ return await runRelease(process.cwd(), config, {
30
+ dryRun: this.dryRun,
31
+ allowDirty: this.allowDirty,
32
+ skipGate: this.skipGate,
33
+ unit: this.unit,
34
+ print: (l) => this.context.stdout.write(`${l}\n`),
35
+ })
36
+ } catch (e) {
37
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
38
+ return 1
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,42 @@
1
+ // shipkit releases — every release unit and where it actually stands. Thin
2
+ // clipanion surface; logic in release/run-releases.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { loadRepoConfig } from '../repo-config.ts'
5
+ import { collectReports, renderReports } from '../release/run-releases.ts'
6
+
7
+ export class ReleasesCommand extends Command {
8
+ static override paths = [['releases']]
9
+
10
+ static override usage = Command.Usage({
11
+ category: 'Gate',
12
+ description: 'every release unit and where it stands: version, npm, tag local and remote, GitHub release, drift. Reports, never gates (only an unresolvable config exits non-zero)',
13
+ examples: [
14
+ ['what is published and what is not', 'shipkit releases'],
15
+ ['filesystem + local git only, no network', 'shipkit releases --offline'],
16
+ ['machine-readable', 'shipkit releases --json'],
17
+ ],
18
+ })
19
+
20
+ json = Option.Boolean('--json', false, { description: 'emit { units: UnitReport[], errors: string[] } JSON' })
21
+ offline = Option.Boolean('--offline', false, { description: 'skip npm, the remote and gh — local facts only' })
22
+
23
+ async execute(): Promise<number> {
24
+ const root = process.cwd()
25
+ try {
26
+ const config = await loadRepoConfig(root)
27
+ const { reports, errors, remoteError } = await collectReports(root, config, { offline: this.offline })
28
+ if (this.json) {
29
+ this.context.stdout.write(`${JSON.stringify({ units: reports, errors }, null, 2)}\n`)
30
+ return errors.length > 0 ? 1 : 0
31
+ }
32
+ for (const l of renderReports(reports, { remoteError, offline: this.offline })) this.context.stdout.write(`${l}\n`)
33
+ // a unit nobody can describe is a repo bug, not a world fact: the
34
+ // listing still prints, and the exit code still says something broke
35
+ for (const e of errors) this.context.stderr.write(`FAIL: ${e}\n`)
36
+ return errors.length > 0 ? 1 : 0
37
+ } catch (e) {
38
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
39
+ return 1
40
+ }
41
+ }
42
+ }
@@ -1,24 +1,36 @@
1
- // shipkit rules — render the SK rule registry. Agents read ids HERE (or
2
- // --json), never guess or mint one: the registry is the single source.
1
+ // shipkit rules, render the rule registry: shipkit's built-in SK rules
2
+ // plus whatever the repo registered through `repo.config.ts rules: []`.
3
+ // Agents read ids HERE (or --json), never guess or mint one: the registry
4
+ // is the single source, and a repo whose own invariants are absent from it
5
+ // is a repo whose agents cannot know what binds them.
3
6
  import { Command, Option } from 'clipanion'
4
7
  import { RULES } from '../rules/rule-registry.ts'
8
+ import { loadRepoConfig } from '../repo-config.ts'
5
9
 
6
10
  export class RulesCommand extends Command {
7
11
  static override paths = [['rules']]
8
12
 
9
13
  static override usage = Command.Usage({
10
14
  category: 'Health',
11
- description: 'list every built-in SK rule: id, what it lints, owning doc, whether check --fix can repair it',
15
+ description: 'every rule that runs here: whether it BLOCKS or only warns, what it lints, the owning doc, and whether --fix can repair it. Built-in SK rules plus the repo\'s own.',
12
16
  examples: [
13
17
  ['list rules', 'shipkit rules'],
14
18
  ['machine-readable', 'shipkit rules --json'],
15
19
  ],
16
20
  })
17
21
 
18
- json = Option.Boolean('--json', false, { description: 'machine-readable [{id, title, owner, fixable}]' })
22
+ json = Option.Boolean('--json', false, { description: 'machine-readable [{id, title, owner, blocking, fixable, source}]' })
19
23
 
20
24
  async execute(): Promise<number> {
21
- const rows = RULES.map((r) => ({ id: r.id, title: r.title, owner: r.owner, fixable: r.fix != null }))
25
+ // a repo without repo.config.ts still gets the built-in catalog: this
26
+ // command must answer "what rules exist" from anywhere
27
+ const repoRules = await loadRepoConfig(process.cwd())
28
+ .then((c) => c.rules ?? [])
29
+ .catch(() => [])
30
+ const rows = [
31
+ ...RULES.map((r) => ({ id: r.id, title: r.title, owner: r.owner, blocking: r.blocking, fixable: r.fix != null, source: 'shipkit' as const })),
32
+ ...repoRules.map((r) => ({ id: r.id, title: r.title, owner: r.owner, blocking: r.blocking, fixable: r.fix != null, source: 'repo' as const })),
33
+ ]
22
34
  if (this.json) {
23
35
  this.context.stdout.write(`${JSON.stringify(rows)}\n`)
24
36
  return 0
@@ -26,7 +38,9 @@ export class RulesCommand extends Command {
26
38
  // a catalog, not a health run — no wire prefixes (`ok:` would assert
27
39
  // a passing check into anything that parses wire output)
28
40
  for (const r of rows)
29
- this.context.stdout.write(`${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}\n`)
41
+ this.context.stdout.write(`${r.blocking ? 'BLOCKS ' : 'warns '}${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}\n`)
42
+ if (repoRules.length > 0)
43
+ this.context.stdout.write(`\n${RULES.length} built-in · ${repoRules.length} repo-local (repo.config.ts rules)\n`)
30
44
  return 0
31
45
  }
32
46
  }
@@ -9,7 +9,7 @@ export class StartCommand extends Command {
9
9
 
10
10
  static override usage = Command.Usage({
11
11
  category: 'Setup',
12
- description: 'ensure <root>/<name> exists, optionally with a CLAUDE.md include line; prints the dir on stdout so a shell alias can cd there',
12
+ description: 'ensure <root>/<name> exists, optionally with a CLAUDE.md include line. Prints the dir on stdout so a shell alias can cd there',
13
13
  examples: [
14
14
  ['start (or enter) a repo', 'shipkit start pong'],
15
15
  ['seed CLAUDE.md includes', "shipkit start pong --include '@~/wiki/index.md' --include '@./agent/index.md'"],
@@ -18,7 +18,7 @@ export class StartCommand extends Command {
18
18
 
19
19
  name = Option.String()
20
20
  root = Option.String('--root', { description: 'parent dir for repos (default ~/dev)' })
21
- include = Option.Array('--include', { description: 'line CLAUDE.md must carry, repeatable, order kept; omitted = only ensure the dir' })
21
+ include = Option.Array('--include', { description: 'line CLAUDE.md must carry, repeatable, order kept. Omitted means only ensure the dir' })
22
22
  alsoOk = Option.Array('--also-ok', { description: 'alternative lines satisfying the first --include — never double-add' })
23
23
  skip = Option.Array('--skip', { description: 'repo names that never get the include' })
24
24
 
package/src/cli.ts CHANGED
@@ -1,18 +1,23 @@
1
1
  #!/usr/bin/env bun
2
- // shipkit — clipanion entry: one command per file in cli/ (cmd_<Name>.ts),
2
+ // shipkit — clipanion entry: one command FAMILY per file in cli/ (cmd_<Name>.ts),
3
3
  // logic in plain modules the commands call. runExit sets the exit code
4
4
  // without force-exiting, so serve commands keep the process alive.
5
5
  import { readFileSync } from 'fs'
6
6
  import { Builtins, Cli } from 'clipanion'
7
7
  import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
8
8
  import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand } from './cli/cmd_Assets.ts'
9
+ import { BumpCommand } from './cli/cmd_Bump.ts'
9
10
  import { CheckCommand } from './cli/cmd_Check.ts'
11
+ import { CommandsCommand } from './cli/cmd_Commands.ts'
10
12
  import { CiCommand } from './cli/cmd_Ci.ts'
11
13
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
14
+ import { HooksCommitMsgCommand, HooksInstallCommand, HooksListCommand, HooksPreCommitCommand } from './cli/cmd_Hooks.ts'
12
15
  import { InitCommand } from './cli/cmd_Init.ts'
13
16
  import { MvCommand } from './cli/cmd_Mv.ts'
14
17
  import { NeedsCommand } from './cli/cmd_Needs.ts'
15
18
  import { ReflectCommand } from './cli/cmd_Reflect.ts'
19
+ import { ReleaseCommand } from './cli/cmd_Release.ts'
20
+ import { ReleasesCommand } from './cli/cmd_Releases.ts'
16
21
  import { RulesCommand } from './cli/cmd_Rules.ts'
17
22
  import { ServeCommand } from './cli/cmd_Serve.ts'
18
23
  import { StartCommand } from './cli/cmd_Start.ts'
@@ -32,6 +37,13 @@ const cli = new Cli({
32
37
  cli.register(Builtins.HelpCommand)
33
38
  cli.register(Builtins.VersionCommand)
34
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)
35
47
  cli.register(MvCommand)
36
48
  cli.register(CheckCommand)
37
49
  cli.register(TuiCommand)
@@ -52,5 +64,6 @@ cli.register(StartCommand)
52
64
  cli.register(NeedsCommand)
53
65
  cli.register(ReflectCommand)
54
66
  cli.register(RulesCommand)
67
+ cli.register(CommandsCommand)
55
68
 
56
69
  cli.runExit(process.argv.slice(2))
@@ -4,7 +4,7 @@
4
4
  // variants are sibling files `<slug>.a.md`, `<slug>.b.md`; picking one flips
5
5
  // it to picked and the others to rejected (files KEPT). Severities:
6
6
  // any file missing `status:` → red (metadata with no inventable default)
7
- // drafts awaiting a pick → yellow (his taste)
7
+ // drafts awaiting a pick → yellow (a pick is a taste act)
8
8
  // picked but not posted → red (paste-from-elsewhere: post + URL proof)
9
9
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
10
10
  import { join } from 'path'
@@ -3,7 +3,7 @@
3
3
  // Behaviors preserved by name: per-source failure isolation (projectNeeds
4
4
  // guards per collection), the identity self-heal before surfacing, and the
5
5
  // asks READ path never minting asks/ on a repo without one. Dedup doctrine
6
- // (his correction 2026-07-23): dedup = acted-on once, NEVER hidden — an
6
+ // : dedup = acted-on once, NEVER hidden — an
7
7
  // ask-covered missing cred still shows RED in secrets with a pointer to the
8
8
  // ask; verbs and pings stay with the ask. Each wire line of STATUS.md lives
9
9
  // in exactly ONE collection: cred probe lines in secrets, everything else
@@ -6,7 +6,10 @@
6
6
 
7
7
  export type RuleOpts = { warnOnly?: boolean }
8
8
  export type FileRule = RuleOpts & { kind: 'file'; path: string; nonEmpty: boolean }
9
- export type PngRule = RuleOpts & { kind: 'png'; path: string; width: number; height: number }
9
+ /** `atLeast`: the venue's numbers are a MINIMUM at a fixed aspect ratio (itch
10
+ * displays a 630x500 cover but accepts larger, and a 2x asset is better) —
11
+ * without it an exact-size rule blocks the release over a better file */
12
+ export type PngRule = RuleOpts & { kind: 'png'; path: string; width: number; height: number; atLeast?: boolean }
10
13
  export type GlobRule = RuleOpts & { kind: 'glob'; pattern: string; min: number; width?: number; height?: number }
11
14
  export type MdRule = RuleOpts & { kind: 'md'; path: string; mustContain: string[] }
12
15
  export type DirRule = RuleOpts & { kind: 'dir'; path: string }
@@ -41,7 +44,7 @@ export type ContractManifest = {
41
44
  }
42
45
 
43
46
  export const file = (path: string, p?: RuleOpts & { nonEmpty?: boolean }): FileRule => ({ kind: 'file', path, nonEmpty: p?.nonEmpty ?? true, warnOnly: p?.warnOnly })
44
- export const png = (path: string, width: number, height: number, p?: RuleOpts): PngRule => ({ kind: 'png', path, width, height, warnOnly: p?.warnOnly })
47
+ export const png = (path: string, width: number, height: number, p?: RuleOpts & { atLeast?: boolean }): PngRule => ({ kind: 'png', path, width, height, atLeast: p?.atLeast, warnOnly: p?.warnOnly })
45
48
  export const glob = (pattern: string, p: RuleOpts & { min: number; width?: number; height?: number }): GlobRule => ({ kind: 'glob', pattern, min: p.min, width: p.width, height: p.height, warnOnly: p.warnOnly })
46
49
  export const md = (path: string, p?: RuleOpts & { mustContain?: string[] }): MdRule => ({ kind: 'md', path, mustContain: p?.mustContain ?? [], warnOnly: p?.warnOnly })
47
50
  export const dir = (path: string, p?: RuleOpts): DirRule => ({ kind: 'dir', path, warnOnly: p?.warnOnly })
@@ -57,11 +57,21 @@ async function checkRule(rule: ArtifactRule, env: ContractEnv): Promise<string[]
57
57
  }
58
58
  case 'png': {
59
59
  const p = abs(env.root, rule.path)
60
- if (!existsSync(p)) { problems.push(`${rule.path} missing — need png ${rule.width}x${rule.height}`); break }
60
+ const need = rule.atLeast === true ? `>= ${rule.width}x${rule.height} at ${rule.width}:${rule.height}` : `${rule.width}x${rule.height}`
61
+ if (!existsSync(p)) { problems.push(`${rule.path} missing — need png ${need}`); break }
61
62
  const dims = pngDims(p)
62
63
  if (dims == null) problems.push(`${rule.path} is not a readable png`)
63
- else if (dims.width !== rule.width || dims.height !== rule.height)
64
- problems.push(`${rule.path} is ${dims.width}x${dims.height} — need ${rule.width}x${rule.height}`)
64
+ else if (rule.atLeast !== true) {
65
+ if (dims.width !== rule.width || dims.height !== rule.height) problems.push(`${rule.path} is ${dims.width}x${dims.height} — need ${need}`)
66
+ } else {
67
+ const want = rule.width / rule.height
68
+ // RELATIVE tolerance: an absolute one is 0.8% at 630:500 and
69
+ // 3% at a tall ratio, i.e. a different rule per venue
70
+ const ratioOff = Math.abs(dims.width / dims.height - want) / want > 0.01
71
+ const tooSmall = dims.width < rule.width || dims.height < rule.height
72
+ if (tooSmall || ratioOff)
73
+ problems.push(`${rule.path} is ${dims.width}x${dims.height} — need ${need}${ratioOff ? ' (the ratio is off; a bigger asset at the same ratio is fine)' : ' (bigger is fine, smaller is not)'}`)
74
+ }
65
75
  break
66
76
  }
67
77
  case 'glob': {
@@ -1,5 +1,5 @@
1
1
  // normative WHAT for .rv-discord/ — a project's public Discord server
2
- // contract (promoted out of .rv-social/discord/ 2026-07-21; posts stay there).
2
+ // contract. Posts stay in .rv-social/.
3
3
  import { defineContract, dirExists, md, png } from 'rvlib-shipkit/src/contracts/contract-types.ts'
4
4
 
5
5
  export default defineContract({
@@ -10,6 +10,6 @@ export default defineContract({
10
10
  detect: [dirExists('.rv-discord')],
11
11
  artifacts: [
12
12
  md('.rv-discord/server.md', { mustContain: ['invite'] }),
13
- png('.rv-discord/icon.png', 512, 512),
13
+ png('.rv-discord/icon.png', 512, 512, { atLeast: true }),
14
14
  ],
15
15
  })
@@ -13,7 +13,7 @@ export default defineContract({
13
13
  artifacts: [
14
14
  file('.rv-identity/brand.ts'),
15
15
  png('public/favicon.png', 512, 512),
16
- png('public/og.png', 1200, 630),
16
+ png('public/og.png', 1200, 630, { atLeast: true }),
17
17
  glob('public/logo.*', { min: 1 }),
18
18
  ],
19
19
  })
@@ -1,5 +1,7 @@
1
1
  // normative WHAT for .rv-itch/ — the WHY + workflow live in the sibling page.
2
- import { defineContract, dirExists, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
2
+ import { custom, defineContract, dirExists, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
3
+ import { existsSync, readFileSync } from 'fs'
4
+ import { join } from 'path'
3
5
 
4
6
  export default defineContract({
5
7
  venue: 'itch',
@@ -10,7 +12,20 @@ export default defineContract({
10
12
  artifacts: [
11
13
  // one AI-disclosure wording everywhere — page.md is its canonical home
12
14
  md('.rv-itch/page.md', { mustContain: ['## AI disclosure'] }),
13
- png('.rv-itch/cover.png', 630, 500),
15
+ // every field of itch's create-project form, answered and ready to
16
+ // paste — the listing is a browser-only act, so the artifact IS the
17
+ // handoff (references/listing.template.md is the fill-in copy)
18
+ md('.rv-itch/listing.md', { mustContain: ['## Title', '## Short description or tagline', '## Classification', '## Genre', '## Tags', '## Visibility'] }),
19
+ // headings alone would pass the TEMPLATE copied in verbatim, and this
20
+ // file exists to be pasted with no decision left in it
21
+ custom('.rv-itch/listing.md answered', (env) => {
22
+ const p = join(env.root, '.rv-itch/listing.md')
23
+ if (!existsSync(p)) return []
24
+ const left = [...readFileSync(p, 'utf8').matchAll(/<[a-z][^<>\n]{2,60}>/g)].map((m) => m[0])
25
+ return left.length === 0 ? [] : [`.rv-itch/listing.md still holds ${left.length} unanswered placeholder(s): ${[...new Set(left)].slice(0, 3).join(' ')} — it is pasted into the store form as-is`]
26
+ }),
27
+ // itch DISPLAYS 630x500 and accepts larger — atLeast, never exact-size
28
+ png('.rv-itch/cover.png', 630, 500, { atLeast: true }),
14
29
  glob('.rv-itch/screenshots/*.png', { min: 3 }),
15
30
  ],
16
31
  })
@@ -1,21 +1,19 @@
1
1
  // The canonical package.json script bodies for a shipkit repo — vocabulary
2
2
  // owned by meta/rv-conventions/package-json.md. ONE source: `shipkit init`
3
3
  // scaffolds from it and rule SK001's autofix repairs from it.
4
+ //
5
+ // a script that only forwards to a shipkit verb is NOT canon: the verb is the
6
+ // vocabulary, and the alias is one more name to keep in sync. only a body
7
+ // shipkit cannot express itself earns a script.
4
8
  export type ScriptStyle = {
5
9
  /** in-repo-source consumers: path to shipkit's cli.ts — bodies become `bun <path> <cmd>` */
6
10
  bin?: string
7
11
  }
8
12
 
9
13
  export function canonicalBodies(opts: ScriptStyle = {}): Record<string, string> {
10
- const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
11
14
  return {
12
- ci: run('ci'),
13
- check: run('check'),
14
- contracts: run('contracts'),
15
- tui: run('tui'),
16
- mv: run('mv'),
17
- // hub runs in WATCH mode (house default 2026-07-23): lib/config edits
18
- // restart the server live; the npm shim can't watch, the source path can
15
+ // hub runs in WATCH mode: lib/config edits restart the server live.
16
+ // `shipkit serve` alone cannot watch, so this one is not an alias
19
17
  hub: `bun --watch ${opts.bin ?? 'node_modules/rvlib-shipkit/src/cli.ts'} serve`,
20
18
  // baseline `test` is universal — lint/fmt bodies are stack-specific,
21
19
  // the scaffold pages own those
@@ -24,14 +22,11 @@ export function canonicalBodies(opts: ScriptStyle = {}): Record<string, string>
24
22
  }
25
23
 
26
24
  // conditional canon (bodies stated by the vocabulary page) — keyed by the
27
- // SK001 requirement name; requirements with no canonical body (assets,
28
- // comfy:up) are deliberately absent: a fixer must not invent one
29
- export function conditionalBodies(opts: ScriptStyle = {}): Record<string, string> {
30
- const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
25
+ // SK001 requirement name; a requirement with no canonical body (comfy:up) is
26
+ // deliberately absent: a fixer must not invent one
27
+ export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, string> {
31
28
  return {
32
29
  'up': './deploy.sh',
33
- 'asks': run('asks'),
34
- 'assets:identity': run('assets identity'),
35
30
  }
36
31
  }
37
32
 
@@ -163,7 +163,7 @@ export class CheckSuite {
163
163
  if (r.ttlMs > 0) {
164
164
  // a skip line is an UNKNOWN, not a clean pass — replaying
165
165
  // "registry unreachable" for a TTL asserts a dead network as
166
- // fact long after it recovered (review find 2026-07-26)
166
+ // fact long after it recovered
167
167
  if (!t.failed && !t.warned && !t.lines.some((l) => l.kind === 'skip')) { cache[r.name] = { version: r.version, key, at: this.now(), lines: t.lines }; cacheDirty = true }
168
168
  // a WARN/FAIL evicts the stale pass — a later run must re-probe,
169
169
  // not replay an ok recorded before the breakage
@@ -1,6 +1,7 @@
1
- // *.check.ts auto-discovery — THE way health checks register (repo.config
2
- // `checks:[]` is dead, one-runner GO 2026-07-22): drop a `<name>.check.ts`
3
- // anywhere in the repo and `shipkit check` runs it as a subprocess — wire
1
+ // *.check.ts auto-discovery — THE way a repo registers its OWN checks
2
+ // (built-in SK rules come from rules/rule-registry.ts): drop a
3
+ // `<name>.check.ts` anywhere in the repo and `shipkit check` runs it as a
4
+ // subprocess — wire
4
5
  // output parsed back, exit code is truth. Skipped dirs: node_modules, dist,
5
6
  // tmp, references (templates for other repos), and every dot-dir (.git,
6
7
  // caches, .rv-* contract folders).
@@ -18,8 +19,16 @@ export type DiscoveredCheck = {
18
19
  name: string
19
20
  /** file runs its own CheckSuite (own TTL cache) — the orchestrator must not double-cache it */
20
21
  selfCached: boolean
22
+ /** `export const blocking = true | false`, read statically. null = the file
23
+ * never said, which is an error: a check that silently does not block is
24
+ * the failure nobody sees. */
25
+ blocking: boolean | null
21
26
  }
22
27
 
28
+ // read without importing: checks run as subprocesses, and discovery must stay
29
+ // pure-fs. `export const blocking = true` (or false), nothing cleverer.
30
+ const BLOCKING_RE = /^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)\s*$/m
31
+
23
32
  export function discoverChecks(root: string): DiscoveredCheck[] {
24
33
  const files: string[] = []
25
34
  const walk = (dir: string): void => {
@@ -39,10 +48,16 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
39
48
  return files.map((f) => {
40
49
  const base = baseOf(f)
41
50
  const name = (seen.get(base) ?? 0) > 1 ? relative(root, f).slice(0, -SUFFIX.length) : base
42
- // a check built on CheckSuite/SkillValidator replays from its OWN
51
+ // a check built on CheckSuite/PageValidator replays from its OWN
43
52
  // TTL cache — caching it again up here would double the staleness
44
53
  let selfCached = false
45
- try { selfCached = /SkillValidator|CheckSuite/.test(readFileSync(f, 'utf8')) } catch {}
46
- return { path: f, name, selfCached }
54
+ let blocking: boolean | null = null
55
+ try {
56
+ const src = readFileSync(f, 'utf8')
57
+ selfCached = /PageValidator|CheckSuite/.test(src)
58
+ const m = src.match(BLOCKING_RE)
59
+ blocking = m == null ? null : m[1] === 'true'
60
+ } catch {}
61
+ return { path: f, name, selfCached, blocking }
47
62
  })
48
63
  }
@@ -6,13 +6,14 @@
6
6
  // layers (SHIPKIT_FRESH=1 + RV_CHECK_FRESH=1 travel to children).
7
7
  import { join, relative } from 'path'
8
8
  import { CheckSuite, type CheckOutcome } from './check-suite.ts'
9
+ import { CheckCtx } from './check-ctx.ts'
9
10
  import { discoverChecks } from './discover-checks.ts'
10
11
  import { spawnWithTimeout } from './spawn-timeout.ts'
11
- import { loadRepoConfig } from '../repo-config.ts'
12
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
12
13
  import { reportToMotherhome } from './motherhome.ts'
13
14
  import { RULES } from '../rules/rule-registry.ts'
14
15
  import { runRuleLint } from '../rules/rule.ts'
15
- import { formatLine, parseWireOutput } from './wire.ts'
16
+ import { formatLine, parseWireOutput, type Line } from './wire.ts'
16
17
  import { writeStatus } from './write-status.ts'
17
18
 
18
19
  // orchestrator cache for checks WITHOUT their own suite — short on purpose:
@@ -31,11 +32,27 @@ export type CheckOpts = {
31
32
  print?: (line: string) => void
32
33
  /** per-check completion tap — the TUI checks panel streams results in */
33
34
  onResult?: (name: string, r: CheckOutcome) => void
35
+ /** the already-resolved config, same contract as runCi: the caller that
36
+ * decides severity and the engine that runs the rules must read the same
37
+ * object, or a blocking rule failure silently reclassifies as a probe */
38
+ config?: RepoConfig
34
39
  }
35
40
 
36
- export async function runCheck(root: string, o: CheckOpts = {}): Promise<number> {
37
- const print = o.print ?? ((l: string) => console.log(l))
38
- const c = await loadRepoConfig(root)
41
+ export type CheckRun = {
42
+ /** wire lines, with non-blocking failures already downgraded to warnings */
43
+ lines: Line[]
44
+ /** a BLOCKING check failed */
45
+ blocked: boolean
46
+ /** names of non-blocking checks that failed — reported, never fatal */
47
+ advisoryFailures: string[]
48
+ /** checks that never declared `blocking` — always fatal */
49
+ undeclared: string[]
50
+ }
51
+
52
+ /** run every check and classify by what it declared. This is the whole engine;
53
+ * runCi wraps it with the task DAG, and nothing else decides severity. */
54
+ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<CheckRun> {
55
+ const c = o.config ?? (await loadRepoConfig(root))
39
56
  // env carries the flags to child checks — restored after the run so a
40
57
  // long-lived caller (doctor, TUI, hub) is never poisoned by one
41
58
  // fresh/frozen invocation
@@ -44,15 +61,29 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
44
61
  if (o.fresh === true) process.env.SHIPKIT_FRESH = '1'
45
62
  if (o.frozen === true) process.env.SHIPKIT_FROZEN = '1'
46
63
  const suite = new CheckSuite({ name: c.name, cacheDir: join(root, 'tmp/.shipkit-cache'), onResult: o.onResult })
47
- // built-in SK rules (rules/rule-registry.ts) — id-tagged, crash-safe,
48
- // fix-pointer floor (rules/rule.ts runRuleLint owns the semantics);
49
- // doctor surface only, deliberately absent from the ci gate
50
- for (const rule of RULES)
64
+ // built-in SK rules (rules/rule-registry.ts) THEN the repo's own
65
+ // (repo.config rules: []), id-tagged, crash-safe, fix-pointer floor
66
+ // (rules/rule.ts runRuleLint owns the semantics). `shipkit ci` runs this
67
+ // same engine, so a blocking rule blocks a commit as well as a doctor run
68
+ const undeclared: string[] = []
69
+ const advisoryFailures: string[] = []
70
+ // a non-blocking failure becomes a WARNING here, at the source. printing
71
+ // FAIL and exiting 0 would be the tool lying about its own severity, and
72
+ // demoting in the check means the suite's exit code already means exactly
73
+ // "something that blocks you failed".
74
+ const demote = (t: CheckCtx, blocking: boolean, name: string): void => {
75
+ if (blocking || !t.failed) return
76
+ if (!advisoryFailures.includes(name)) advisoryFailures.push(name)
77
+ t.lines = t.lines.map((l) => (l.kind === 'fail' ? { ...l, kind: 'warn' as const } : l.kind === 'fix-fail' ? { ...l, kind: 'fix-warn' as const } : l))
78
+ t.failed = false
79
+ t.warned = true
80
+ }
81
+ for (const rule of [...RULES, ...(c.rules ?? [])])
51
82
  suite.addCheck({
52
83
  name: rule.title,
53
84
  remainValidFor: rule.ttl,
54
85
  cacheExtra: rule.cacheKey != null ? () => rule.cacheKey!({ root, config: c }) : undefined,
55
- logic: (t) => runRuleLint(rule, t, { root, config: c }),
86
+ logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
56
87
  })
57
88
  const childEnv: Record<string, string | undefined> = {
58
89
  ...process.env,
@@ -60,6 +91,10 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
60
91
  }
61
92
  for (const d of discoverChecks(root)) {
62
93
  const rel = relative(root, d.path)
94
+ // file wins, then the repo default, then nothing: an unclassified
95
+ // check is an error, never a guess
96
+ const blocking = d.blocking ?? c.checkDefaults?.blocking
97
+ if (blocking == null) undeclared.push(rel)
63
98
  suite.addCheck({
64
99
  name: d.name,
65
100
  remainValidFor: d.selfCached ? undefined : DISCOVERED_TTL,
@@ -72,22 +107,35 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
72
107
  // (crash, non-wire output, timeout) must still fail loud
73
108
  if (r.code !== 0 && !t.failed)
74
109
  t.fail(`${d.name} exited ${r.code}${r.timedOut ? ` (timeout after ${CHECK_TIMEOUT_MS}ms)` : ''} — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}`, `bun ${rel}`)
110
+ demote(t, blocking ?? true, d.name)
75
111
  },
76
112
  })
77
113
  }
78
114
  const r = await suite.execute()
79
115
  if (prevFresh == null) delete process.env.SHIPKIT_FRESH; else process.env.SHIPKIT_FRESH = prevFresh
80
116
  if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
81
- // report to the motherhome (docsBase wiki on this disk) — never fails
82
- // the check, notes only when it wrote or broke
83
- const mh = reportToMotherhome(root, c)
84
- const statusOutcome = o.status !== false
85
- ? writeStatus(root, { name: c.name, command: 'shipkit check', lines: r.lines.map((l) => formatLine(l, false)), exitCode: r.exitCode })
86
- : null
87
- if (o.json === true) { print(JSON.stringify(r)); return r.exitCode }
117
+ reportToMotherhome(root, c)
118
+ return { lines: r.lines, blocked: r.exitCode !== 0, advisoryFailures, undeclared }
119
+ }
120
+
121
+ /** `shipkit check` as a standalone command: the same engine, printed. */
122
+ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number> {
123
+ const print = o.print ?? ((l: string) => console.log(l))
124
+ const c = o.config ?? (await loadRepoConfig(root))
125
+ const run = await collectChecks(root, { ...o, config: c })
126
+ const code = run.blocked || run.undeclared.length > 0 ? 1 : 0
127
+ if (o.json === true) { print(JSON.stringify(run)); return code }
88
128
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
89
- for (const l of r.lines) print(formatLine(l, color))
90
- if (mh.note != null) print(mh.note)
91
- if (statusOutcome != null && !statusOutcome.written) print(`WARN ${statusOutcome.warn}`)
92
- return r.exitCode
129
+ for (const l of run.lines) print(formatLine(l, color))
130
+ for (const u of run.undeclared) {
131
+ print(`FAIL: ${u} does not declare whether it blocks`)
132
+ print(` fix: add \`export const blocking = true\` (about this repo) or \`false\` (about the world) to that file`)
133
+ }
134
+ if (o.status !== false) {
135
+ // the producing command is named honestly: a checks-only run must not
136
+ // overwrite the gate verdict with a green nobody gated
137
+ const st = writeStatus(root, { name: c.name, command: 'shipkit check', lines: run.lines.map((l) => formatLine(l, false)), exitCode: code })
138
+ if (!st.written) print(`WARN ${st.warn}`)
139
+ }
140
+ return code
93
141
  }