rvlib-shipkit 0.10.0 → 0.13.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 (61) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +17 -3
  3. package/package.json +5 -2
  4. package/src/asks/asks-queue.ts +42 -0
  5. package/src/assets/kit-config.ts +2 -3
  6. package/src/assets/roll-workspace.ts +2 -1
  7. package/src/assets/run-assets.ts +0 -11
  8. package/src/assets/server/studio-server.ts +7 -24
  9. package/src/cli/cmd_Assets.ts +3 -14
  10. package/src/cli/cmd_Check.ts +22 -4
  11. package/src/cli/cmd_Needs.ts +47 -0
  12. package/src/cli/cmd_Reflect.ts +116 -0
  13. package/src/cli/cmd_Rules.ts +32 -0
  14. package/src/cli/cmd_Serve.ts +1 -1
  15. package/src/cli.ts +7 -2
  16. package/src/collections/actions.ts +85 -0
  17. package/src/collections/collection-types.ts +65 -0
  18. package/src/collections/graphs.ts +34 -0
  19. package/src/collections/pings.ts +72 -0
  20. package/src/collections/posts.ts +125 -0
  21. package/src/collections/providers.ts +275 -0
  22. package/src/contracts/apply-contracts.ts +47 -0
  23. package/src/contracts/run-contracts.ts +8 -3
  24. package/src/core/canonical-scripts.ts +46 -0
  25. package/src/core/check-suite.ts +6 -3
  26. package/src/core/locked-write.ts +39 -0
  27. package/src/core/motherhome.ts +87 -0
  28. package/src/core/run-check.ts +28 -15
  29. package/src/core/run-ci.ts +4 -0
  30. package/src/core/write-status.ts +7 -26
  31. package/src/init-repo.ts +24 -11
  32. package/src/reflect/prompt-corpus.ts +305 -0
  33. package/src/repo-config.ts +26 -38
  34. package/src/rules/SK001-package-json-shape.ts +156 -0
  35. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  36. package/src/rules/SK003-claude-md-shape.ts +64 -0
  37. package/src/rules/SK004-gitignore-policy.ts +105 -0
  38. package/src/rules/SK005-rvlib-versions.ts +99 -0
  39. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  40. package/src/rules/SK007-github-visibility.ts +63 -0
  41. package/src/rules/rule-registry.ts +32 -0
  42. package/src/rules/rule.ts +62 -0
  43. package/src/rules/run-fix.ts +57 -0
  44. package/src/serve/hub-app/col-card.tsx +192 -0
  45. package/src/serve/hub-app/cols-svc.ts +136 -0
  46. package/src/serve/hub-app/geometry-view.tsx +155 -0
  47. package/src/serve/hub-app/health-svc.ts +49 -0
  48. package/src/serve/hub-app/hub-app.tsx +240 -127
  49. package/src/serve/hub-app/hub-st.ts +169 -0
  50. package/src/serve/hub-app/hub-types.ts +72 -0
  51. package/src/serve/hub-app/hub.css +286 -35
  52. package/src/serve/hub-app/index.html +1 -1
  53. package/src/serve/hub-app/md.ts +24 -0
  54. package/src/serve/hub-app/media-svc.ts +429 -0
  55. package/src/serve/hub-app/media-view.tsx +299 -0
  56. package/src/serve/run-serve.ts +46 -91
  57. package/src/serve/serve-hub.ts +99 -49
  58. package/src/shipkit.ts +13 -6
  59. package/src/assets/server/studio-page.ts +0 -687
  60. package/src/core/package-json-check.ts +0 -88
  61. package/src/serve/hub-pages.ts +0 -45
