rvlib-shipkit 0.14.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 (65) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +6 -22
  3. package/package.json +1 -1
  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/manifests/discord.contract.ts +1 -1
  21. package/src/contracts/manifests/itch.contract.ts +2 -4
  22. package/src/core/canonical-scripts.ts +9 -14
  23. package/src/core/check-suite.ts +1 -1
  24. package/src/core/discover-checks.ts +21 -6
  25. package/src/core/run-check.ts +69 -21
  26. package/src/core/run-ci.ts +75 -9
  27. package/src/core/run-fixers.ts +144 -0
  28. package/src/core/write-status.ts +14 -5
  29. package/src/hooks/commit-message.ts +42 -0
  30. package/src/hooks/leak-guard.ts +0 -0
  31. package/src/hooks/run-hooks.ts +131 -0
  32. package/src/init-repo.ts +10 -5
  33. package/src/reflect/prompt-corpus.ts +3 -4
  34. package/src/release/changelog.ts +53 -0
  35. package/src/release/git-facts.ts +133 -0
  36. package/src/release/run-bump.ts +419 -0
  37. package/src/release/run-release.ts +267 -0
  38. package/src/release/run-releases.ts +230 -0
  39. package/src/release/unit-gate.ts +67 -0
  40. package/src/release/units.ts +246 -0
  41. package/src/repo-config.ts +56 -2
  42. package/src/rules/SK001-package-json-shape.ts +39 -17
  43. package/src/rules/SK002-contracts-fulfillness.ts +1 -0
  44. package/src/rules/SK003-claude-md-shape.ts +2 -1
  45. package/src/rules/SK004-gitignore-policy.ts +1 -0
  46. package/src/rules/SK005-rvlib-versions.ts +22 -3
  47. package/src/rules/SK006-npm-publish-safety.ts +1 -0
  48. package/src/rules/SK007-github-visibility.ts +5 -1
  49. package/src/rules/SK008-reviewer-agent.ts +1 -0
  50. package/src/rules/SK009-commit-ai-tells.ts +66 -0
  51. package/src/rules/SK010-undeclared-release-unit.ts +114 -0
  52. package/src/rules/rule-registry.ts +26 -6
  53. package/src/rules/rule.ts +8 -1
  54. package/src/rules/run-fix.ts +15 -8
  55. package/src/serve/hub-app/col-card.tsx +2 -3
  56. package/src/serve/hub-app/hub-app.tsx +6 -8
  57. package/src/serve/hub-app/hub-st.ts +4 -7
  58. package/src/serve/hub-app/media-view.tsx +2 -2
  59. package/src/serve/run-serve.ts +4 -5
  60. package/src/serve/serve-hub.ts +2 -3
  61. package/src/shipkit.ts +9 -1
  62. package/src/tui/checks-panel.ts +1 -1
  63. package/src/tui/run-tui.ts +2 -3
  64. package/src/tui/tasks-panel.ts +10 -4
  65. package/src/tui/tui-store.ts +2 -2
