rvlib-shipkit 0.11.0 → 0.14.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.
- package/CHANGELOG.md +200 -0
- package/README.md +17 -3
- package/package.json +6 -3
- package/src/asks/asks-queue.ts +8 -0
- package/src/assets/kit-config.ts +2 -3
- package/src/assets/roll-workspace.ts +2 -1
- package/src/assets/run-assets.ts +0 -11
- package/src/assets/server/studio-server.ts +7 -24
- package/src/cli/cmd_Assets.ts +3 -14
- package/src/cli/cmd_Check.ts +22 -4
- package/src/cli/cmd_Reflect.ts +116 -0
- package/src/cli/cmd_Rules.ts +32 -0
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli.ts +5 -2
- package/src/collections/actions.ts +19 -0
- package/src/collections/collection-types.ts +13 -0
- package/src/collections/graphs.ts +34 -0
- package/src/collections/pings.ts +5 -1
- package/src/collections/posts.ts +20 -1
- package/src/collections/providers.ts +163 -50
- package/src/contracts/apply-contracts.ts +47 -0
- package/src/contracts/contract-types.ts +5 -2
- package/src/contracts/contracts-engine.ts +13 -3
- package/src/contracts/manifests/discord.contract.ts +1 -1
- package/src/contracts/manifests/identity.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +19 -2
- package/src/contracts/run-contracts.ts +8 -3
- package/src/core/canonical-scripts.ts +46 -0
- package/src/core/check-suite.ts +6 -3
- package/src/core/locked-write.ts +39 -0
- package/src/core/motherhome.ts +87 -0
- package/src/core/run-check.ts +28 -15
- package/src/core/run-ci.ts +4 -0
- package/src/core/write-status.ts +7 -26
- package/src/init-repo.ts +27 -11
- package/src/reflect/prompt-corpus.ts +305 -0
- package/src/repo-config.ts +30 -38
- package/src/rules/SK001-package-json-shape.ts +156 -0
- package/src/rules/SK002-contracts-fulfillness.ts +19 -0
- package/src/rules/SK003-claude-md-shape.ts +64 -0
- package/src/rules/SK004-gitignore-policy.ts +105 -0
- package/src/rules/SK005-rvlib-versions.ts +99 -0
- package/src/rules/SK006-npm-publish-safety.ts +42 -0
- package/src/rules/SK007-github-visibility.ts +63 -0
- package/src/rules/SK008-reviewer-agent.ts +98 -0
- package/src/rules/rule-registry.ts +34 -0
- package/src/rules/rule.ts +62 -0
- package/src/rules/run-fix.ts +57 -0
- package/src/serve/hub-app/col-card.tsx +192 -0
- package/src/serve/hub-app/cols-svc.ts +136 -0
- package/src/serve/hub-app/geometry-view.tsx +155 -0
- package/src/serve/hub-app/health-svc.ts +49 -0
- package/src/serve/hub-app/hub-app.tsx +236 -215
- package/src/serve/hub-app/hub-st.ts +169 -0
- package/src/serve/hub-app/hub-types.ts +72 -0
- package/src/serve/hub-app/hub.css +286 -59
- package/src/serve/hub-app/index.html +1 -1
- package/src/serve/hub-app/md.ts +24 -0
- package/src/serve/hub-app/media-svc.ts +429 -0
- package/src/serve/hub-app/media-view.tsx +299 -0
- package/src/serve/run-serve.ts +23 -9
- package/src/serve/serve-hub.ts +62 -47
- package/src/shipkit.ts +9 -5
- package/src/assets/server/studio-page.ts +0 -687
- package/src/core/package-json-check.ts +0 -88
- package/src/serve/hub-pages.ts +0 -38
|
@@ -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,98 @@
|
|
|
1
|
+
// SK008 — the mandated reviewer agent type. A repo that declares
|
|
2
|
+
// `reviewer: { name, brief }` must carry `.claude/agents/<name>.md`: an agent
|
|
3
|
+
// definition whose system prompt POINTS at the brief file (ONE home for the
|
|
4
|
+
// text, so review framing can never drift per repo) and whose `tools:` line
|
|
5
|
+
// stays inside a read-only ALLOWLIST — a reviewer's product is findings, never
|
|
6
|
+
// fixes, and a denylist of today's edit-tool names would wave through anything
|
|
7
|
+
// that edits by proxy. An absent `tools:` line is the worst case of all: the
|
|
8
|
+
// harness then inherits every tool. The brief itself must exist. No
|
|
9
|
+
// declaration = not a reviewer repo, and the rule passes silently.
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
11
|
+
import { homedir } from 'os'
|
|
12
|
+
import { dirname, isAbsolute, join } from 'path'
|
|
13
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
14
|
+
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
15
|
+
|
|
16
|
+
/** default read-only toolset — an ALLOWLIST, not a denylist: a tool that can
|
|
17
|
+
* edit by proxy (spawning a subagent, an MCP writer) is exactly what a
|
|
18
|
+
* denylist of today's edit-tool names would wave through */
|
|
19
|
+
export const REVIEWER_TOOLS = ['Read', 'Grep', 'Glob', 'Bash']
|
|
20
|
+
|
|
21
|
+
export const reviewerAgentPath = (name: string): string => `.claude/agents/${name}.md`
|
|
22
|
+
|
|
23
|
+
// the invariants, as a pure function over the file text — the rule below and
|
|
24
|
+
// any repo-local linter call THIS, so there is one definition of "a valid
|
|
25
|
+
// reviewer agent file" and not one per caller
|
|
26
|
+
export function reviewerAgentIssues(text: string, reviewer: NonNullable<RepoConfig['reviewer']>): string[] {
|
|
27
|
+
const issues: string[] = []
|
|
28
|
+
const allowed = reviewer.tools ?? REVIEWER_TOOLS
|
|
29
|
+
const declared = text.match(/^name:\s*(\S+)\s*$/m)?.[1]
|
|
30
|
+
if (declared !== reviewer.name)
|
|
31
|
+
issues.push(`frontmatter name is ${declared == null ? 'missing' : `'${declared}'`} — it must be '${reviewer.name}', the type spawners ask for`)
|
|
32
|
+
if (!text.includes(reviewer.brief))
|
|
33
|
+
issues.push(`never points at the brief '${reviewer.brief}' — the agent's whole job is to load it before judging anything`)
|
|
34
|
+
const tools = text.match(/^tools:\s*(.+)$/m)?.[1]
|
|
35
|
+
if (tools == null) issues.push(`has no inline 'tools:' allowlist — omitting it inherits every tool, edit tools included`)
|
|
36
|
+
else
|
|
37
|
+
for (const t of tools.split(',').map((x) => x.trim()).filter(Boolean))
|
|
38
|
+
if (!allowed.includes(t)) issues.push(`grants '${t}', outside the allowlist (${allowed.join(', ')}) — a reviewer reports findings, it never edits, directly or by proxy`)
|
|
39
|
+
return issues
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** the brief must EXIST: a reviewer whose mandated first action is reading a
|
|
43
|
+
* missing file flounders silently, which is the one thing this rule prevents */
|
|
44
|
+
export function briefMissing(root: string, brief: string): boolean {
|
|
45
|
+
const p = brief.startsWith('~/') ? join(homedir(), brief.slice(2)) : isAbsolute(brief) ? brief : join(root, brief)
|
|
46
|
+
return !existsSync(p)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function canonicalAgent(reviewer: NonNullable<RepoConfig['reviewer']>): string {
|
|
50
|
+
return `---
|
|
51
|
+
name: ${reviewer.name}
|
|
52
|
+
description: The mandated critical reviewer for this repo. Use before every commit, and whenever a diff needs judging against what was actually asked for. Judges whether a change should exist at all, not just whether it is correct.
|
|
53
|
+
tools: ${(reviewer.tools ?? REVIEWER_TOOLS).join(', ')}
|
|
54
|
+
model: opus
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
You are this repo's critical reviewer. You have no Edit or Write tool on
|
|
58
|
+
purpose: your product is findings, never fixes. Bash is for READING the state
|
|
59
|
+
of things (\`git diff\`, the gates, running a claim to check it) — never for
|
|
60
|
+
writing files by another route.
|
|
61
|
+
|
|
62
|
+
FIRST ACTION, always: read \`${reviewer.brief}\` and follow it exactly. It tells
|
|
63
|
+
you what else to read, when to refuse the job, the order of the questions, and
|
|
64
|
+
the output shape.
|
|
65
|
+
|
|
66
|
+
That brief overrides any habit of reviewing a diff for correctness alone.
|
|
67
|
+
`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const reviewerAgent: Rule = {
|
|
71
|
+
id: 'SK008',
|
|
72
|
+
title: 'reviewer agent',
|
|
73
|
+
owner: 'identity/how-we-work.md',
|
|
74
|
+
lint: (t, env) => {
|
|
75
|
+
const r = env.config.reviewer
|
|
76
|
+
if (r == null) return t.ok('no reviewer agent declared (repo.config reviewer:) — skipped')
|
|
77
|
+
const rel = reviewerAgentPath(r.name)
|
|
78
|
+
const p = join(env.root, rel)
|
|
79
|
+
if (briefMissing(env.root, r.brief)) t.fail(`the declared brief '${r.brief}' does not exist — every review's mandated first action would fail`)
|
|
80
|
+
if (!existsSync(p)) return t.fail(`${rel} missing — the mandated reviewer type is unavailable, so every review runs on whatever framing the spawner improvised`)
|
|
81
|
+
const issues = reviewerAgentIssues(readFileSync(p, 'utf8'), r)
|
|
82
|
+
for (const i of issues) t.fail(`${rel}: ${i}`)
|
|
83
|
+
if (issues.length === 0 && !t.failed) t.ok(`${rel} → ${r.brief}`)
|
|
84
|
+
},
|
|
85
|
+
// create-if-missing only: an existing definition may carry a repo's own
|
|
86
|
+
// tuned prompt, and silently rewriting that would cost more than the
|
|
87
|
+
// finding it repairs. Violations in a present file stay a loud FAIL.
|
|
88
|
+
fix: (env: RuleEnv): FixResult => {
|
|
89
|
+
const r = env.config.reviewer
|
|
90
|
+
if (r == null) return { changed: false, summary: 'no reviewer declared — nothing to write' }
|
|
91
|
+
const rel = reviewerAgentPath(r.name)
|
|
92
|
+
const p = join(env.root, rel)
|
|
93
|
+
if (existsSync(p)) return { changed: false, summary: `${rel} exists — remaining findings are about its content, fix them by hand` }
|
|
94
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
95
|
+
writeFileSync(p, canonicalAgent(r))
|
|
96
|
+
return { changed: true, summary: `${rel} written — points at ${r.brief}, no edit tools` }
|
|
97
|
+
},
|
|
98
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
import { reviewerAgent } from './SK008-reviewer-agent.ts'
|
|
14
|
+
|
|
15
|
+
export const RULES: readonly Rule[] = [
|
|
16
|
+
packageJsonShape,
|
|
17
|
+
contractsFulfillness,
|
|
18
|
+
claudeMdShape,
|
|
19
|
+
gitignorePolicy,
|
|
20
|
+
rvlibVersions,
|
|
21
|
+
npmPublishSafety,
|
|
22
|
+
githubVisibility,
|
|
23
|
+
reviewerAgent,
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const seen = new Set<string>()
|
|
27
|
+
for (const r of RULES) {
|
|
28
|
+
if (!/^SK\d{3}$/.test(r.id)) throw new Error(`rule-registry: bad id '${r.id}' — rules are SK### (3 digits, never reused)`)
|
|
29
|
+
if (seen.has(r.id)) throw new Error(`rule-registry: duplicate id ${r.id}`)
|
|
30
|
+
if (r.owner === '') throw new Error(`rule-registry: ${r.id} has no owner page`)
|
|
31
|
+
seen.add(r.id)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// `shipkit check --fix [SK### …]` — apply rule autofixes, then re-lint and
|
|
2
|
+
// show the real end state. Never runs under ci (the gate stays pure), never
|
|
3
|
+
// caches (a fixer must see reality). No ids = every fixable rule; a
|
|
4
|
+
// requested id that is unknown or unfixable fails loud instead of silently
|
|
5
|
+
// narrowing.
|
|
6
|
+
import { CheckCtx } from '../core/check-ctx.ts'
|
|
7
|
+
import { formatLine } from '../core/wire.ts'
|
|
8
|
+
import { loadRepoConfig } from '../repo-config.ts'
|
|
9
|
+
import { RULES, ruleById } from './rule-registry.ts'
|
|
10
|
+
import { runRuleLint, type RuleEnv } from './rule.ts'
|
|
11
|
+
|
|
12
|
+
export type FixOpts = {
|
|
13
|
+
ids?: string[]
|
|
14
|
+
print?: (line: string) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
18
|
+
const print = o.print ?? ((l: string) => console.log(l))
|
|
19
|
+
const config = await loadRepoConfig(root)
|
|
20
|
+
const env: RuleEnv = { root, config }
|
|
21
|
+
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
22
|
+
|
|
23
|
+
let targets = RULES.filter((r) => r.fix != null)
|
|
24
|
+
if (o.ids != null && o.ids.length > 0) {
|
|
25
|
+
for (const id of o.ids) {
|
|
26
|
+
const r = ruleById(id)
|
|
27
|
+
if (r == null) { print(`FAIL: unknown rule '${id}' — see: shipkit rules`); return 1 }
|
|
28
|
+
if (r.fix == null) { print(`FAIL: [${id}] has no autofix — fix by hand: ${config.docsBase ?? ''}${r.owner}`); return 1 }
|
|
29
|
+
}
|
|
30
|
+
const wanted = new Set(o.ids)
|
|
31
|
+
targets = targets.filter((r) => wanted.has(r.id))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let exit = 0
|
|
35
|
+
for (const rule of targets) {
|
|
36
|
+
const before = new CheckCtx(new Map())
|
|
37
|
+
await runRuleLint(rule, before, env)
|
|
38
|
+
if (!before.failed && !before.warned) {
|
|
39
|
+
print(`ok: [${rule.id}] already clean — nothing to fix`)
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
const f = await rule.fix!(env)
|
|
43
|
+
// an unchanged fix is the DESIGNED outcome for judgment findings —
|
|
44
|
+
// a note, not a warning
|
|
45
|
+
print(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `[${rule.id}] fix changed nothing — ${f.summary}`)
|
|
46
|
+
const after = new CheckCtx(new Map())
|
|
47
|
+
await runRuleLint(rule, after, env)
|
|
48
|
+
// the fix already ran: advertising it again on surviving findings
|
|
49
|
+
// would send an agent in a loop
|
|
50
|
+
for (const l of after.lines) print(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix SK\d{3}$/, '') }, color))
|
|
51
|
+
if (after.failed) exit = 1
|
|
52
|
+
}
|
|
53
|
+
const unfixable = RULES.filter((r) => r.fix == null).length
|
|
54
|
+
if (unfixable > 0 && (o.ids == null || o.ids.length === 0))
|
|
55
|
+
print(`${unfixable} rule(s) have no autofix — run \`shipkit check\` for the full repo state`)
|
|
56
|
+
return exit
|
|
57
|
+
}
|