@@ -0,0 +1,156 @@
1
+ // SK001 — canonical package.json shape (meta/rv-conventions/package-json.md):
2
+ // private:true, no license, capability-required script names FAIL, banned
3
+ // aliases + missing baseline trio WARN. Doctor surface only — never wired
4
+ // into the ci gate. Pure local fs, no TTL.
5
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
6
+ import { join } from 'path'
7
+ import { canonicalBodies, conditionalBodies, inferBin } from '../core/canonical-scripts.ts'
8
+ import type { CheckCtx } from '../core/check-ctx.ts'
9
+ import type { RepoConfig } from '../repo-config.ts'
10
+ import type { FixResult, Rule, RuleEnv } from './rule.ts'
11
+
12
+ type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string> }
13
+
14
+ const OWNER = 'meta/rv-conventions/package-json.md'
15
+
16
+ // alias → { canonical, bodyMatch } — WARN only when the body proves the
17
+ // alias really is the canonical capability (a `serve` that serves a dist/
18
+ // folder must not be told to rename)
19
+ const ALIASES: Record<string, { canonical: string; bodyMatch: RegExp }> = {
20
+ 'serve': { canonical: 'hub', bodyMatch: /(shipkit|cli\.ts) serve\b/ },
21
+ 'identity': { canonical: 'assets:identity', bodyMatch: /assets(:| )identity\b/ },
22
+ 'pretty': { canonical: 'fmt', bodyMatch: /./ },
23
+ 'check-types': { canonical: 'typecheck', bodyMatch: /./ },
24
+ }
25
+
26
+ const BASELINE = ['test', 'lint', 'fmt'] as const
27
+
28
+ // no per-line fix pointers here — the rule framework appends ONE pointer
29
+ // (docsBase + OWNER) to a failing/warning run, the same one-per-severity
30
+ // doctrine the contracts engine follows
31
+ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConfig): Promise<void> {
32
+ let pkg: Pkg
33
+ try {
34
+ pkg = (await Bun.file(join(root, 'package.json')).json()) as Pkg
35
+ } catch (e) {
36
+ t.fail(`package.json unreadable (${String(e).slice(0, 120)})`)
37
+ return
38
+ }
39
+ const scripts = pkg.scripts ?? {}
40
+ // rvlib-* as a probe ROOT is theoretical (libs live as sub-packages and
41
+ // the probe only runs where repo.config.ts loads) — field inversion is
42
+ // still handled so a future lib-root repo fails correctly, not absurdly
43
+ const isLib = pkg.name?.startsWith('rvlib-') === true
44
+
45
+ // published rvlib-* roots invert the field rules (distribute/npm owns them)
46
+ if (isLib) {
47
+ if (pkg.license !== 'MIT') t.fail(`package.json: rvlib-* package must carry license MIT`)
48
+ if (pkg.private === true) t.fail(`package.json: rvlib-* package must not be private`)
49
+ } else {
50
+ if (pkg.private !== true) t.fail(`package.json: "private": true is required`)
51
+ if (pkg.license != null) t.fail(`package.json: license field is banned on project repos (rvlib-* sub-packages excepted)`)
52
+ }
53
+
54
+ // aliases first — an alias satisfies the requirement but earns a WARN,
55
+ // never a double report (alias WARN + missing-canonical FAIL). A banned
56
+ // alias lingering NEXT TO its canonical still WARNs (sweep on sight),
57
+ // it just doesn't satisfy anything.
58
+ const aliased = new Set<string>()
59
+ for (const [name, a] of Object.entries(ALIASES)) {
60
+ const body = scripts[name]
61
+ if (body == null || !a.bodyMatch.test(body)) continue
62
+ if (scripts[a.canonical] == null) aliased.add(a.canonical)
63
+ t.warn(`package.json: script '${name}' is the house '${a.canonical}' — ${scripts[a.canonical] == null ? 'rename it' : `delete it ('${a.canonical}' exists)`}`)
64
+ }
65
+
66
+ const has = (name: string): boolean => scripts[name] != null || aliased.has(name)
67
+
68
+ const required = requiredScripts(root, c)
69
+ const missing = required.filter((r) => !has(r.name))
70
+ for (const m of missing) t.fail(`package.json: missing script '${m.name}' (${m.why})`)
71
+
72
+ const missingBaseline = BASELINE.filter((n) => !has(n))
73
+ if (missingBaseline.length > 0)
74
+ t.warn(`package.json: missing baseline script(s): ${missingBaseline.join(', ')}`)
75
+
76
+ if (!t.failed && !t.warned)
77
+ t.ok(`package.json shape ok (private, no license, ${required.length} required scripts present)`)
78
+ }
79
+
80
+ // capability → required script names (FAIL); repo.config.ts existence is
81
+ // implied — this rule only runs under `shipkit check`
82
+ function requiredScripts(root: string, c: RepoConfig): Array<{ name: string; why: string }> {
83
+ const required: Array<{ name: string; why: string }> = [
84
+ { name: 'ci', why: 'shipkit repo' },
85
+ { name: 'check', why: 'shipkit repo' },
86
+ { name: 'contracts', why: 'shipkit repo' },
87
+ { name: 'tui', why: 'shipkit repo' },
88
+ { name: 'hub', why: 'shipkit repo' },
89
+ { name: 'mv', why: 'shipkit repo' },
90
+ ]
91
+ if (existsSync(join(root, '.rv-assets/kit.ts'))) required.push({ name: 'assets', why: '.rv-assets/kit.ts exists' })
92
+ if (existsSync(join(root, '.rv-identity/brand.ts'))) required.push({ name: 'assets:identity', why: '.rv-identity/brand.ts exists' })
93
+ if (existsSync(join(root, '.rv-comfy'))) required.push({ name: 'comfy:up', why: '.rv-comfy/ exists' })
94
+ if (existsSync(join(root, 'deploy.sh'))) required.push({ name: 'up', why: 'deploy.sh exists' })
95
+ if (existsSync(join(root, c.asks?.dir ?? 'asks'))) required.push({ name: 'asks', why: 'ask queue exists' })
96
+ return required
97
+ }
98
+
99
+ export const packageJsonShape: Rule = {
100
+ id: 'SK001',
101
+ title: 'package.json',
102
+ owner: OWNER,
103
+ lint: (t: CheckCtx, env: RuleEnv) => evaluatePackageJson(t, env.root, env.config),
104
+ // mechanical repairs only: field flips + scripts whose canonical body the
105
+ // vocabulary page states. Bodies it does NOT state (assets, comfy:up) are
106
+ // named in the summary, never invented. rvlib-* roots untouched — their
107
+ // license/private are publish decisions.
108
+ fix: (env: RuleEnv): FixResult => {
109
+ const pkgPath = join(env.root, 'package.json')
110
+ if (!existsSync(pkgPath)) return { changed: false, summary: 'no package.json — nothing to fix' }
111
+ const raw = readFileSync(pkgPath, 'utf8')
112
+ let pkg: Pkg & { scripts?: Record<string, string> }
113
+ try { pkg = JSON.parse(raw) as Pkg } catch { return { changed: false, summary: 'package.json unparseable — repair it by hand first' } }
114
+ if (pkg.name?.startsWith('rvlib-') === true) return { changed: false, summary: 'rvlib-* package — license/private are publish decisions, not autofixable' }
115
+ const changes: string[] = []
116
+ const notes: string[] = []
117
+ // any "I am published" marker → the field flips are a publish
118
+ // decision, not a mechanical repair (the LIBRARY-profile gap in
119
+ // meta/gaps.md is still open)
120
+ const publishMarkers: string[] = ['files', 'exports', 'publishConfig'].filter((k) => (pkg as Record<string, unknown>)[k] != null)
121
+ if (pkg.scripts?.['prepublishOnly'] != null) publishMarkers.push('prepublishOnly')
122
+ if (publishMarkers.length > 0) {
123
+ notes.push(`publishable package (${publishMarkers.join(', ')}) — private/license left for a human`)
124
+ } else {
125
+ if (pkg.private !== true) { pkg.private = true; changes.push('private:true set') }
126
+ if (pkg.license != null) { delete pkg.license; changes.push('license removed') }
127
+ }
128
+ pkg.scripts ??= {}
129
+ const bin = inferBin(pkg.scripts)
130
+ const bodies: Record<string, string> = { ...canonicalBodies({ bin }), ...conditionalBodies({ bin }) }
131
+ const skipped: string[] = []
132
+ // a banned alias satisfies a requirement for the lint — adding its
133
+ // canonical beside it would create the exact duplicate the lint then
134
+ // tells a human to delete
135
+ const aliasSatisfied = new Set<string>()
136
+ for (const [name, a] of Object.entries(ALIASES))
137
+ if (pkg.scripts[name] != null && a.bodyMatch.test(pkg.scripts[name]!)) aliasSatisfied.add(a.canonical)
138
+ for (const m of requiredScripts(env.root, env.config).filter((r) => pkg.scripts![r.name] == null && !aliasSatisfied.has(r.name))) {
139
+ const body = bodies[m.name]
140
+ if (body == null) { skipped.push(m.name); continue }
141
+ pkg.scripts[m.name] = body
142
+ changes.push(`script '${m.name}' added`)
143
+ }
144
+ if (pkg.scripts['test'] == null) { pkg.scripts['test'] = 'bun test'; changes.push(`script 'test' added`) }
145
+ if (changes.length > 0) {
146
+ const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
147
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
148
+ }
149
+ const summary = [
150
+ changes.length > 0 ? changes.join(', ') : 'nothing mechanical to change',
151
+ ...notes,
152
+ ...(skipped.length > 0 ? [`left for a human (no canonical body): ${skipped.join(', ')}`] : []),
153
+ ].join(' · ')
154
+ return { changed: changes.length > 0, summary }
155
+ },
156
+ }
@@ -0,0 +1,19 @@
1
+ // SK002 — .rv-* contract fulfillness (meta/contracts.md). The rule is the
2
+ // registration point; the evaluation machinery stays in
3
+ // contracts/contracts-engine.ts (venue manifests are DATA rows, not rules —
4
+ // one SK id covers the invariant "declared contracts are fulfilled, folders
5
+ // can't rot undeclared"). Every surface reporting fulfillness (`check`,
6
+ // `shipkit contracts`, the ci `contracts` task) tags with this id via
7
+ // contracts/apply-contracts.ts.
8
+ import { activeContracts, applyContractsResult, contractsConfigFrom } from '../contracts/apply-contracts.ts'
9
+ import { evaluateContracts } from '../contracts/contracts-engine.ts'
10
+ import type { Rule } from './rule.ts'
11
+
12
+ export const contractsFulfillness: Rule = {
13
+ id: 'SK002',
14
+ title: 'contracts',
15
+ owner: 'meta/contracts.md',
16
+ lint: async (t, env) => {
17
+ applyContractsResult(t, await evaluateContracts(activeContracts(env.config), contractsConfigFrom(env.config, env.root)), contractsFulfillness)
18
+ },
19
+ }
@@ -0,0 +1,64 @@
1
+ // SK003 — the CLAUDE.md two-line contract (meta/agent-folder.md): CLAUDE.md
2
+ // is an include index, never a doc; `@./agent/index.md` comes LAST (include
3
+ // order is SEMANTIC — base context first, repo docs after so they
4
+ // override; a field bug moved the local include line and inverted it); a
5
+ // public repo's `@./CLAUDE.local.md` line-1 indirection must be gitignored.
6
+ // The lib stays generic: no personal include path is asserted — `shipkit
7
+ // start --include` flags own which base include a machine wires in. A repo
8
+ // with neither CLAUDE.md nor agent/ is not a two-line-contract repo and
9
+ // passes.
10
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
11
+ import { join } from 'path'
12
+ import { spawnWithTimeout } from '../core/spawn-timeout.ts'
13
+ import type { FixResult, Rule, RuleEnv } from './rule.ts'
14
+
15
+ const AGENT_LINE = '@./agent/index.md'
16
+ const LOCAL_LINE = '@./CLAUDE.local.md'
17
+
18
+ export const claudeMdShape: Rule = {
19
+ id: 'SK003',
20
+ title: 'CLAUDE.md',
21
+ owner: 'meta/agent-folder.md',
22
+ lint: async (t, env) => {
23
+ const claudePath = join(env.root, 'CLAUDE.md')
24
+ const hasAgent = existsSync(join(env.root, 'agent/index.md'))
25
+ if (!existsSync(claudePath)) {
26
+ if (hasAgent) t.fail(`agent/index.md exists but there is no CLAUDE.md routing it — the two-line contract wants ${AGENT_LINE}`)
27
+ else t.ok('no CLAUDE.md / agent — not a two-line-contract repo')
28
+ return
29
+ }
30
+ const lines = readFileSync(claudePath, 'utf8').split('\n').filter((l) => l.trim() !== '')
31
+ const includes = lines.filter((l) => l.startsWith('@'))
32
+ const prose = lines.length - includes.length
33
+ if (prose > 0)
34
+ t.fail(`CLAUDE.md carries ${prose} non-include line(s) — it is a tiny include index, content belongs in agent/index.md`)
35
+ if (includes.length > 2)
36
+ t.fail(`CLAUDE.md has ${includes.length} include lines — the contract is 2: base context, then ${AGENT_LINE}`)
37
+ if (hasAgent && !lines.includes(AGENT_LINE))
38
+ t.fail(`agent/index.md exists but CLAUDE.md does not import it — add ${AGENT_LINE} as the LAST line`)
39
+ if (lines.includes(AGENT_LINE) && lines.at(-1) !== AGENT_LINE)
40
+ t.fail(`include order inverted — base context first, ${AGENT_LINE} LAST (order is semantic: repo docs override the base)`)
41
+ if (lines.includes(LOCAL_LINE)) {
42
+ const r = await spawnWithTimeout(['git', 'check-ignore', '-q', 'CLAUDE.local.md'], { cwd: env.root, timeoutMs: 5_000 })
43
+ if (r.code === 1)
44
+ t.fail(`${LOCAL_LINE} is imported but CLAUDE.local.md is not gitignored — the machine-local include must never be tracked`)
45
+ else if (r.code !== 0)
46
+ t.note(`CLAUDE.local.md gitignore not verifiable (git says: ${r.out.trim().split('\n')[0] ?? `exit ${r.code}`})`)
47
+ }
48
+ if (!t.failed) t.ok(`CLAUDE.md include-index ok (${lines.length} line(s))`)
49
+ },
50
+ // the one mechanical repair: the missing agent/index.md route, appended
51
+ // to an EXISTING CLAUDE.md. Never creates the file — a created one-line
52
+ // CLAUDE.md would lint green with the base include forever missing,
53
+ // trading a loud FAIL for a permanent silent gap. Creation and prose
54
+ // relocation stay human/machine-local (`shipkit start --include`).
55
+ fix: (env: RuleEnv): FixResult => {
56
+ const claudePath = join(env.root, 'CLAUDE.md')
57
+ if (!existsSync(join(env.root, 'agent/index.md'))) return { changed: false, summary: 'no agent/index.md — nothing mechanical to fix' }
58
+ if (!existsSync(claudePath)) return { changed: false, summary: 'CLAUDE.md missing — creation needs the base include (shipkit start --include), not autofixable' }
59
+ const cur = readFileSync(claudePath, 'utf8')
60
+ if (cur.split('\n').includes(AGENT_LINE)) return { changed: false, summary: `${AGENT_LINE} already routed — remaining findings need a human` }
61
+ writeFileSync(claudePath, `${cur.endsWith('\n') || cur === '' ? cur : `${cur}\n`}${AGENT_LINE}\n`)
62
+ return { changed: true, summary: `${AGENT_LINE} appended as the last line` }
63
+ },
64
+ }
@@ -0,0 +1,105 @@
1
+ // SK004 — the .gitignore policy block (meta/contracts.md git policy; tmp/
2
+ // included because shipkit itself writes tmp/.shipkit-cache in every
3
+ // repo). A marker-keyed managed block so the always-gitignored contract
4
+ // surfaces can never silently rot per repo. Ecosystem-specific names the
5
+ // published lib may not carry ride as repo-contributed lines:
6
+ // `defineRepo({ gitignore: [...] })` renders them INSIDE the block, so the
7
+ // whole policy is machine-enforced. A well-formed block is rewritten IN
8
+ // PLACE — lines below it (e.g. a live venue's `!.rv-itch/` override, where
9
+ // last-match-wins) keep their position; everything outside the block is
10
+ // never touched. `shipkit init` writes the same block through this fixer.
11
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
12
+ import { join } from 'path'
13
+ import type { RepoConfig } from '../repo-config.ts'
14
+ import type { FixResult, Rule, RuleEnv } from './rule.ts'
15
+
16
+ const BEGIN = '# >>> shipkit gitignore policy (SK004) — managed block, edit outside only'
17
+ const END = '# <<< shipkit gitignore policy'
18
+ const CONTRIB = '# repo-contributed lines (repo.config.ts gitignore:)'
19
+
20
+ const POLICY_LINES = [
21
+ '.rv-reflections/',
22
+ '.rv-social/',
23
+ '.rv-journal/',
24
+ '.rv-domain/',
25
+ '.rv-assets/out/',
26
+ '# venue folders stay ignored until the listing is live — override below the block',
27
+ '.rv-itch/',
28
+ '.rv-steam/',
29
+ '.rv-discord/',
30
+ 'CLAUDE.local.md',
31
+ 'tmp/',
32
+ ]
33
+
34
+ export function expectedBlock(config: RepoConfig): string {
35
+ const extra = config.gitignore ?? []
36
+ return [BEGIN, ...POLICY_LINES, ...(extra.length > 0 ? [CONTRIB, ...extra] : []), END].join('\n')
37
+ }
38
+
39
+ export function policyPatterns(config: RepoConfig): string[] {
40
+ return [...POLICY_LINES.filter((l) => !l.startsWith('#')), ...(config.gitignore ?? [])]
41
+ }
42
+
43
+ type Parsed = { text: string; crlf: boolean; lines: string[]; b: number; e: number }
44
+
45
+ function parse(root: string): Parsed | null {
46
+ const p = join(root, '.gitignore')
47
+ if (!existsSync(p)) return null
48
+ const text = readFileSync(p, 'utf8')
49
+ const crlf = text.includes('\r\n')
50
+ const lines = text.split('\n').map((l) => l.replace(/\r$/, ''))
51
+ return { text, crlf, lines, b: lines.indexOf(BEGIN), e: lines.indexOf(END) }
52
+ }
53
+
54
+ type BlockState = 'missing-file' | 'missing-block' | 'corrupted' | 'stale' | 'current'
55
+
56
+ function blockState(root: string, config: RepoConfig): BlockState {
57
+ const f = parse(root)
58
+ if (f == null) return 'missing-file'
59
+ if (f.b === -1 && f.e === -1) return 'missing-block'
60
+ if (f.b === -1 || f.e === -1 || f.e < f.b) return 'corrupted'
61
+ return f.lines.slice(f.b, f.e + 1).join('\n') === expectedBlock(config) ? 'current' : 'stale'
62
+ }
63
+
64
+ function writeBlock(root: string, config: RuleEnv['config']): void {
65
+ const p = join(root, '.gitignore')
66
+ const f = parse(root)
67
+ const block = expectedBlock(config).split('\n')
68
+ let out: string[]
69
+ if (f == null) out = block
70
+ else if (f.b !== -1 && f.e !== -1 && f.e >= f.b)
71
+ // well-formed block: rewrite IN PLACE so below-block overrides
72
+ // (last-match-wins) keep working
73
+ out = [...f.lines.slice(0, f.b), ...block, ...f.lines.slice(f.e + 1)]
74
+ else {
75
+ // absent or corrupted: drop stray markers, keep every repo line
76
+ // visible, append the canonical block
77
+ const kept = (f.b === -1 && f.e === -1 ? f.lines : f.lines.filter((l) => l !== BEGIN && l !== END))
78
+ while (kept.length > 0 && kept.at(-1)!.trim() === '') kept.pop()
79
+ out = kept.length > 0 ? [...kept, '', ...block] : block
80
+ }
81
+ const eol = f?.crlf === true ? '\r\n' : '\n'
82
+ writeFileSync(p, `${out.join(eol)}${out.at(-1) === '' ? '' : eol}`)
83
+ }
84
+
85
+ export const gitignorePolicy: Rule = {
86
+ id: 'SK004',
87
+ title: '.gitignore',
88
+ owner: 'meta/contracts.md',
89
+ lint: (t, env) => {
90
+ const s = blockState(env.root, env.config)
91
+ switch (s) {
92
+ case 'missing-file': return t.fail('.gitignore missing — the contract git policy is unenforced')
93
+ case 'missing-block': return t.fail('.gitignore has no shipkit policy block — contract surfaces can leak into git')
94
+ case 'corrupted': return t.fail('.gitignore policy block corrupted (unpaired marker)')
95
+ case 'stale': return t.fail('.gitignore policy block is stale — the policy or repo-contributed lines moved since it was written')
96
+ case 'current': return t.ok(`.gitignore policy block current (${policyPatterns(env.config).length} patterns)`)
97
+ }
98
+ },
99
+ fix: (env: RuleEnv): FixResult => {
100
+ const before = blockState(env.root, env.config)
101
+ if (before === 'current') return { changed: false, summary: 'policy block already current' }
102
+ writeBlock(env.root, env.config)
103
+ return { changed: true, summary: before === 'missing-file' ? '.gitignore created with the policy block' : before === 'missing-block' ? 'policy block appended (repo lines untouched)' : before === 'corrupted' ? 'policy block rebuilt (stray markers dropped, repo lines kept visible)' : 'policy block rewritten in place (repo lines untouched)' }
104
+ },
105
+ }
@@ -0,0 +1,99 @@
1
+ // SK005 — rvlib-* dependency freshness, and the self-update that keeps
2
+ // every repo converging on the current toolkit (a stale repo silently
3
+ // missing months of rules is the failure; machine-clearable drift clears
4
+ // ITSELF). Per rvlib-* dep:
5
+ // link: → the symlink must resolve (a broken link dies cryptically at
6
+ // import time otherwise) — nothing to update, code is live
7
+ // semver → installed vs npm latest; NEWER LIVE → this lint runs
8
+ // `bun update --latest` itself, loudly, guarded:
9
+ // - SHIPKIT_FROZEN=1 (`check --frozen`) → WARN, never touch
10
+ // - package.json/lockfile git-dirty (or no git) → WARN deferred,
11
+ // a tool must not mix its writes into human-staged work
12
+ // registry unreachable → loud skip, never red (offline is not a defect)
13
+ // Committing the bump stays with the working agent — shipkit never commits.
14
+ // Clean passes cache 24h (rule ttl), so the registry is fetched at most
15
+ // daily per repo; skip/WARN/FAIL runs are never cached. peerDependencies
16
+ // are NOT scanned: keeping a published lib's peer ranges consistent after
17
+ // a bump stays the agent's job.
18
+ import { existsSync, readFileSync } from 'fs'
19
+ import { join } from 'path'
20
+ import type { CheckCtx } from '../core/check-ctx.ts'
21
+ import type { Rule, RuleEnv } from './rule.ts'
22
+
23
+ type Pkg = { version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
24
+
25
+ const installedVersion = (root: string, name: string): string | null => {
26
+ const p = join(root, 'node_modules', name, 'package.json')
27
+ if (!existsSync(p)) return null
28
+ try { return (JSON.parse(readFileSync(p, 'utf8')) as Pkg).version ?? null } catch { return null }
29
+ }
30
+
31
+ async function latestVersion(t: CheckCtx, name: string): Promise<string> {
32
+ const res = await t.fetch(`https://registry.npmjs.org/${name}/latest`)
33
+ if (!res.ok) throw new Error(`registry ${res.status}`)
34
+ const v = ((await res.json()) as Pkg).version
35
+ if (v == null) throw new Error('registry payload has no version')
36
+ return v
37
+ }
38
+
39
+ export const rvlibVersions: Rule = {
40
+ id: 'SK005',
41
+ title: 'rvlib versions',
42
+ owner: 'build/shipkit.md',
43
+ ttl: '24h',
44
+ lint: async (t, env: RuleEnv) => {
45
+ let pkg: Pkg
46
+ try { pkg = JSON.parse(readFileSync(join(env.root, 'package.json'), 'utf8')) as Pkg } catch { return t.note('no readable package.json — rvlib versions not applicable') }
47
+ const deps = Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }).filter(([n]) => n.startsWith('rvlib-'))
48
+ if (deps.length === 0) return t.ok('no rvlib-* deps')
49
+
50
+ const stale: Array<{ name: string; installed: string; latest: string }> = []
51
+ for (const [name, spec] of deps) {
52
+ if (spec.startsWith('link:')) {
53
+ if (installedVersion(env.root, name) == null) t.fail(`${name} is link: but the symlink does not resolve — re-run \`bun link ${name}\` (a broken link dies cryptically at import time)`)
54
+ else t.ok(`${name} (linked, live)`)
55
+ continue
56
+ }
57
+ const installed = installedVersion(env.root, name)
58
+ if (installed == null) { t.fail(`${name} declared (${spec}) but not installed — run: bun install`); continue }
59
+ let latest: string
60
+ try { latest = await latestVersion(t, name) } catch (e) {
61
+ t.lines.push({ kind: 'skip', text: `${name} ${installed} — registry unreachable (${String(e).slice(0, 80)}), version drift unknown` })
62
+ continue
63
+ }
64
+ // real semver order, not string difference — a dist-tag pointing
65
+ // BEHIND the installed version (prerelease, unpublish window)
66
+ // must never trigger a silent downgrade
67
+ const cmp = Bun.semver.order(latest, installed)
68
+ if (cmp === 0) t.ok(`${name} ${installed} (latest)`)
69
+ else if (cmp < 0) t.ok(`${name} ${installed} ahead of npm latest ${latest} (prerelease or dist-tag lag) — not touching`)
70
+ else stale.push({ name, installed, latest })
71
+ }
72
+ if (stale.length === 0) return
73
+
74
+ const drift = stale.map((s) => `${s.name} ${s.installed} → ${s.latest}`).join(', ')
75
+ if (process.env['SHIPKIT_FROZEN'] === '1') {
76
+ t.warn(`newer rvlib live: ${drift} (frozen — apply with: bun update --latest ${stale.map((s) => s.name).join(' ')})`)
77
+ return
78
+ }
79
+ const dirty = await t.exec(['git', 'status', '--porcelain', '--', 'package.json', 'bun.lock', 'bun.lockb'], { cwd: env.root })
80
+ if (dirty.code !== 0 || dirty.out.trim() !== '') {
81
+ t.warn(`newer rvlib live: ${drift} — self-update deferred (${dirty.code !== 0 ? `git says: ${dirty.out.trim().split('\n')[0] ?? `exit ${dirty.code}`}` : 'package.json/lockfile dirty'}); commit first, then re-run check`)
82
+ return
83
+ }
84
+ const names = stale.map((s) => s.name)
85
+ const up = await t.exec(['bun', 'update', '--latest', ...names], { cwd: env.root, timeoutMs: 120_000 })
86
+ if (up.code !== 0) {
87
+ t.fail(`self-update failed (bun update --latest ${names.join(' ')} exited ${up.code}${up.timedOut ? ', timed out' : ''}) — package.json/lockfile may be partially written, \`git status\` first: ${up.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}`)
88
+ return
89
+ }
90
+ for (const s of stale) {
91
+ const now = installedVersion(env.root, s.name)
92
+ // WARN on purpose: the run MUTATED the repo and a follow-up
93
+ // (restart watchers, commit) is owed — an ok here would be a
94
+ // green, cacheable line replaying a past event as current state
95
+ if (now === s.latest) t.warn(`${s.name} ${s.installed} → ${now} self-updated — restart watchers, commit the bump`)
96
+ else t.warn(`${s.name} updated but reads ${now ?? 'missing'} (wanted ${s.latest}) — check the lockfile`)
97
+ }
98
+ },
99
+ }
@@ -0,0 +1,42 @@
1
+ // SK006 — npm publish safety (meta/rv-conventions/package-json.md).
2
+ // `private: true` is npm's own unconditional publish refusal — THE lock
3
+ // (SK001 enforces its presence). This rule closes the contradiction class
4
+ // around it: a private package carrying publish machinery is either a
5
+ // leftover (dead weight that confuses agents) or a loaded gun for the day
6
+ // someone flips `private` — both fail loud. rvlib-* roots publish by
7
+ // design and are exempt. Lint-only: deleting publish machinery is a
8
+ // judgment call (a `publish` script may publish a SITE, not npm).
9
+ // SCOPE, stated: the checked surface is publishConfig + prepublish*/
10
+ // postpublish + a script literally NAMED `publish` running npm/bun
11
+ // publish. Machinery reachable under OTHER names (release scripts,
12
+ // np/release-it deps, a control room publishing its own sub-libs) is
13
+ // deliberately out of scope — `private: true` on the root already refuses,
14
+ // and sanctioned sub-lib publishing must not turn this rule red.
15
+ import { existsSync, readFileSync } from 'fs'
16
+ import { join } from 'path'
17
+ import type { Rule } from './rule.ts'
18
+
19
+ type Pkg = { name?: string; private?: boolean; publishConfig?: unknown; scripts?: Record<string, string> }
20
+
21
+ export const npmPublishSafety: Rule = {
22
+ id: 'SK006',
23
+ title: 'npm publish safety',
24
+ owner: 'meta/rv-conventions/package-json.md',
25
+ lint: (t, env) => {
26
+ const p = join(env.root, 'package.json')
27
+ if (!existsSync(p)) return t.ok('no package.json — nothing publishable')
28
+ let pkg: Pkg
29
+ try { pkg = JSON.parse(readFileSync(p, 'utf8')) as Pkg } catch { return t.note('package.json unreadable — SK001 reports it') }
30
+ if (pkg.name?.startsWith('rvlib-') === true) return t.ok('rvlib-* package — publishes by design (distribute/npm owns it)')
31
+ if (pkg.private !== true) return t.note('private:true missing — SK001 reports it; publish machinery not judged until the lock is on')
32
+ const machinery: string[] = []
33
+ if (pkg.publishConfig != null) machinery.push('publishConfig')
34
+ for (const s of ['prepublishOnly', 'prepublish', 'postpublish'])
35
+ if (pkg.scripts?.[s] != null) machinery.push(`scripts.${s}`)
36
+ if (pkg.scripts?.['publish'] != null && /npm publish|bun publish/.test(pkg.scripts['publish']))
37
+ machinery.push(`scripts.publish (runs npm/bun publish)`)
38
+ if (machinery.length > 0)
39
+ t.fail(`private package carries publish machinery: ${machinery.join(', ')} — dead weight now, a loaded gun the day private flips; delete it or make the repo a published lib on purpose`)
40
+ else t.ok(`private package — no publishConfig / prepublish* / npm-publishing 'publish' script on the root`)
41
+ },
42
+ }
@@ -0,0 +1,63 @@
1
+ // SK007 — github visibility tripwire (meta/contracts.md pre-public gate).
2
+ // `repo.config.ts visibility: 'private' | 'public'` (absent = 'private')
3
+ // states the INTENT; gh states the FACT; a mismatch in either direction is
4
+ // red — public-while-declared-private is the leak case,
5
+ // private-while-declared-public is a stale config lying to agents. The
6
+ // TARGET repo is resolved explicitly from the remote url (preferring
7
+ // `github`, then `origin`, then the first remote) and passed to gh — gh's
8
+ // own base-repo resolution prefers `upstream`, which in a fork-shaped
9
+ // checkout would confidently validate the WRONG repository. Flipping a
10
+ // repo public stays a human act behind the pre-public audit; this rule is
11
+ // the standing tripwire if it ever happens out of band. Clean passes cache
12
+ // 7d, keyed on the declared value (a flip re-probes immediately); every
13
+ // unverifiable path (no git, no remote, non-github remote, gh missing or
14
+ // erroring, unreadable answer) is a loud skip — an UNKNOWN, never red,
15
+ // never cached.
16
+ import type { Rule } from './rule.ts'
17
+
18
+ export const githubVisibility: Rule = {
19
+ id: 'SK007',
20
+ title: 'github visibility',
21
+ owner: 'meta/contracts.md',
22
+ ttl: '7d',
23
+ cacheKey: (env) => env.config.visibility ?? 'private',
24
+ lint: async (t, env) => {
25
+ const declared = env.config.visibility ?? 'private'
26
+ const skip = (why: string): void => void t.lines.push({ kind: 'skip', text: `github visibility unverified — ${why} (declared: ${declared})` })
27
+ const firstLine = (s: string): string => s.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 120) ?? ''
28
+
29
+ const remotes = await t.exec(['git', 'remote'], { cwd: env.root })
30
+ if (remotes.code !== 0) return skip(`git says: ${firstLine(remotes.out) || `exit ${remotes.code}`}`)
31
+ const names = remotes.out.trim().split('\n').filter((l) => l !== '')
32
+ if (names.length === 0) return skip('no git remote')
33
+ // his repos name it `github`, not `origin` (field catch 2026-07-26)
34
+ const pick = ['github', 'origin'].find((n) => names.includes(n)) ?? names[0]!
35
+ const url = await t.exec(['git', 'remote', 'get-url', pick], { cwd: env.root })
36
+ if (url.code !== 0) return skip(`git says: ${firstLine(url.out)}`)
37
+ const m = url.out.match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\s*$/m)
38
+ if (m == null) return skip(`remote '${pick}' is not github (${firstLine(url.out)})`)
39
+ const target = m[1]!
40
+
41
+ const r = await t.exec(['gh', 'repo', 'view', target, '--json', 'isPrivate,nameWithOwner'], { cwd: env.root, timeoutMs: 30_000 })
42
+ if (r.code !== 0) return skip(`gh failed (${firstLine(r.out)})`)
43
+ // gh mixes stderr chatter (release notices, SSO warnings) into the
44
+ // captured output — carve the JSON object out before parsing, and an
45
+ // unreadable answer stays an UNKNOWN, never a FAIL
46
+ const start = r.out.indexOf('{')
47
+ const end = r.out.lastIndexOf('}')
48
+ let parsed: { isPrivate?: boolean; nameWithOwner?: string }
49
+ try {
50
+ if (start === -1 || end < start) throw new Error('no JSON object in output')
51
+ parsed = JSON.parse(r.out.slice(start, end + 1)) as typeof parsed
52
+ } catch {
53
+ return skip(`gh answer unreadable (${firstLine(r.out)})`)
54
+ }
55
+ const actual = parsed.isPrivate === true ? 'private' : 'public'
56
+ const who = parsed.nameWithOwner ?? target
57
+ if (actual === declared) t.ok(`github ${who} is ${actual} — matches repo.config visibility`)
58
+ else if (actual === 'public')
59
+ t.fail(`github ${who} is PUBLIC but repo.config declares '${declared}' — the leak case; make it private again or run the pre-public gate and declare visibility: 'public'`)
60
+ else
61
+ t.fail(`github ${who} is private but repo.config declares 'public' — stale config lying to agents; fix the declaration`)
62
+ },
63
+ }
@@ -0,0 +1,32 @@
1
+ // THE rule registry — single source of every SK rule. `shipkit check` runs
2
+ // them, `shipkit rules` lists them, fix pointers and wiki pages name their
3
+ // ids. A duplicate or malformed id throws at import time: every surface
4
+ // that loads rules dies loud rather than run with an ambiguous registry.
5
+ import type { Rule } from './rule.ts'
6
+ import { packageJsonShape } from './SK001-package-json-shape.ts'
7
+ import { contractsFulfillness } from './SK002-contracts-fulfillness.ts'
8
+ import { claudeMdShape } from './SK003-claude-md-shape.ts'
9
+ import { gitignorePolicy } from './SK004-gitignore-policy.ts'
10
+ import { rvlibVersions } from './SK005-rvlib-versions.ts'
11
+ import { npmPublishSafety } from './SK006-npm-publish-safety.ts'
12
+ import { githubVisibility } from './SK007-github-visibility.ts'
13
+
14
+ export const RULES: readonly Rule[] = [
15
+ packageJsonShape,
16
+ contractsFulfillness,
17
+ claudeMdShape,
18
+ gitignorePolicy,
19
+ rvlibVersions,
20
+ npmPublishSafety,
21
+ githubVisibility,
22
+ ]
23
+
24
+ const seen = new Set<string>()
25
+ for (const r of RULES) {
26
+ if (!/^SK\d{3}$/.test(r.id)) throw new Error(`rule-registry: bad id '${r.id}' — rules are SK### (3 digits, never reused)`)
27
+ if (seen.has(r.id)) throw new Error(`rule-registry: duplicate id ${r.id}`)
28
+ if (r.owner === '') throw new Error(`rule-registry: ${r.id} has no owner page`)
29
+ seen.add(r.id)
30
+ }
31
+
32
+ export const ruleById = (id: string): Rule | undefined => RULES.find((r) => r.id === id)
@@ -0,0 +1,62 @@
1
+ // Rule — one machine-enforced invariant with a stable id. One module per
2
+ // rule (`src/rules/SK###-<slug>.ts`), registered in rule-registry.ts; ids
3
+ // are never reused or renumbered. `shipkit check` runs every rule's lint;
4
+ // a rule may also carry an idempotent fix() (the `check --fix` runner).
5
+ // Every wire line a rule emits is tagged `[SK###]` so output is greppable
6
+ // by rule and a fix pointer names the rule that can repair it.
7
+ import type { CheckCtx } from '../core/check-ctx.ts'
8
+ import type { Line } from '../core/wire.ts'
9
+ import type { RepoConfig } from '../repo-config.ts'
10
+
11
+ export type RuleEnv = { root: string; config: RepoConfig }
12
+
13
+ export type FixResult = { changed: boolean; summary: string }
14
+
15
+ export type Rule = {
16
+ /** stable `SK` + 3 digits — never reused, never renumbered (registry throws) */
17
+ id: string
18
+ /** check-suite display name — stable: the check name in STATUS.md, the TUI row, and the suite cache */
19
+ title: string
20
+ /** wiki page owning the invariant — the run-check framework appends `docsBase + owner`
21
+ * as the fix: pointer whenever a failing/warning lint attached none */
22
+ owner: string
23
+ /** clean-pass cache duration (check-suite TTL, e.g. '24h') — for rules that
24
+ * probe the network; skip/WARN/FAIL runs are never cached */
25
+ ttl?: string
26
+ /** cache-identity contribution for TTL'd rules whose verdict depends on
27
+ * config — a changed key busts the cache instantly instead of replaying
28
+ * a stale assertion for the TTL */
29
+ cacheKey?: (env: RuleEnv) => string
30
+ lint: (t: CheckCtx, env: RuleEnv) => void | Promise<void>
31
+ /** idempotent auto-repair; `check --fix` applies it then re-lints — never interactive */
32
+ fix?: (env: RuleEnv) => FixResult | Promise<FixResult>
33
+ }
34
+
35
+ // THE one way to run a rule's lint (check suite + the --fix runner): a
36
+ // throwing lint still fails with its id, a failing/warning run always
37
+ // carries at least one fix: pointer (the lint's own precise ones win, else
38
+ // docsBase + the rule's owner page), and every line gets tagged.
39
+ export async function runRuleLint(rule: Rule, t: CheckCtx, env: RuleEnv): Promise<void> {
40
+ try {
41
+ await rule.lint(t, env)
42
+ } catch (e) {
43
+ t.fail(`${rule.title} lint errored (${String(e).slice(0, 200)}) — network down, or a bug in this rule`)
44
+ }
45
+ if ((t.failed || t.warned) && !t.lines.some((l) => l.kind === 'fix-fail' || l.kind === 'fix-warn'))
46
+ t.lines.push({ kind: t.failed ? 'fix-fail' : 'fix-warn', text: `${env.config.docsBase ?? ''}${rule.owner}` })
47
+ t.lines = tagRuleLines(rule, t.lines)
48
+ }
49
+
50
+ // tag every severity line with the rule id; fix pointers of a fixable rule
51
+ // advertise the autofix. Idempotent — already-tagged lines pass through
52
+ // (cached suite replays re-enter here).
53
+ export function tagRuleLines(rule: Rule, lines: Line[]): Line[] {
54
+ return lines.map((l) => {
55
+ if (l.kind === 'fix-warn' || l.kind === 'fix-fail')
56
+ return rule.fix == null || l.text.includes('--fix ')
57
+ ? l
58
+ : { ...l, text: `${l.text} · autofix: shipkit check --fix ${rule.id}` }
59
+ if (l.kind === 'note') return l
60
+ return l.text.startsWith(`[${rule.id}]`) ? l : { ...l, text: `[${rule.id}] ${l.text}` }
61
+ })
62
+ }