rvlib-shipkit 0.14.0 → 0.17.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 +141 -0
- package/README.md +6 -22
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +1 -1
- package/src/assets/panels/geometry-rects.ts +3 -17
- package/src/assets/server/studio-server.ts +1 -1
- package/src/cli/cmd_Bump.ts +61 -0
- package/src/cli/cmd_Check.ts +2 -2
- package/src/cli/cmd_Ci.ts +10 -2
- package/src/cli/cmd_Commands.ts +126 -0
- package/src/cli/cmd_Hooks.ts +66 -0
- package/src/cli/cmd_Mv.ts +29 -6
- package/src/cli/cmd_Release.ts +41 -0
- package/src/cli/cmd_Releases.ts +42 -0
- package/src/cli/cmd_Rules.ts +20 -6
- package/src/cli/cmd_Start.ts +2 -2
- package/src/cli.ts +14 -1
- package/src/collections/posts.ts +1 -1
- package/src/collections/providers.ts +1 -1
- package/src/contracts/manifests/discord.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +2 -4
- package/src/core/canonical-scripts.ts +9 -14
- package/src/core/check-suite.ts +1 -1
- package/src/core/discover-checks.ts +21 -6
- package/src/core/run-check.ts +69 -21
- package/src/core/run-ci.ts +78 -9
- package/src/core/run-fixers.ts +144 -0
- package/src/core/write-status.ts +14 -5
- package/src/hooks/commit-message.ts +42 -0
- package/src/hooks/leak-guard.ts +0 -0
- package/src/hooks/root-identity.ts +64 -0
- package/src/hooks/run-hooks.ts +149 -0
- package/src/init-repo.ts +10 -5
- package/src/reflect/prompt-corpus.ts +3 -4
- package/src/release/changelog.ts +70 -0
- package/src/release/git-facts.ts +133 -0
- package/src/release/run-bump.ts +436 -0
- package/src/release/run-release.ts +271 -0
- package/src/release/run-releases.ts +230 -0
- package/src/release/unit-gate.ts +67 -0
- package/src/release/units.ts +246 -0
- package/src/repo-config.ts +56 -2
- package/src/rules/SK001-package-json-shape.ts +39 -17
- package/src/rules/SK002-contracts-fulfillness.ts +1 -0
- package/src/rules/SK003-claude-md-shape.ts +2 -1
- package/src/rules/SK004-gitignore-policy.ts +4 -0
- package/src/rules/SK005-rvlib-versions.ts +22 -3
- package/src/rules/SK006-npm-publish-safety.ts +1 -0
- package/src/rules/SK007-github-visibility.ts +5 -1
- package/src/rules/SK008-reviewer-agent.ts +1 -0
- package/src/rules/SK009-commit-ai-tells.ts +66 -0
- package/src/rules/SK010-undeclared-release-unit.ts +125 -0
- package/src/rules/rule-registry.ts +26 -6
- package/src/rules/rule.ts +8 -1
- package/src/rules/run-fix.ts +15 -8
- package/src/serve/hub-app/col-card.tsx +2 -3
- package/src/serve/hub-app/hub-app.tsx +6 -8
- package/src/serve/hub-app/hub-st.ts +4 -7
- package/src/serve/hub-app/media-view.tsx +2 -2
- package/src/serve/run-serve.ts +4 -5
- package/src/serve/serve-hub.ts +2 -3
- package/src/shipkit.ts +9 -1
- package/src/tui/checks-panel.ts +1 -1
- package/src/tui/run-tui.ts +2 -3
- package/src/tui/tasks-panel.ts +10 -4
- package/src/tui/tui-store.ts +2 -2
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// what `shipkit ci` repairs before it gates.
|
|
2
|
+
//
|
|
3
|
+
// mechanical slots only (formatter, import canonicaliser): they rewrite the
|
|
4
|
+
// files the agent is already editing and their output is total. structural
|
|
5
|
+
// SK rule fixers rewrite repo config, so they stay behind --fix-rules.
|
|
6
|
+
//
|
|
7
|
+
// under CI the same fixers run in verify mode instead: a gate that silently
|
|
8
|
+
// rewrites the tree it checks can turn red green with no commit. one
|
|
9
|
+
// mechanism, two modes, rather than a formatter plus a format:check that
|
|
10
|
+
// drift apart.
|
|
11
|
+
import { existsSync, readFileSync } from 'fs'
|
|
12
|
+
import { join } from 'path'
|
|
13
|
+
import { fileURLToPath } from 'url'
|
|
14
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
15
|
+
import { runFix } from '../rules/run-fix.ts'
|
|
16
|
+
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
17
|
+
|
|
18
|
+
const FIXER_TIMEOUT_MS = 120_000
|
|
19
|
+
|
|
20
|
+
export type FixerSlot = {
|
|
21
|
+
id: string
|
|
22
|
+
cmd: string[]
|
|
23
|
+
source: 'package.json script' | 'built-in (rvlib-ts-tools)'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type FixerResolution = {
|
|
27
|
+
slots: FixerSlot[]
|
|
28
|
+
/** slots nothing could fill, printed, never silent */
|
|
29
|
+
unfilled: { id: string; reason: string }[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// one fixer per mechanical concern. `fmt` accepts the two names the house
|
|
33
|
+
// vocabulary allows so a repo that calls it `format` is not silently unfixed.
|
|
34
|
+
const FMT_SCRIPTS = ['fmt', 'format'] as const
|
|
35
|
+
|
|
36
|
+
export function resolveFixers(root: string): FixerResolution {
|
|
37
|
+
const pkgPath = join(root, 'package.json')
|
|
38
|
+
let scripts: Record<string, string> = {}
|
|
39
|
+
if (existsSync(pkgPath)) {
|
|
40
|
+
try { scripts = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {} }
|
|
41
|
+
catch (e) { throw new Error(`package.json unparseable — fixers cannot register: ${String(e).slice(0, 120)}`) }
|
|
42
|
+
}
|
|
43
|
+
const slots: FixerSlot[] = []
|
|
44
|
+
const unfilled: { id: string; reason: string }[] = []
|
|
45
|
+
|
|
46
|
+
const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null)
|
|
47
|
+
if (fmtName != null) slots.push({ id: 'fmt', cmd: ['bun', 'run', fmtName], source: 'package.json script' })
|
|
48
|
+
else unfilled.push({ id: 'fmt', reason: `no 'fmt' or 'format' script — formatting stays a manual step` })
|
|
49
|
+
|
|
50
|
+
if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
|
|
51
|
+
else {
|
|
52
|
+
let cli: string | null = null
|
|
53
|
+
try { cli = fileURLToPath(import.meta.resolve('rvlib-ts-tools/src/cli.ts')) } catch { cli = null }
|
|
54
|
+
if (cli == null) unfilled.push({ id: 'imports:fix', reason: `no 'imports:fix' script and rvlib-ts-tools is unresolvable` })
|
|
55
|
+
else slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (rvlib-ts-tools)' })
|
|
56
|
+
}
|
|
57
|
+
return { slots, unfilled }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** worktree fingerprint: every dirty path mapped to `<status>:<content hash>`,
|
|
61
|
+
* or null outside a git repo. The fix pass diffs this before and after so the
|
|
62
|
+
* report names real files instead of asserting "fixed" and hoping.
|
|
63
|
+
*
|
|
64
|
+
* The hash is load-bearing, not decoration. Status alone is blind to the most
|
|
65
|
+
* common case there is: a file the agent already edited (` M`) that a fixer
|
|
66
|
+
* then reformats stays ` M`, so a status-only compare reports "nothing to
|
|
67
|
+
* rewrite" while the tree changed under it, and in verify mode that is a
|
|
68
|
+
* silent green on unformatted code. Hashing is confined to the dirty set,
|
|
69
|
+
* which is small by construction. */
|
|
70
|
+
function treeState(root: string): Map<string, string> | null {
|
|
71
|
+
const p = Bun.spawnSync(['git', 'status', '--porcelain'], { cwd: root })
|
|
72
|
+
if (p.exitCode !== 0) return null
|
|
73
|
+
const m = new Map<string, string>()
|
|
74
|
+
for (const l of p.stdout.toString().split('\n')) {
|
|
75
|
+
if (l.trim() === '') continue
|
|
76
|
+
const status = l.slice(0, 2)
|
|
77
|
+
// rename entries read `R old -> new`; the new path is what exists
|
|
78
|
+
const path = l.slice(3).trim().split(' -> ').at(-1) ?? ''
|
|
79
|
+
if (path === '') continue
|
|
80
|
+
const abs = join(root, path.replace(/^"|"$/g, ''))
|
|
81
|
+
let hash = 'absent'
|
|
82
|
+
try { if (existsSync(abs)) hash = String(Bun.hash(readFileSync(abs))) } catch { hash = 'unreadable' }
|
|
83
|
+
m.set(path, `${status}:${hash}`)
|
|
84
|
+
}
|
|
85
|
+
return m
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type FixPassOpts = {
|
|
89
|
+
/** also apply the structural SK rule fixers (default false) */
|
|
90
|
+
rules?: boolean
|
|
91
|
+
print: (line: string) => void
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type FixPassResult = {
|
|
95
|
+
ran: boolean
|
|
96
|
+
/** paths whose git status changed across the pass */
|
|
97
|
+
touched: string[]
|
|
98
|
+
/** verify mode only: a fixer had to rewrite something, so the commit is not clean */
|
|
99
|
+
failed: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpts): Promise<FixPassResult> {
|
|
103
|
+
// verify mode: same fixers, but rewriting anything means the COMMIT was
|
|
104
|
+
// not clean, and the gate says so instead of quietly repairing it
|
|
105
|
+
const verify = process.env.CI != null && process.env.CI !== ''
|
|
106
|
+
const { slots, unfilled } = resolveFixers(root)
|
|
107
|
+
for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
|
|
108
|
+
const before = treeState(root)
|
|
109
|
+
|
|
110
|
+
for (const s of slots) {
|
|
111
|
+
const r = await spawnWithTimeout(s.cmd, { cwd: root, timeoutMs: FIXER_TIMEOUT_MS })
|
|
112
|
+
// a broken fixer must never be mistaken for a clean one: the gate
|
|
113
|
+
// still runs, but the failure is loud and names the command
|
|
114
|
+
if (r.code !== 0)
|
|
115
|
+
o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
|
|
116
|
+
}
|
|
117
|
+
// structural repo-config repair is never implicit, and never in verify mode
|
|
118
|
+
if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print })
|
|
119
|
+
|
|
120
|
+
const after = treeState(root)
|
|
121
|
+
const touched: string[] = []
|
|
122
|
+
if (before != null && after != null) {
|
|
123
|
+
for (const [path, st] of after) if (before.get(path) !== st) touched.push(path)
|
|
124
|
+
// a file the fixer restored to its committed state disappears from
|
|
125
|
+
// porcelain entirely, that is still a change worth naming
|
|
126
|
+
for (const path of before.keys()) if (!after.has(path)) touched.push(path)
|
|
127
|
+
}
|
|
128
|
+
const list = `${touched.slice(0, 8).join(' ')}${touched.length > 8 ? ` … +${touched.length - 8}` : ''}`
|
|
129
|
+
if (before == null) {
|
|
130
|
+
o.print(`ok: fix pass ran, ${slots.length} fixer(s) (not a git repo — rewritten files cannot be listed)`)
|
|
131
|
+
return { ran: true, touched, failed: false }
|
|
132
|
+
}
|
|
133
|
+
if (touched.length === 0) {
|
|
134
|
+
o.print(`ok: ${verify ? 'fixers verified' : 'fix pass'} clean (${slots.length} fixer(s), nothing to rewrite)`)
|
|
135
|
+
return { ran: true, touched, failed: false }
|
|
136
|
+
}
|
|
137
|
+
if (verify) {
|
|
138
|
+
o.print(`FAIL: ${touched.length} file(s) are not fixer-clean: ${list}`)
|
|
139
|
+
o.print(` fix: run \`shipkit ci\` locally (the fix pass rewrites them) and commit the result`)
|
|
140
|
+
return { ran: true, touched, failed: true }
|
|
141
|
+
}
|
|
142
|
+
o.print(`ok: fix pass rewrote ${touched.length} file(s): ${list}`)
|
|
143
|
+
return { ran: true, touched, failed: false }
|
|
144
|
+
}
|
package/src/core/write-status.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// THE STATUS.md writer — every repo gets one
|
|
2
|
-
//
|
|
3
|
-
// shape rv-doctor pioneered. Atomic + lock-guarded via core/locked-write:
|
|
1
|
+
// THE STATUS.md writer — every repo gets one: timestamp header + verdict +
|
|
2
|
+
// fenced compact render. Atomic + lock-guarded via core/locked-write:
|
|
4
3
|
// concurrent runners never interleave — the loser skips with a WARN
|
|
5
4
|
// outcome, never corrupts the file.
|
|
6
5
|
|
|
@@ -12,6 +11,10 @@ export type StatusInput = {
|
|
|
12
11
|
name: string
|
|
13
12
|
/** the command that produced this render, e.g. 'shipkit ci' */
|
|
14
13
|
command: string
|
|
14
|
+
/** the run covered the WHOLE gate, so a green may say "clear to ship".
|
|
15
|
+
* Absent means it did not: the claim is opt-in, because a caller that
|
|
16
|
+
* forgets it costs a nag and a caller that over-claims costs a release. */
|
|
17
|
+
fullGate?: boolean
|
|
15
18
|
/** the run's printed lines (ANSI stripped here) */
|
|
16
19
|
lines: string[]
|
|
17
20
|
exitCode: number
|
|
@@ -27,11 +30,17 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
|
|
|
27
30
|
const clean = s.lines.map(stripAnsi)
|
|
28
31
|
const fails = clean.filter((l) => l.startsWith('FAIL')).length
|
|
29
32
|
const warns = clean.filter((l) => l.startsWith('WARN')).length
|
|
33
|
+
// only the full gate may say "clear to ship": a checks-only run proves
|
|
34
|
+
// the repo is sound, never that the build passes, and a narrowed gate
|
|
35
|
+
// proves only the slice it ran. the scope rides on the WARN verdict
|
|
36
|
+
// too, or a narrowed green hides behind a nag nobody reads twice
|
|
37
|
+
const full = s.fullGate === true
|
|
38
|
+
const scope = full ? 'clear to ship' : `${s.command} ran, not the whole gate`
|
|
30
39
|
const verdict = s.exitCode !== 0 || fails > 0
|
|
31
40
|
? `RED — ${Math.max(fails, 1)} FAIL line${fails === 1 ? '' : 's'}. Fixes are printed under each FAIL.`
|
|
32
41
|
: warns > 0
|
|
33
|
-
? `GREEN with nags — ${warns} WARN. Proceed.`
|
|
34
|
-
:
|
|
42
|
+
? `GREEN with nags — ${warns} WARN. ${full ? 'Proceed.' : `${scope}.`}`
|
|
43
|
+
: `GREEN — ${scope}.`
|
|
35
44
|
// local time, not UTC — an evening run must not stamp yesterday's date
|
|
36
45
|
const now = new Date()
|
|
37
46
|
const p = (n: number): string => String(n).padStart(2, '0')
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// the commit-message predicate, shared by the commit-msg hook (blocks at
|
|
2
|
+
// write time) and SK009 (audits history). one implementation, or the two
|
|
3
|
+
// drift and the drift only shows when a repo goes public.
|
|
4
|
+
//
|
|
5
|
+
// mechanical text rules only, so this stays publishable.
|
|
6
|
+
|
|
7
|
+
export type OffenseKind = 'dash' | 'attribution'
|
|
8
|
+
export type Offense = { line: number; text: string; kind: OffenseKind; why: string }
|
|
9
|
+
|
|
10
|
+
/** trailers and footers that name an assistant, plus the robot emoji. */
|
|
11
|
+
export const AI_ATTRIBUTION =
|
|
12
|
+
/(?:co-authored-by|assisted-by|signed-off-by):[^\n]*\b(?:claude|anthropic|copilot|chatgpt|openai|cursor|aider|devin|gemini)\b|generated with[^\n]*\b(?:claude|copilot|chatgpt|gemini)\b|🤖/i
|
|
13
|
+
|
|
14
|
+
/** everything below git's scissors line is the verbose-mode diff, which git
|
|
15
|
+
* strips before committing, diff content is not ours to police. */
|
|
16
|
+
export function stripNonMessage(message: string): string {
|
|
17
|
+
const scissors = message.search(/^# -+ >8 -+$/m)
|
|
18
|
+
return scissors === -1 ? message : message.slice(0, scissors)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** offenses in ONE commit message (or one commit body from `git log`).
|
|
22
|
+
* `#` comment lines never land in the message and are ignored; a `Revert "…"`
|
|
23
|
+
* title quotes the reverted subject verbatim and is exempt; a line-start
|
|
24
|
+
* `- ` bullet is a list marker, not punctuation. */
|
|
25
|
+
export function commitMessageOffenses(message: string): Offense[] {
|
|
26
|
+
const offenses: Offense[] = []
|
|
27
|
+
stripNonMessage(message).split('\n').forEach((raw, i) => {
|
|
28
|
+
if (raw.startsWith('#')) return
|
|
29
|
+
if (i === 0 && /^Revert "/.test(raw)) return
|
|
30
|
+
if (/[—–]/.test(raw))
|
|
31
|
+
offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'em/en dash: use a comma, a period, parentheses, or a new sentence' })
|
|
32
|
+
const body = raw.replace(/^\s*[-*] /, '')
|
|
33
|
+
if (body.includes(' - '))
|
|
34
|
+
offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
|
|
35
|
+
if (AI_ATTRIBUTION.test(raw))
|
|
36
|
+
offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, "Generated with" footer, or robot emoji' })
|
|
37
|
+
})
|
|
38
|
+
return offenses
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const hasDashPunctuation = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'dash')
|
|
42
|
+
export const hasAiAttribution = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'attribution')
|
|
Binary file
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// a commit must not silently change WHICH REPO this is.
|
|
2
|
+
//
|
|
3
|
+
// the failure this exists for: a fixture builder ran with its cwd on the real
|
|
4
|
+
// repo instead of a temp dir, overwrote the root package.json and
|
|
5
|
+
// repo.config.ts with a throwaway shape, and committed. Every other gate
|
|
6
|
+
// passed, because a gutted repo still lints, still typechecks and still has a
|
|
7
|
+
// valid package.json. Only the NAME betrayed it.
|
|
8
|
+
import { existsSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
|
|
11
|
+
export type IdentityOutcome =
|
|
12
|
+
| { status: 'skipped'; reason: string }
|
|
13
|
+
| { status: 'clean' }
|
|
14
|
+
| { status: 'changed'; changes: string[]; scratch: string[] }
|
|
15
|
+
|
|
16
|
+
/** scratch files a refactor leaves behind. They are never a deliberate commit,
|
|
17
|
+
* and one of them rode in with the fixture accident. */
|
|
18
|
+
const SCRATCH = /\.(bak|orig|rej)$/
|
|
19
|
+
|
|
20
|
+
const git = (root: string, args: string[]): { code: number; out: string } => {
|
|
21
|
+
const p = Bun.spawnSync(['git', ...args], { cwd: root, env: process.env })
|
|
22
|
+
return { code: p.exitCode, out: p.stdout.toString() }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** the `name:` a repo.config.ts declares, read textually: importing it would
|
|
26
|
+
* run the file, and the staged version is exactly the one not to trust. */
|
|
27
|
+
export function configName(source: string): string | null {
|
|
28
|
+
return source.match(/\bname\s*:\s*['"`]([^'"`]+)['"`]/)?.[1] ?? null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function packageName(source: string): string | null {
|
|
32
|
+
try { return (JSON.parse(source) as { name?: string }).name ?? null }
|
|
33
|
+
catch { return null }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** compare the INDEX against HEAD: what is about to be committed, versus what
|
|
37
|
+
* the repo already agreed it is. Absent on either side is not a change, so
|
|
38
|
+
* adding or removing a file is left to the rules that own that. */
|
|
39
|
+
function identityDrift(root: string, path: string, read: (s: string) => string | null): string | null {
|
|
40
|
+
const staged = git(root, ['show', `:${path}`])
|
|
41
|
+
const head = git(root, ['show', `HEAD:${path}`])
|
|
42
|
+
if (staged.code !== 0 || head.code !== 0) return null
|
|
43
|
+
const before = read(head.out)
|
|
44
|
+
const after = read(staged.out)
|
|
45
|
+
if (before == null || after == null || before === after) return null
|
|
46
|
+
return `${path}: name '${before}' → '${after}'`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function runRootIdentity(root: string): IdentityOutcome {
|
|
50
|
+
if (git(root, ['rev-parse', '--verify', 'HEAD']).code !== 0)
|
|
51
|
+
return { status: 'skipped', reason: 'no HEAD yet — nothing to compare the first commit against' }
|
|
52
|
+
if (process.env['SHIPKIT_ALLOW_IDENTITY_CHANGE'] === '1')
|
|
53
|
+
return { status: 'skipped', reason: 'SHIPKIT_ALLOW_IDENTITY_CHANGE=1' }
|
|
54
|
+
|
|
55
|
+
const changes = [
|
|
56
|
+
identityDrift(root, 'package.json', packageName),
|
|
57
|
+
...(existsSync(join(root, 'repo.config.ts')) ? [identityDrift(root, 'repo.config.ts', configName)] : []),
|
|
58
|
+
].filter((c): c is string => c != null)
|
|
59
|
+
|
|
60
|
+
const added = git(root, ['diff', '--cached', '--name-only', '--diff-filter=A'])
|
|
61
|
+
const scratch = added.out.split('\n').map((l) => l.trim()).filter((l) => l !== '' && SCRATCH.test(l))
|
|
62
|
+
|
|
63
|
+
return changes.length === 0 && scratch.length === 0 ? { status: 'clean' } : { status: 'changed', changes, scratch }
|
|
64
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// the git-hook gates, owned by the toolkit rather than by per-repo shell.
|
|
2
|
+
//
|
|
3
|
+
// hand-rolled hooks are invisible to `shipkit check`: a repo reports green
|
|
4
|
+
// while its next commit is already doomed, and nothing can enumerate what
|
|
5
|
+
// the hooks enforce. `shipkit hooks list` answers that without reading shell.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { commitMessageOffenses } from './commit-message.ts'
|
|
9
|
+
import { runLeakGuard } from './leak-guard.ts'
|
|
10
|
+
import { runRootIdentity } from './root-identity.ts'
|
|
11
|
+
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
12
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
13
|
+
|
|
14
|
+
export type HooksConfig = {
|
|
15
|
+
/** repo-specific commands run in the pre-commit gate, after the built-ins.
|
|
16
|
+
* Non-zero exit blocks the commit; stdout/stderr are shown. */
|
|
17
|
+
preCommit?: { id: string; cmd: string[] }[]
|
|
18
|
+
/** repo-specific commands run on the commit message; the message file path
|
|
19
|
+
* is appended as the last argument. */
|
|
20
|
+
commitMsg?: { id: string; cmd: string[] }[]
|
|
21
|
+
/** keyword file for the leak guard (default '.rv-private/banned-keywords.txt') */
|
|
22
|
+
keywordFile?: string
|
|
23
|
+
/** disable a built-in gate, visible in `hooks list`, never silent */
|
|
24
|
+
disable?: ('leak-guard' | 'commit-message' | 'root-identity')[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HOOK_DIR = 'hooks'
|
|
28
|
+
const HOOKS = ['pre-commit', 'commit-msg'] as const
|
|
29
|
+
|
|
30
|
+
/** wire `core.hooksPath` and write the two hook bodies. Idempotent. */
|
|
31
|
+
export function installHooks(root: string, binInvocation: string): { written: string[]; note: string } {
|
|
32
|
+
const dir = join(root, HOOK_DIR)
|
|
33
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
34
|
+
const written: string[] = []
|
|
35
|
+
for (const h of HOOKS) {
|
|
36
|
+
const body = h === 'commit-msg'
|
|
37
|
+
? `#!/bin/sh\nexec ${binInvocation} hooks commit-msg "$1"\n`
|
|
38
|
+
: `#!/bin/sh\nexec ${binInvocation} hooks pre-commit\n`
|
|
39
|
+
const path = join(dir, h)
|
|
40
|
+
const prev = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
41
|
+
if (prev !== body) { writeFileSync(path, body, { mode: 0o755 }); written.push(`${HOOK_DIR}/${h}`) }
|
|
42
|
+
}
|
|
43
|
+
const cfg = Bun.spawnSync(['git', 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
|
|
44
|
+
return {
|
|
45
|
+
written,
|
|
46
|
+
note: cfg.exitCode === 0 ? `core.hooksPath=${HOOK_DIR}` : `WARN could not set core.hooksPath (exit ${cfg.exitCode}) — hooks will not fire`,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type GateOutcome = { id: string; ok: boolean; lines: string[] }
|
|
51
|
+
|
|
52
|
+
/** the pre-commit gate: built-in leak guard, then the repo's own commands. */
|
|
53
|
+
export async function runPreCommit(root: string, config: RepoConfig): Promise<GateOutcome[]> {
|
|
54
|
+
const h = config.hooks ?? {}
|
|
55
|
+
const out: GateOutcome[] = []
|
|
56
|
+
if (!(h.disable ?? []).includes('leak-guard')) {
|
|
57
|
+
const r = runLeakGuard(root, { keywordFile: h.keywordFile })
|
|
58
|
+
if (r.status === 'skipped') out.push({ id: 'leak-guard', ok: true, lines: [`skip: leak-guard (${r.reason})`] })
|
|
59
|
+
else if (r.status === 'clean') out.push({ id: 'leak-guard', ok: true, lines: [`ok: leak-guard clean (${r.patterns} pattern(s))`] })
|
|
60
|
+
else out.push({
|
|
61
|
+
id: 'leak-guard',
|
|
62
|
+
ok: false,
|
|
63
|
+
lines: [
|
|
64
|
+
`FAIL: leak-guard matched ${r.hits.length} banned pattern(s) — this commit would leak private content`,
|
|
65
|
+
...r.hits.map((x) => ` ${x.where}: ${x.pattern}`),
|
|
66
|
+
` fix: remove the content, or edit the keyword file if the pattern is wrong`,
|
|
67
|
+
],
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
if (!(h.disable ?? []).includes('root-identity')) {
|
|
71
|
+
const r = runRootIdentity(root)
|
|
72
|
+
if (r.status === 'skipped') out.push({ id: 'root-identity', ok: true, lines: [`skip: root-identity (${r.reason})`] })
|
|
73
|
+
else if (r.status === 'clean') out.push({ id: 'root-identity', ok: true, lines: ['ok: root-identity unchanged'] })
|
|
74
|
+
else out.push({
|
|
75
|
+
id: 'root-identity',
|
|
76
|
+
ok: false,
|
|
77
|
+
lines: [
|
|
78
|
+
`FAIL: this commit changes WHICH REPO this is, which is almost always a fixture that escaped its temp dir`,
|
|
79
|
+
...r.changes.map((c) => ` ${c}`),
|
|
80
|
+
...r.scratch.map((s) => ` adds a scratch file: ${s}`),
|
|
81
|
+
` fix: restore the real files (git checkout HEAD -- package.json repo.config.ts) and drop the scratch files`,
|
|
82
|
+
` a deliberate rename: SHIPKIT_ALLOW_IDENTITY_CHANGE=1 git commit ...`,
|
|
83
|
+
],
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
for (const c of h.preCommit ?? []) {
|
|
87
|
+
const r = await spawnWithTimeout(c.cmd, { cwd: root, timeoutMs: 120_000 })
|
|
88
|
+
out.push({
|
|
89
|
+
id: c.id,
|
|
90
|
+
ok: r.code === 0,
|
|
91
|
+
lines: r.code === 0 ? [`ok: ${c.id}`] : [`FAIL: ${c.id} (exit ${r.code})`, ...r.out.trim().split('\n').slice(-30).map((l) => ` ${l}`)],
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** the commit-msg gate: the shared message predicate, then the repo's own. */
|
|
98
|
+
export async function runCommitMsg(root: string, config: RepoConfig, messageFile: string): Promise<GateOutcome[]> {
|
|
99
|
+
const h = config.hooks ?? {}
|
|
100
|
+
const out: GateOutcome[] = []
|
|
101
|
+
const message = readFileSync(messageFile, 'utf8')
|
|
102
|
+
if (!(h.disable ?? []).includes('commit-message')) {
|
|
103
|
+
const offenses = commitMessageOffenses(message)
|
|
104
|
+
out.push(offenses.length === 0
|
|
105
|
+
? { id: 'commit-message', ok: true, lines: ['ok: commit message clean (no dash punctuation, no AI attribution)'] }
|
|
106
|
+
: {
|
|
107
|
+
id: 'commit-message',
|
|
108
|
+
ok: false,
|
|
109
|
+
lines: [
|
|
110
|
+
`FAIL: commit message has ${offenses.length} offense(s)`,
|
|
111
|
+
...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`),
|
|
112
|
+
],
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
// the leak guard also reads the MESSAGE, a private name pasted into a
|
|
116
|
+
// commit body is as public as one pasted into a file
|
|
117
|
+
if (!(h.disable ?? []).includes('leak-guard')) {
|
|
118
|
+
const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, text: [{ label: 'commit message', content: message }] })
|
|
119
|
+
if (r.status === 'hit')
|
|
120
|
+
out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map((x) => ` ${x.where}: ${x.pattern}`)] })
|
|
121
|
+
}
|
|
122
|
+
for (const c of h.commitMsg ?? []) {
|
|
123
|
+
const r = await spawnWithTimeout([...c.cmd, messageFile], { cwd: root, timeoutMs: 60_000 })
|
|
124
|
+
out.push({
|
|
125
|
+
id: c.id,
|
|
126
|
+
ok: r.code === 0,
|
|
127
|
+
lines: r.code === 0 ? [`ok: ${c.id}`] : [`FAIL: ${c.id} (exit ${r.code})`, ...r.out.trim().split('\n').slice(-30).map((l) => ` ${l}`)],
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** what blocks a commit here, the enumeration hand-rolled hooks never had. */
|
|
134
|
+
export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
135
|
+
const h = config.hooks ?? {}
|
|
136
|
+
const off = new Set(h.disable ?? [])
|
|
137
|
+
const kw = h.keywordFile ?? '.rv-private/banned-keywords.txt'
|
|
138
|
+
// a gate listed as active but unable to fire is worse than no listing:
|
|
139
|
+
// this surface exists so nobody has to guess
|
|
140
|
+
const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) ? '' : ` (INACTIVE, ${kw} absent)`
|
|
141
|
+
return [
|
|
142
|
+
`pre-commit leak-guard${leakState} — staged content + paths vs ${kw}`,
|
|
143
|
+
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
144
|
+
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
|
145
|
+
`commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution`,
|
|
146
|
+
`commit-msg leak-guard${leakState} — the message text`,
|
|
147
|
+
...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
|
|
148
|
+
]
|
|
149
|
+
}
|
package/src/init-repo.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// deps — every gap is printed, never silent). Plain module behind cmd_Init.
|
|
4
4
|
// Refuses to overwrite an existing config; script merge never clobbers
|
|
5
5
|
// existing keys. `bin` switches script bodies from the npm bin to an
|
|
6
|
-
// in-repo-source CLI path
|
|
7
|
-
//
|
|
6
|
+
// in-repo-source CLI path for repos that consume shipkit by path instead of
|
|
7
|
+
// from node_modules. Only bodies shipkit cannot express as a verb are
|
|
8
|
+
// scaffolded: an alias script would be a second name for one capability.
|
|
8
9
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
9
10
|
import { join } from 'path'
|
|
10
11
|
import { canonicalBodies } from './core/canonical-scripts.ts'
|
|
12
|
+
import { hasTestFiles } from './rules/SK001-package-json-shape.ts'
|
|
11
13
|
import { reportToMotherhome } from './core/motherhome.ts'
|
|
12
14
|
import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
|
|
13
15
|
import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
|
|
@@ -59,7 +61,10 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
59
61
|
// canonical script bodies — ONE source (core/canonical-scripts.ts),
|
|
60
62
|
// shared with rule SK001's autofix; vocabulary owned by
|
|
61
63
|
// meta/rv-conventions/package-json.md
|
|
64
|
+
// `test` is scaffolded only once the repo has tests: `bun test` exits
|
|
65
|
+
// non-zero with none, and a scaffold that reds the gate is a bad scaffold
|
|
62
66
|
for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
|
|
67
|
+
if (k === 'test' && !hasTestFiles(root)) continue
|
|
63
68
|
if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
|
|
64
69
|
else if (pkg.scripts[k] !== v) console.log(`skip: package.json script '${k}' exists — left untouched`)
|
|
65
70
|
}
|
|
@@ -76,8 +81,8 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
76
81
|
const floor = resolveFloor(defineRepo({ name }), root)
|
|
77
82
|
for (const t of floor.tasks) console.log(`ok: floor ${t.id} ← ${floor.sources[t.id] ?? 'unknown'}`)
|
|
78
83
|
for (const u of floor.unfilled) console.log(`skip: ${u.id} (${u.reason})`)
|
|
79
|
-
//
|
|
80
|
-
// actually installed (
|
|
84
|
+
// every verb comes from the `shipkit` bin — dead on arrival unless the
|
|
85
|
+
// package is actually installed (bunx runs without adding the dep)
|
|
81
86
|
if (opts.bin == null) {
|
|
82
87
|
try {
|
|
83
88
|
Bun.resolveSync('rvlib-shipkit', root)
|
|
@@ -86,7 +91,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
86
91
|
console.log(' fix: bun add -d rvlib-shipkit')
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
|
-
console.log('next: fill venues/social in repo.config.ts, then `
|
|
94
|
+
console.log('next: fill venues/social in repo.config.ts, then `shipkit ci`')
|
|
90
95
|
console.log('next: identity+assets — mint .rv-identity/brand.ts + .rv-assets/kit.ts, `shipkit assets identity` (the identity contract expects public/ favicon+og+logo)')
|
|
91
96
|
return 0
|
|
92
97
|
}
|
|
@@ -35,8 +35,8 @@ const siblingConfigDirs = (home: string): string[] => {
|
|
|
35
35
|
* (`:` or `,` separated) PLUS ~/.claude PLUS any sibling ~/.claude-<name>
|
|
36
36
|
* holding a projects/ dir. Additive on purpose — a second local account keeps
|
|
37
37
|
* its own transcripts, and the env var only names the account running RIGHT
|
|
38
|
-
* NOW, so honoring it alone hides every session of the other one
|
|
39
|
-
*
|
|
38
|
+
* NOW, so honoring it alone hides every session of the other one — a corpus
|
|
39
|
+
* mined from a single root is silently partial. */
|
|
40
40
|
export function discoverConfigRoots(home: string = homedir()): ConfigRoot[] {
|
|
41
41
|
const fromEnv = (process.env.CLAUDE_CONFIG_DIR ?? '')
|
|
42
42
|
.split(/[:,]/)
|
|
@@ -137,8 +137,7 @@ export function promptTextOf(line: string): { text: string; entry: RawEntry } |
|
|
|
137
137
|
/** A prompt he typed while the agent was still working: the harness parks it as
|
|
138
138
|
* `{type:'queue-operation', operation:'enqueue', content}` and, if the run ends
|
|
139
139
|
* first, it may NEVER become a user message. Skipping these loses exactly the
|
|
140
|
-
* mid-flight corrections and standing conventions a retrospective is for
|
|
141
|
-
* (measured 2026-07-26: 72 such texts invisible in this repo alone). */
|
|
140
|
+
* mid-flight corrections and standing conventions a retrospective is for */
|
|
142
141
|
export function queuedTextOf(line: string): { text: string; entry: RawEntry } | null {
|
|
143
142
|
let parsed: unknown
|
|
144
143
|
try {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// reading a versioned CHANGELOG, in one place, because two readers of the
|
|
2
|
+
// same file always drift and the release then ships notes nobody wrote.
|
|
3
|
+
// House convention: newest first, one `## <version>` per released version,
|
|
4
|
+
// `## Unreleased` on top while work accumulates.
|
|
5
|
+
|
|
6
|
+
/** matches `## 1.2.3`, `## [1.2.3]`, and a dated heading in any of the dash
|
|
7
|
+
* shapes real changelogs use (`## 1.2.3 — 2026-07-28`). The dash here is
|
|
8
|
+
* DATA in someone's changelog, not prose this repo controls. */
|
|
9
|
+
export const changelogHeading = (version: string): RegExp =>
|
|
10
|
+
new RegExp(`^##\\s*\\[?${version.replace(/\./g, '\\.')}\\]?\\s*(?:[-–—:(].*)?$`)
|
|
11
|
+
|
|
12
|
+
/** the `## <version>` section of a versioned changelog, as release notes.
|
|
13
|
+
* Throws when the section is missing or empty: a release with no notes is a
|
|
14
|
+
* release nobody can read later. */
|
|
15
|
+
export function extractChangelogSection(p: { changelog: string; version: string }): string {
|
|
16
|
+
const lines = p.changelog.split('\n')
|
|
17
|
+
const head = changelogHeading(p.version)
|
|
18
|
+
const start = lines.findIndex((l) => head.test(l.trim()))
|
|
19
|
+
if (start === -1) throw new Error(`CHANGELOG has no "## ${p.version}" section — write the notes, then release`)
|
|
20
|
+
let end = lines.length
|
|
21
|
+
for (let i = start + 1; i < lines.length; i++)
|
|
22
|
+
if (lines[i]?.startsWith('## ')) { end = i; break }
|
|
23
|
+
const body = lines.slice(start + 1, end).join('\n').trim()
|
|
24
|
+
if (body === '') throw new Error(`CHANGELOG section "## ${p.version}" is empty — write the notes, then release`)
|
|
25
|
+
return body
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const hasVersionSection = (changelog: string, version: string): boolean =>
|
|
29
|
+
changelog.split('\n').some((l) => changelogHeading(version).test(l.trim()))
|
|
30
|
+
|
|
31
|
+
// the heading may carry the kind this release IS: `## Unreleased (minor)`.
|
|
32
|
+
// Whoever writes the notes knows whether they broke something, and that is
|
|
33
|
+
// the only moment the answer is cheap. A batch release then needs no single
|
|
34
|
+
// kind imposed across units that changed by different amounts.
|
|
35
|
+
const UNRELEASED = /^##\s*\[?unreleased\]?\s*(?:\((major|minor|patch)\))?\s*$/i
|
|
36
|
+
|
|
37
|
+
export type ReleaseKind = 'major' | 'minor' | 'patch'
|
|
38
|
+
|
|
39
|
+
export const hasUnreleasedHeading = (changelog: string): boolean =>
|
|
40
|
+
changelog.split('\n').some((l) => UNRELEASED.test(l.trim()))
|
|
41
|
+
|
|
42
|
+
/** the kind declared on the Unreleased heading, or null when it says nothing.
|
|
43
|
+
* Null is never guessed into a kind: `--all` refuses and names the unit,
|
|
44
|
+
* because a wrong version number reaches a registry and cannot be recalled. */
|
|
45
|
+
export function declaredKind(changelog: string): ReleaseKind | null {
|
|
46
|
+
for (const l of changelog.split('\n')) {
|
|
47
|
+
const m = l.trim().match(UNRELEASED)
|
|
48
|
+
if (m != null) return (m[1]?.toLowerCase() as ReleaseKind | undefined) ?? null
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** rename the Unreleased heading to the new version. Returns the new text, or
|
|
54
|
+
* an error when there is no such section or it carries no notes: a version
|
|
55
|
+
* with empty notes is a version nobody can read later. */
|
|
56
|
+
export function promoteUnreleased(changelog: string, version: string): { text: string } | { error: string } {
|
|
57
|
+
const lines = changelog.split('\n')
|
|
58
|
+
const at = lines.findIndex((l) => UNRELEASED.test(l.trim()))
|
|
59
|
+
if (at === -1) return { error: `CHANGELOG has no "## Unreleased" section — write the notes for this version under one, then bump` }
|
|
60
|
+
let end = lines.length
|
|
61
|
+
for (let i = at + 1; i < lines.length; i++) if (lines[i]?.startsWith('## ')) { end = i; break }
|
|
62
|
+
if (lines.slice(at + 1, end).join('\n').trim() === '')
|
|
63
|
+
return { error: `the "## Unreleased" section is empty — write what changed, then bump` }
|
|
64
|
+
lines[at] = `## ${version}`
|
|
65
|
+
return { text: lines.join('\n') }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** does this unit have notes waiting? `--all` selects on exactly this: having
|
|
69
|
+
* something to say IS the decision to ship. */
|
|
70
|
+
export const hasUnreleasedNotes = (changelog: string): boolean => !('error' in promoteUnreleased(changelog, '0.0.0'))
|