@@ -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
@@ -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({
@@ -17,16 +17,14 @@ export default defineContract({
17
17
  // handoff (references/listing.template.md is the fill-in copy)
18
18
  md('.rv-itch/listing.md', { mustContain: ['## Title', '## Short description or tagline', '## Classification', '## Genre', '## Tags', '## Visibility'] }),
19
19
  // headings alone would pass the TEMPLATE copied in verbatim, and this
20
- // file exists to be pasted with no decision left in it (field
21
- // 2026-07-27: a reviewer proved `cp template listing.md` went green)
20
+ // file exists to be pasted with no decision left in it
22
21
  custom('.rv-itch/listing.md answered', (env) => {
23
22
  const p = join(env.root, '.rv-itch/listing.md')
24
23
  if (!existsSync(p)) return []
25
24
  const left = [...readFileSync(p, 'utf8').matchAll(/<[a-z][^<>\n]{2,60}>/g)].map((m) => m[0])
26
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`]
27
26
  }),
28
- // itch DISPLAYS 630x500 and accepts larger — an exact-size rule would
29
- // block a release over a better cover (audit 2026-07-27)
27
+ // itch DISPLAYS 630x500 and accepts larger — atLeast, never exact-size
30
28
  png('.rv-itch/cover.png', 630, 500, { atLeast: true }),
31
29
  glob('.rv-itch/screenshots/*.png', { min: 3 }),
32
30
  ],
@@ -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
  }
@@ -1,11 +1,16 @@
1
- // the ci gate — floor + contracts + repo tasks through the DAG runner.
2
- // Plain module behind cmd_Ci (logic stays out of command classes) so tests
3
- // can run the gate in-process. Returns the exit code; json prints the
4
- // structured summary. Every run refreshes STATUS.md (write-status.ts —
5
- // atomic + lock) unless status:false.
1
+ // the ci gate — floor + contracts + repo tasks through the DAG runner, THEN
2
+ // every discovered check (core/run-check.ts owns severity). Plain module
3
+ // behind cmd_Ci (logic stays out of command classes) so tests can run the
4
+ // gate in-process. Returns the exit code; json prints the structured summary.
5
+ // Every run refreshes STATUS.md (write-status.ts — atomic + lock) unless
6
+ // status:false, and the fix pass plus some checks WRITE, so a run is not
7
+ // read-only unless frozen is set and --no-fix is passed.
6
8
  import { join } from 'path'
7
- import { allTasks, loadRepoConfig } from '../repo-config.ts'
9
+ import { allTasks, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
8
10
  import { reportToMotherhome } from './motherhome.ts'
11
+ import { collectChecks } from './run-check.ts'
12
+ import { runFixPass } from './run-fixers.ts'
13
+ import { formatLine } from './wire.ts'
9
14
  import { runTasks, type TaskResult } from './task-runner.ts'
10
15
  import { writeStatus } from './write-status.ts'
11
16
 
@@ -17,17 +22,57 @@ export type CiOpts = {
17
22
  onlyIds?: string[]
18
23
  /** refresh STATUS.md after the run (default true — O4: every repo gets it) */
19
24
  status?: boolean
25
+ /** repair the mechanical slots before gating. default on locally, verify
26
+ * mode under CI. core/run-fixers.ts owns the scope. */
27
+ fix?: boolean
28
+ /** also apply the structural SK rule fixers during the fix pass */
29
+ fixRules?: boolean
30
+ /** report-only for self-maintaining rules and checks (SHIPKIT_FROZEN
31
+ * travels to child checks). The release path passes it: a rule that
32
+ * rewrites package.json between the gate and `npm publish` packs a tree
33
+ * that exists in no commit. */
34
+ frozen?: boolean
35
+ /** treat a NON-blocking check failure as fatal too. `release.requireHealthy`
36
+ * is the only caller: a repo that wants a missing cred to stop a release
37
+ * asks for it, and every other run keeps the declared severity. */
38
+ failOnAdvisory?: boolean
39
+ /** run the tasks only. The TUI owns this: it renders the checks in its own
40
+ * panel, and running the suite twice per refresh proves nothing twice. */
41
+ skipChecks?: boolean
20
42
  /** line sink — commands pass their context stream; defaults to console */
21
43
  print?: (line: string) => void
22
44
  /** per-task completion tap — the TUI tasks panel streams results in */
23
45
  onTaskFinish?: (r: TaskResult) => void
46
+ /** the already-resolved config. A caller that holds one MUST pass it: two
47
+ * loads of repo.config.ts can disagree, and the release path classifies
48
+ * rule-vs-probe severity from the config it holds. */
49
+ config?: RepoConfig
50
+ }
51
+
52
+ /** what STATUS.md may claim about this run. `--with` ADDS a task, so it stays
53
+ * the full gate; `--only`, `--skip` and skipChecks each remove coverage, and
54
+ * a narrowed run that stamps the full gate's verdict is a green nobody
55
+ * measured. The label names the invocation either way. */
56
+ function ciScope(o: CiOpts): { command: string; fullGate: boolean } {
57
+ const narrowing = [
58
+ ...(o.onlyIds ?? []).map((i) => `--only ${i}`),
59
+ ...(o.skipGroups ?? []).map((g) => `--skip ${g}`),
60
+ ...(o.skipChecks === true ? ['(tasks only)'] : []),
61
+ ]
62
+ const parts = [...narrowing, ...(o.withIds ?? []).map((i) => `--with ${i}`)]
63
+ return { command: parts.length === 0 ? 'shipkit ci' : `shipkit ci ${parts.join(' ')}`, fullGate: narrowing.length === 0 }
24
64
  }
25
65
 
26
66
  export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
27
67
  const print = o.print ?? ((l: string) => console.log(l))
28
68
  const collected: string[] = []
29
69
  const sink = (l: string): void => { collected.push(l); print(l) }
30
- const c = await loadRepoConfig(root)
70
+ const c = o.config ?? (await loadRepoConfig(root))
71
+ // mechanical failures are not information, so they never cost a round trip
72
+ let fixFailed = false
73
+ if (o.fix !== false)
74
+ fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, print: sink })).failed
75
+ else sink('skip: fix pass (--no-fix)')
31
76
  const { tasks, disabled, unfilled } = allTasks(c, root)
32
77
  for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
33
78
  // a floor slot nothing could fill is VISIBLE — a thin gate must never
@@ -44,12 +89,33 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
44
89
  print: sink,
45
90
  onTaskFinish: o.onTaskFinish,
46
91
  })
47
- const code = summary.ok ? 0 : 1
92
+ // the checks, in the SAME command. there is no second surface: a check
93
+ // declares whether it blocks, and that is the only question about it.
94
+ const checks = o.skipChecks === true
95
+ ? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
96
+ : await collectChecks(root, { status: false, config: c, frozen: o.frozen })
97
+ const color = process.stdout.isTTY && process.env.NO_COLOR == null
98
+ for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
99
+ for (const u of checks.undeclared) {
100
+ sink(`FAIL: ${u} does not declare whether it blocks`)
101
+ sink(` fix: add \`export const blocking = true\` (about this repo) or \`false\` (about the world) to that file`)
102
+ }
103
+ let advisoryFatal = false
104
+ if (checks.advisoryFailures.length > 0) {
105
+ if (o.failOnAdvisory === true) {
106
+ advisoryFatal = true
107
+ sink(`FAIL: ${checks.advisoryFailures.length} non-blocking check(s) failing and release.requireHealthy is on: ${checks.advisoryFailures.join(', ')}`)
108
+ sink(' fix: shipkit check')
109
+ } else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
110
+ }
111
+ const code = summary.ok && !fixFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
48
112
  // motherhome row (docsBase wiki on this disk) — never affects the gate
49
113
  const mh = reportToMotherhome(root, c)
50
114
  if (mh.note != null) print(mh.note)
51
115
  if (o.status !== false) {
52
- const st = writeStatus(root, { name: c.name, command: 'shipkit ci', lines: collected, exitCode: code })
116
+ // scope is named in the file: ci proves the gate, not repo health, and
117
+ // a bare GREEN here would claim clear-to-ship on a repo with red checks
118
+ const st = writeStatus(root, { name: c.name, ...ciScope(o), lines: collected, exitCode: code })
53
119
  if (!st.written) print(`WARN ${st.warn}`)
54
120
  }
55
121
  if (o.json === true)