rvlib-shipkit 0.37.0 → 0.38.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 +21 -1
- package/package.json +1 -1
- package/src/cli/cmd_Rules.ts +14 -9
- package/src/core/discover-checks.ts +6 -2
- package/src/core/floor.ts +38 -3
- package/src/core/locked-write.ts +2 -2
- package/src/core/machine-commit.ts +3 -0
- package/src/core/nesting.ts +57 -0
- package/src/core/repair-contract-dirs.ts +4 -1
- package/src/core/run-check.ts +2 -2
- package/src/core/run-ci.ts +1 -0
- package/src/core/task-runner.ts +6 -2
- package/src/hooks/run-hooks.ts +1 -1
- package/src/init-repo.ts +5 -1
- package/src/ledger/gate-units.ts +2 -1
- package/src/ledger/git-key.ts +5 -2
- package/src/ledger/git-scan.ts +11 -5
- package/src/release/run-bump.ts +3 -0
- package/src/release/run-release.ts +3 -0
- package/src/release/units.ts +4 -1
- package/src/repo-config.ts +36 -3
- package/src/rules/SK004-gitignore-policy.ts +95 -3
- package/src/rules/SK007-github-visibility.ts +4 -0
- package/src/rules/SK008-reviewer-agent.ts +3 -3
- package/src/rules/SK009-commit-ai-tells.ts +4 -0
- package/src/rules/SK013-one-way-in-policy.ts +54 -18
- package/src/rules/SK015-nested-repo.ts +29 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/rules/rule.ts +13 -0
- package/src/rules/run-fix.ts +6 -0
- package/src/suite/judge-suites.ts +143 -0
- package/src/suite/suite-cli.ts +114 -0
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// PLACE — lines below it (e.g. a live venue's `!.rv-itch/` override, where
|
|
8
8
|
// last-match-wins) keep their position; everything outside the block is
|
|
9
9
|
// never touched. `shipkit init` writes the same block through this fixer.
|
|
10
|
+
import { execFileSync } from 'child_process'
|
|
10
11
|
import { existsSync, readFileSync } from 'fs'
|
|
11
12
|
import { join } from 'path'
|
|
12
13
|
import type { RepoConfig } from '../repo-config.ts'
|
|
@@ -58,6 +59,60 @@ export function policyPatterns(config: RepoConfig): string[] {
|
|
|
58
59
|
return [...POLICY_LINES.filter((l) => !l.startsWith('#')), ...(config.gitignore ?? [])]
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
// the three venue folders are DESIGNED to be unignored below the block once a
|
|
63
|
+
// listing is live (last-match-wins, the comment inside POLICY_LINES says so),
|
|
64
|
+
// so an override on one of them is the mechanism working. Every other policy
|
|
65
|
+
// path is a claim the block makes, and a claim git contradicts is a bug.
|
|
66
|
+
function endsWithNewline(root: string): boolean {
|
|
67
|
+
const p = join(root, '.gitignore')
|
|
68
|
+
if (!existsSync(p)) return true
|
|
69
|
+
const text = readFileSync(p, 'utf8')
|
|
70
|
+
return text === '' || text.endsWith('\n')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const OVERRIDABLE = new Set(['.rv-itch/', '.rv-steam/', '.rv-discord/'])
|
|
74
|
+
|
|
75
|
+
/** a path git can be asked about, for a pattern that may be a glob or a dir */
|
|
76
|
+
function probePath(pattern: string): string {
|
|
77
|
+
if (pattern.endsWith('/')) return `${pattern}probe`
|
|
78
|
+
// `.*.tmp-*` and `*.loras.local.txt`: a literal standing in for the glob
|
|
79
|
+
return pattern.includes('*') ? pattern.replace(/\*/g, 'probe') : pattern
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** which of `paths` git does NOT ignore. null when this is not a git repo, so
|
|
83
|
+
* a rule can say it did not measure rather than report a green it invented */
|
|
84
|
+
export function notIgnored(root: string, paths: string[]): string[] | null {
|
|
85
|
+
if (paths.length === 0) return []
|
|
86
|
+
try {
|
|
87
|
+
// --stdin -z -n: one call, every path, and `-n` makes git print a row
|
|
88
|
+
// for the MISSES too, which is the answer we want
|
|
89
|
+
const out = execFileSync('git', ['check-ignore', '--stdin', '-z', '-n', '-v'], {
|
|
90
|
+
cwd: root, input: paths.join('\0'), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
91
|
+
})
|
|
92
|
+
// -v -z rows are source\0line\0pattern\0path; an unignored path has an
|
|
93
|
+
// empty source and an empty pattern
|
|
94
|
+
const f = out.split('\0')
|
|
95
|
+
const miss: string[] = []
|
|
96
|
+
for (let i = 0; i + 3 < f.length + 1; i += 4) {
|
|
97
|
+
if (f[i] === '' && f[i + 2] === '' && f[i + 3] != null && f[i + 3] !== '') miss.push(f[i + 3]!)
|
|
98
|
+
}
|
|
99
|
+
return miss
|
|
100
|
+
} catch (e) {
|
|
101
|
+
// exit 1 means "none of them are ignored", which is an ANSWER, and git
|
|
102
|
+
// still wrote the rows we parse. Anything else (no repo, no git) is not
|
|
103
|
+
const err = e as { status?: number; stdout?: string }
|
|
104
|
+
if (err.status === 1 && typeof err.stdout === 'string') {
|
|
105
|
+
const f = err.stdout.split('\0')
|
|
106
|
+
const miss: string[] = []
|
|
107
|
+
for (let i = 0; i + 3 < f.length + 1; i += 4) {
|
|
108
|
+
if (f[i] === '' && f[i + 2] === '' && f[i + 3] != null && f[i + 3] !== '') miss.push(f[i + 3]!)
|
|
109
|
+
}
|
|
110
|
+
return miss
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
61
116
|
type Parsed = { text: string; crlf: boolean; lines: string[]; b: number; e: number }
|
|
62
117
|
|
|
63
118
|
function parse(root: string): Parsed | null {
|
|
@@ -122,11 +177,48 @@ export const gitignorePolicy: Rule = {
|
|
|
122
177
|
case 'missing-block': return t.fail('.gitignore has no shipkit policy block — contract surfaces can leak into git')
|
|
123
178
|
case 'corrupted': return t.fail('.gitignore policy block corrupted (unpaired marker)')
|
|
124
179
|
case 'stale': return t.fail('.gitignore policy block is stale — the policy or repo-contributed lines moved since it was written')
|
|
125
|
-
case 'current': return t.ok(`.gitignore policy block current (${policyPatterns(env.config).length} patterns)`)
|
|
126
180
|
}
|
|
181
|
+
// an unterminated last line is a TRAP, not a cosmetic defect: the next
|
|
182
|
+
// `>> .gitignore` fuses onto it and the result is one pattern that
|
|
183
|
+
// ignores neither path, with the file ending in a newline afterwards
|
|
184
|
+
// so nothing textual looks wrong
|
|
185
|
+
if (!endsWithNewline(env.root)) {
|
|
186
|
+
t.fail('.gitignore does not end with a newline — the next append fuses onto its last line and ignores neither path')
|
|
187
|
+
t.lines.push({ kind: 'fix-fail', text: 'shipkit ci (or check --fix SK004) terminates it' })
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
// the block states what must be ignored. git is the only thing that
|
|
191
|
+
// knows whether it IS: a stray negation below the block, or a line
|
|
192
|
+
// fused into another, leaves the text current over a broken repo
|
|
193
|
+
const claimed = policyPatterns(env.config).filter((p) => !OVERRIDABLE.has(p))
|
|
194
|
+
const probes = new Map(claimed.map((p) => [probePath(p), p]))
|
|
195
|
+
const miss = notIgnored(env.root, [...probes.keys()])
|
|
196
|
+
if (miss == null) {
|
|
197
|
+
// not a git repo (a fixture, or a dir before `git init`). Say the
|
|
198
|
+
// verification did not run rather than reporting a green nothing
|
|
199
|
+
// measured, and stay quiet: there is nothing wrong here to fix
|
|
200
|
+
t.ok(`.gitignore policy block current (${claimed.length} patterns)`)
|
|
201
|
+
t.lines.push({ kind: 'skip', text: 'not a git repo here, so nothing verified the block against what git actually ignores' })
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
if (miss.length > 0) {
|
|
205
|
+
const names = [...new Set(miss.map((m) => probes.get(m) ?? m))]
|
|
206
|
+
t.fail(`.gitignore block claims ${names.join(', ')} ignored, git says otherwise — a negation below the block, or a line fused into its neighbour`)
|
|
207
|
+
t.lines.push({ kind: 'fix-fail', text: `git check-ignore -v ${miss[0]} names the line that wins` })
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
t.ok(`.gitignore policy block current, ${claimed.length} patterns verified against git`)
|
|
127
211
|
},
|
|
128
212
|
fix: (env: RuleEnv): FixResult => {
|
|
129
213
|
const before = blockState(env.root, env.config)
|
|
214
|
+
// terminate an unterminated file even when the block itself is current:
|
|
215
|
+
// that missing byte is what the next append fuses onto
|
|
216
|
+
let terminated: string | null = null
|
|
217
|
+
if (before === 'current' && !endsWithNewline(env.root)) {
|
|
218
|
+
const p = join(env.root, '.gitignore')
|
|
219
|
+
atomicWrite(p, `${readFileSync(p, 'utf8')}\n`)
|
|
220
|
+
terminated = '.gitignore terminated with a newline (the next append would have fused onto its last line)'
|
|
221
|
+
}
|
|
130
222
|
const blockSummary = before === 'current'
|
|
131
223
|
? null
|
|
132
224
|
: before === 'missing-file' ? '.gitignore created with the policy block'
|
|
@@ -137,10 +229,10 @@ export const gitignorePolicy: Rule = {
|
|
|
137
229
|
// the block must be written FIRST: untracking a path the ignore list
|
|
138
230
|
// does not yet carry leaves it staged for re-add on the next `git add`
|
|
139
231
|
const un = untrackGenerated(env.root, { enabled: env.config.untrackGenerated })
|
|
140
|
-
const parts = [blockSummary]
|
|
232
|
+
const parts = [blockSummary, terminated]
|
|
141
233
|
if (un.did === 'untracked') parts.push(`untracked ${un.paths.join(', ')} (generated, now gitignored, committed as ${un.commit})`)
|
|
142
234
|
if (un.did === 'refused') parts.push(`left ${trackedGeneratedPaths(env.root).join(', ')} tracked: ${un.why}`)
|
|
143
235
|
const summary = parts.filter((x): x is string => x != null).join(' · ')
|
|
144
|
-
return { changed: blockSummary != null || un.did === 'untracked', summary: summary === '' ? 'policy block already current' : summary }
|
|
236
|
+
return { changed: blockSummary != null || terminated != null || un.did === 'untracked', summary: summary === '' ? 'policy block already current' : summary }
|
|
145
237
|
},
|
|
146
238
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// erroring, unreadable answer) is a loud skip — an UNKNOWN, never red,
|
|
15
15
|
// never cached.
|
|
16
16
|
import { carveGhJson, firstGhLine } from '../git-bootstrap.ts'
|
|
17
|
+
import { nestedIn } from '../core/nesting.ts'
|
|
17
18
|
import type { Rule } from './rule.ts'
|
|
18
19
|
|
|
19
20
|
export const githubVisibility: Rule = {
|
|
@@ -30,6 +31,9 @@ export const githubVisibility: Rule = {
|
|
|
30
31
|
const declared = env.config.visibility ?? 'private'
|
|
31
32
|
const skip = (why: string): void => void t.lines.push({ kind: 'skip', text: `github visibility unverified — ${why} (declared: ${declared})` })
|
|
32
33
|
const firstLine = firstGhLine
|
|
34
|
+
const nested = nestedIn(env.root)
|
|
35
|
+
if (nested != null)
|
|
36
|
+
return void t.lines.push({ kind: 'skip', text: `github visibility unverified, nested in ${nested}: the remote belongs to the parent repo (declared: ${declared})` })
|
|
33
37
|
|
|
34
38
|
const remotes = await t.exec(['git', 'remote'], { cwd: env.root })
|
|
35
39
|
if (remotes.code !== 0) return skip(`git says: ${firstLine(remotes.out) || `exit ${remotes.code}`}`)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// SK008 — the
|
|
1
|
+
// SK008 — the declared reviewer agent type. A repo that declares
|
|
2
2
|
// `reviewer: { name, brief }` must carry `.claude/agents/<name>.md`: an agent
|
|
3
3
|
// definition whose system prompt POINTS at the brief file (ONE home for the
|
|
4
4
|
// text, so review framing can never drift per repo) and whose `tools:` line
|
|
@@ -50,7 +50,7 @@ export function briefMissing(root: string, brief: string): boolean {
|
|
|
50
50
|
function canonicalAgent(reviewer: NonNullable<RepoConfig['reviewer']>): string {
|
|
51
51
|
return `---
|
|
52
52
|
name: ${reviewer.name}
|
|
53
|
-
description: The
|
|
53
|
+
description: The critical reviewer for this repo. Whether a review is worth its wall clock is the session's call: reach for it on code whose failure the gate cannot see, and tell it to verify by running rather than to opine. Judges whether a change should exist at all, not just whether it is correct.
|
|
54
54
|
tools: ${(reviewer.tools ?? REVIEWER_TOOLS).join(', ')}
|
|
55
55
|
model: opus
|
|
56
56
|
---
|
|
@@ -82,7 +82,7 @@ export const reviewerAgent: Rule = {
|
|
|
82
82
|
const rel = reviewerAgentPath(r.name)
|
|
83
83
|
const p = join(env.root, rel)
|
|
84
84
|
if (briefMissing(env.root, r.brief)) t.fail(`the declared brief '${r.brief}' does not exist — every review's mandated first action would fail`)
|
|
85
|
-
if (!existsSync(p)) return t.fail(`${rel} missing — the
|
|
85
|
+
if (!existsSync(p)) return t.fail(`${rel} missing — the declared reviewer type is unavailable, so any review runs on whatever framing the spawner improvised`)
|
|
86
86
|
const issues = reviewerAgentIssues(readFileSync(p, 'utf8'), r)
|
|
87
87
|
for (const i of issues) t.fail(`${rel}: ${i}`)
|
|
88
88
|
if (issues.length === 0 && !t.failed) t.ok(`${rel} → ${r.brief}`)
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { readFileSync } from 'fs'
|
|
12
12
|
import { join } from 'path'
|
|
13
13
|
import { hasAiAttribution, hasDashPunctuation } from '../hooks/commit-message.ts'
|
|
14
|
+
import { nestedIn } from '../core/nesting.ts'
|
|
14
15
|
import type { Rule } from './rule.ts'
|
|
15
16
|
|
|
16
17
|
// the documented silencer: .claude/settings.json attribution.commit/.pr both
|
|
@@ -33,6 +34,9 @@ export const commitAiTells: Rule = {
|
|
|
33
34
|
// work-tree content, so nothing a scan can produce describes this answer
|
|
34
35
|
never: 'reads the full commit history and every ref name, neither of which is work-tree content',
|
|
35
36
|
lint: async (t, env) => {
|
|
37
|
+
const nested = nestedIn(env.root)
|
|
38
|
+
if (nested != null)
|
|
39
|
+
return void t.lines.push({ kind: 'skip', text: `commit history not scanned, nested in ${nested}: that history belongs to the parent repo` })
|
|
36
40
|
const declared = env.config.visibility ?? 'private'
|
|
37
41
|
if (declared !== 'public')
|
|
38
42
|
return t.ok(`declared ${declared} — the no-AI-attribution ban still applies from commit one (identity/how-we-commit.md); the full-history scan arms at visibility: 'public'`)
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// TRACKED on purpose: `.claude/settings.local.json` is gitignored by the house
|
|
14
14
|
// scaffold, so a policy written there would bind exactly one checkout.
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync } from 'fs'
|
|
16
|
-
import { dirname, join } from 'path'
|
|
16
|
+
import { dirname, isAbsolute, join } from 'path'
|
|
17
17
|
import { inferBin } from '../core/canonical-scripts.ts'
|
|
18
18
|
import { denyRules, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
|
|
19
19
|
import type { RepoConfig } from '../repo-config.ts'
|
|
@@ -36,19 +36,30 @@ export function exemptSlots(c: RepoConfig): Set<string> {
|
|
|
36
36
|
return off
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/** how the hook invokes the guard.
|
|
40
|
-
* is not promised to be the repo
|
|
39
|
+
/** how the hook invokes the guard. A relative path hangs off
|
|
40
|
+
* `$CLAUDE_PROJECT_DIR` because a hook's cwd is not promised to be the repo
|
|
41
|
+
* root, and an absolute one stands alone */
|
|
41
42
|
export function guardCommand(bin?: string): string {
|
|
42
43
|
const path = bin == null
|
|
43
44
|
? 'node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts'
|
|
44
45
|
: bin.replace(/cli\.ts$/, 'hooks/one-way-in-guard.ts')
|
|
45
|
-
return `bun "$CLAUDE_PROJECT_DIR/${path}"`
|
|
46
|
+
return isAbsolute(path) ? `bun "${path}"` : `bun "$CLAUDE_PROJECT_DIR/${path}"`
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
const readPkg = (root: string): { scripts?: Record<string, string>; dependencies?: Record<string, string>; devDependencies?: Record<string, string> } | null => {
|
|
50
|
+
try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as never } catch { return null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const declaresShipkit = (root: string): boolean => {
|
|
54
|
+
const pkg = readPkg(root)
|
|
55
|
+
return pkg?.dependencies?.['rvlib-shipkit'] != null || pkg?.devDependencies?.['rvlib-shipkit'] != null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** a guard under `node_modules/` that is not there yet, in a repo that
|
|
59
|
+
* declares rvlib-shipkit, says the dependency is not installed, which
|
|
60
|
+
* `bun install` fixes and no edit to this repo can. Without the declaration
|
|
61
|
+
* no install ever brings it, so it is a path this repo got wrong like any other. */
|
|
62
|
+
const isUninstalledDep = (root: string, rel: string): boolean => rel.startsWith('node_modules/') && declaresShipkit(root)
|
|
52
63
|
|
|
53
64
|
const readSettings = (root: string): Settings | null => {
|
|
54
65
|
const p = join(root, SETTINGS_PATH)
|
|
@@ -65,21 +76,32 @@ const guardHookCommand = (s: Settings): string | null => {
|
|
|
65
76
|
|
|
66
77
|
const hasGuardHook = (s: Settings): boolean => guardHookCommand(s) != null
|
|
67
78
|
|
|
68
|
-
/** the guard file the hook command points at, resolved against the repo root
|
|
79
|
+
/** the guard file the hook command points at, resolved against the repo root
|
|
80
|
+
* when it hangs off `$CLAUDE_PROJECT_DIR`, as written when it is absolute.
|
|
69
81
|
* A hook whose script does not exist errors on EVERY Bash call and refuses
|
|
70
82
|
* nothing, so the agent sees only the bare deny, which is the one failure
|
|
71
83
|
* this rule exists to prevent. Declaring it and never running it is worse
|
|
72
84
|
* than not declaring it, so it FAILs. */
|
|
73
85
|
function guardScriptMissing(root: string, command: string): string | null {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return existsSync(
|
|
86
|
+
const rel = /["']?\$(?:\{)?CLAUDE_PROJECT_DIR(?:\})?\/([^"'\s]+)["']?/.exec(command)?.[1]
|
|
87
|
+
if (rel != null) return existsSync(join(root, rel)) ? null : rel
|
|
88
|
+
const abs = /^bun\s+["']?(\/[^"'\s]+)["']?/.exec(command.trim())?.[1]
|
|
89
|
+
if (abs != null) return existsSync(abs) ? null : abs
|
|
90
|
+
return null // a form this rule cannot resolve is not a form it may condemn
|
|
78
91
|
}
|
|
79
92
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
/** the guard the fixer writes: the cli path the scripts run when a guard sits
|
|
94
|
+
* beside it (a repo's own `src/cli.ts` matches the same shape), else the
|
|
95
|
+
* repo's own dependency, else the copy of shipkit doing the fixing, which is
|
|
96
|
+
* the one the launcher falls back to in a repo that has none */
|
|
97
|
+
export function guardCommandFor(root: string): string {
|
|
98
|
+
const bin = inferBin(readPkg(root)?.scripts ?? {})
|
|
99
|
+
if (bin != null) {
|
|
100
|
+
const fromBin = guardCommand(bin)
|
|
101
|
+
if (guardScriptMissing(root, fromBin) == null) return fromBin
|
|
102
|
+
}
|
|
103
|
+
if (declaresShipkit(root)) return guardCommand()
|
|
104
|
+
return guardCommand(join(import.meta.dir, '..', 'cli.ts'))
|
|
83
105
|
}
|
|
84
106
|
|
|
85
107
|
export const oneWayInPolicy: Rule = {
|
|
@@ -115,7 +137,7 @@ export const oneWayInPolicy: Rule = {
|
|
|
115
137
|
// that fixes it, not a red gate on a repo nobody has installed yet,
|
|
116
138
|
// and the deny list is the floor throughout. A path this repo chose
|
|
117
139
|
// and got wrong is still a FAIL: it will never resolve on its own.
|
|
118
|
-
if (missing != null && isUninstalledDep(missing))
|
|
140
|
+
if (missing != null && isUninstalledDep(env.root, missing))
|
|
119
141
|
t.warn(
|
|
120
142
|
`${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which is not installed yet — until then a refusal names no replacement, and the deny list is the whole floor`,
|
|
121
143
|
`bun install (or bun add -d rvlib-shipkit)`,
|
|
@@ -149,10 +171,24 @@ export const oneWayInPolicy: Rule = {
|
|
|
149
171
|
s.permissions = { ...s.permissions, deny: [...deny, ...added] }
|
|
150
172
|
changes.push(`${added.length} raw runner(s) denied`)
|
|
151
173
|
}
|
|
174
|
+
const wantGuard = guardCommandFor(env.root)
|
|
152
175
|
if (!hasGuardHook(s)) {
|
|
153
176
|
const pre = s.hooks?.['PreToolUse'] ?? []
|
|
154
|
-
s.hooks = { ...s.hooks, PreToolUse: [...pre, { matcher: 'Bash', hooks: [{ type: 'command', command:
|
|
177
|
+
s.hooks = { ...s.hooks, PreToolUse: [...pre, { matcher: 'Bash', hooks: [{ type: 'command', command: wantGuard }] }] }
|
|
155
178
|
changes.push('PreToolUse guard wired (every refusal names the shipkit command)')
|
|
179
|
+
} else {
|
|
180
|
+
// a guard that cannot run errors on every Bash call, so it is repointed
|
|
181
|
+
// at the copy this repo runs. One that resolves, or waits on a declared
|
|
182
|
+
// install, is the repo's choice and stays as written
|
|
183
|
+
for (const m of s.hooks?.['PreToolUse'] ?? [])
|
|
184
|
+
for (const h of m.hooks ?? []) {
|
|
185
|
+
if (h.command?.includes('one-way-in-guard') !== true) continue
|
|
186
|
+
const missing = guardScriptMissing(env.root, h.command)
|
|
187
|
+
if (missing == null || isUninstalledDep(env.root, missing) || h.command === wantGuard) continue
|
|
188
|
+
if (guardScriptMissing(env.root, wantGuard) != null) continue
|
|
189
|
+
changes.push(`PreToolUse guard repointed from ${missing}, which does not exist`)
|
|
190
|
+
h.command = wantGuard
|
|
191
|
+
}
|
|
156
192
|
}
|
|
157
193
|
if (changes.length === 0) return { changed: false, summary: `${SETTINGS_PATH} already closes every raw runner` }
|
|
158
194
|
mkdirSync(dirname(p), { recursive: true })
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// SK015 nested repo (build/shipkit.md): a repo.config.ts inside a larger git
|
|
2
|
+
// work tree declares `nested: true`, and a work tree root never does. The call
|
|
3
|
+
// sites that write history refuse on the disk fact either way (core/nesting.ts),
|
|
4
|
+
// so this rule is about the declaration telling the truth to the next reader.
|
|
5
|
+
import { nestedIn } from '../core/nesting.ts'
|
|
6
|
+
import type { Rule } from './rule.ts'
|
|
7
|
+
|
|
8
|
+
export const nestedRepo: Rule = {
|
|
9
|
+
id: 'SK015',
|
|
10
|
+
blocking: true,
|
|
11
|
+
title: 'nested repo',
|
|
12
|
+
owner: 'build/shipkit.md',
|
|
13
|
+
never: 'reads where the git work tree root sits, which no content inside the repo describes',
|
|
14
|
+
lint: (t, env) => {
|
|
15
|
+
const top = nestedIn(env.root)
|
|
16
|
+
const declared = env.config.nested === true
|
|
17
|
+
if (top != null && !declared) {
|
|
18
|
+
t.fail(`repo.config.ts sits inside the git repo at ${top} and does not declare it`)
|
|
19
|
+
t.lines.push({ kind: 'fix-fail', text: 'add `nested: true` to repo.config.ts: shipkit then owns this folder only, and never writes the parent history' })
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
if (top == null && declared) {
|
|
23
|
+
t.fail('repo.config.ts declares `nested: true`, but this folder is its own git work tree root (or sits outside git)')
|
|
24
|
+
t.lines.push({ kind: 'fix-fail', text: 'delete `nested: true` from repo.config.ts' })
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
t.ok(top == null ? 'not nested, and not declared nested' : `nested in ${top}, declared`)
|
|
28
|
+
},
|
|
29
|
+
}
|
|
@@ -17,6 +17,7 @@ import { siblingUnitRanges } from './SK011-sibling-unit-ranges.ts'
|
|
|
17
17
|
import { configBarrelImport } from './SK012-config-barrel-import.ts'
|
|
18
18
|
import { oneWayInPolicy } from './SK013-one-way-in-policy.ts'
|
|
19
19
|
import { proseSte } from './SK014-prose-ste.ts'
|
|
20
|
+
import { nestedRepo } from './SK015-nested-repo.ts'
|
|
20
21
|
|
|
21
22
|
export const RULES: readonly Rule[] = [
|
|
22
23
|
packageJsonShape,
|
|
@@ -33,6 +34,7 @@ export const RULES: readonly Rule[] = [
|
|
|
33
34
|
configBarrelImport,
|
|
34
35
|
oneWayInPolicy,
|
|
35
36
|
proseSte,
|
|
37
|
+
nestedRepo,
|
|
36
38
|
]
|
|
37
39
|
|
|
38
40
|
// THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
|
package/src/rules/rule.ts
CHANGED
|
@@ -60,11 +60,24 @@ export type Rule = {
|
|
|
60
60
|
fix?: (env: RuleEnv) => FixResult | Promise<FixResult>
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** the config part of a rule's cache identity */
|
|
64
|
+
export function ruleCacheExtra(rule: Rule, env: RuleEnv): string | undefined {
|
|
65
|
+
const key = rule.cacheKey?.(env)
|
|
66
|
+
const exempt = env.config.ruleExempt?.[rule.id]
|
|
67
|
+
return exempt == null ? key : `${key ?? ''}|exempt:${exempt}`
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
// THE one way to run a rule's lint (check suite + the --fix runner): a
|
|
64
71
|
// throwing lint still fails with its id, a failing/warning run always
|
|
65
72
|
// carries at least one fix: pointer (the lint's own precise ones win, else
|
|
66
73
|
// docsBase + the rule's owner page), and every line gets tagged.
|
|
67
74
|
export async function runRuleLint(rule: Rule, t: CheckCtx, env: RuleEnv): Promise<void> {
|
|
75
|
+
const exempt = env.config.ruleExempt?.[rule.id]
|
|
76
|
+
if (exempt != null) {
|
|
77
|
+
t.lines.push({ kind: 'skip', text: `${rule.id} exempt: ${exempt}` })
|
|
78
|
+
t.lines = tagRuleLines(rule, t.lines)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
68
81
|
try {
|
|
69
82
|
await rule.lint(t, env)
|
|
70
83
|
} catch (e) {
|
package/src/rules/run-fix.ts
CHANGED
|
@@ -40,6 +40,12 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
|
40
40
|
for (const rule of targets) {
|
|
41
41
|
const before = new CheckCtx(new Map())
|
|
42
42
|
await runRuleLint(rule, before, env)
|
|
43
|
+
// an exempted rule never autofixes: the repo broke that invariant on purpose
|
|
44
|
+
const exempt = config.ruleExempt?.[rule.id]
|
|
45
|
+
if (exempt != null) {
|
|
46
|
+
if (o.quiet !== true) print(`skip: [${rule.id}] ${rule.id} exempt: ${exempt}`)
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
43
49
|
if (!before.failed && !before.warned) {
|
|
44
50
|
// inside the ci fix pass, a line per already-clean rule is a wall of
|
|
45
51
|
// nothing above the run that reports those same rules for real
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// the suite budget, as pure functions: which script is a plain `bun test`, how a
|
|
2
|
+
// run splits into a parallel and a serial phase, and which suites broke the
|
|
3
|
+
// budget. suite-cli.ts spawns, this module decides (meta/rv-conventions/test-speed.md).
|
|
4
|
+
import { isAbsolute, posix } from 'path'
|
|
5
|
+
|
|
6
|
+
export type SlowSuite = {
|
|
7
|
+
file: string
|
|
8
|
+
/** what this suite catches that no fast test can */
|
|
9
|
+
catches: string
|
|
10
|
+
/** why it cannot be made fast (smaller fixture, pure logic extracted) */
|
|
11
|
+
whyNotFast: string
|
|
12
|
+
}
|
|
13
|
+
export type SerialSuite = {
|
|
14
|
+
file: string
|
|
15
|
+
/** what breaks when it shares a run with other suites */
|
|
16
|
+
why: string
|
|
17
|
+
}
|
|
18
|
+
export type TestsConfig = {
|
|
19
|
+
/** per suite wall time in the quick tier (default 2000). A suite over it under the parallel run is timed again alone, and only that timing fails it. The full tier fails nothing on time */
|
|
20
|
+
budgetMs?: number
|
|
21
|
+
/** suites that break the budget on purpose: the full tier runs them, the quick tier does not */
|
|
22
|
+
slow?: SlowSuite[]
|
|
23
|
+
/** suites that cannot share a run: they run one at a time after the parallel phase */
|
|
24
|
+
serial?: SerialSuite[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_BUDGET_MS = 2000
|
|
28
|
+
const TESTS_KEYS = ['budgetMs', 'slow', 'serial']
|
|
29
|
+
const SLOW_KEYS = ['file', 'catches', 'whyNotFast']
|
|
30
|
+
const SERIAL_KEYS = ['file', 'why']
|
|
31
|
+
|
|
32
|
+
export const normSuitePath = (file: string): string => posix.normalize(file.replace(/\\/g, '/')).replace(/^\.\//, '')
|
|
33
|
+
|
|
34
|
+
/** the arguments after `bun test`, or null when a shell would interpret the script
|
|
35
|
+
* (cd, env prefix, pipes, quotes, substitution): that body stays the repo's own */
|
|
36
|
+
export function parseBunTestScript(script: string): string[] | null {
|
|
37
|
+
const s = script.trim()
|
|
38
|
+
if (!/^bun test(\s|$)/.test(s)) return null
|
|
39
|
+
// `=` stays allowed: after `bun test` it only ever means `--flag=value`
|
|
40
|
+
if (/[&|;<>$`'"\\(){}*?!#~]/.test(s.slice('bun test'.length))) return null
|
|
41
|
+
return s.split(/\s+/).slice(2)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** the flags the floor hands suite-cli.ts. Only file names travel: a reason edit
|
|
45
|
+
* must not move the task's cache key */
|
|
46
|
+
export function suiteFlags(t: TestsConfig | undefined): string[] {
|
|
47
|
+
if (t == null) return []
|
|
48
|
+
return [
|
|
49
|
+
...(t.budgetMs != null ? ['--budget-ms', String(t.budgetMs)] : []),
|
|
50
|
+
...(t.slow ?? []).flatMap((s) => ['--slow', s.file]),
|
|
51
|
+
...(t.serial ?? []).flatMap((s) => ['--serial', s.file]),
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** the inverse of suiteFlags, plus where the declarations live (printed in
|
|
56
|
+
* every fix line) and the `bun test` arguments after `--` */
|
|
57
|
+
export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs: string[]; declaredIn: string } | string {
|
|
58
|
+
const tests: Required<TestsConfig> = { budgetMs: DEFAULT_BUDGET_MS, slow: [], serial: [] }
|
|
59
|
+
let declaredIn = 'repo.config.ts tests'
|
|
60
|
+
let i = 0
|
|
61
|
+
for (; i < argv.length && argv[i] !== '--'; i++) {
|
|
62
|
+
const flag = argv[i]!
|
|
63
|
+
const value = argv[i + 1]
|
|
64
|
+
if (value == null || value === '--') return `${flag} needs a value`
|
|
65
|
+
i++
|
|
66
|
+
if (flag === '--budget-ms') tests.budgetMs = Number(value)
|
|
67
|
+
else if (flag === '--slow') tests.slow.push({ file: value, catches: '(declared)', whyNotFast: '(declared)' })
|
|
68
|
+
else if (flag === '--serial') tests.serial.push({ file: value, why: '(declared)' })
|
|
69
|
+
else if (flag === '--declared-in') declaredIn = value
|
|
70
|
+
else return `unknown flag ${flag}`
|
|
71
|
+
}
|
|
72
|
+
if (!(tests.budgetMs > 0)) return '--budget-ms must be a positive number'
|
|
73
|
+
return { tests, rawArgs: argv.slice(i + 1), declaredIn }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** what `bun test` receives in each phase. The serial phase names its files
|
|
77
|
+
* explicitly, because bun reports no tests when a path is both named and ignored */
|
|
78
|
+
export function planPhases(p: { tests: TestsConfig; tier: 'quick' | 'full'; rawArgs: string[]; parallel: boolean }): { parallel: string[]; serial: string[] | null } {
|
|
79
|
+
const slow = (p.tests.slow ?? []).map((s) => normSuitePath(s.file))
|
|
80
|
+
const serial = (p.tests.serial ?? []).map((s) => normSuitePath(s.file))
|
|
81
|
+
const ignored = [...new Set([...(p.tier === 'quick' ? slow : []), ...serial])]
|
|
82
|
+
const serialRun = serial.filter((f) => p.tier === 'full' || !slow.includes(f))
|
|
83
|
+
return {
|
|
84
|
+
parallel: [...p.rawArgs, ...(p.parallel ? ['--parallel'] : []), ...ignored.map((f) => `--path-ignore-patterns=${f}`)],
|
|
85
|
+
serial: serialRun.length > 0 ? [...p.rawArgs, ...serialRun] : null,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type Timed = { file: string; ms: number }
|
|
90
|
+
|
|
91
|
+
export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full' }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
|
|
92
|
+
const budget = p.tests.budgetMs ?? DEFAULT_BUDGET_MS
|
|
93
|
+
const slow = new Set((p.tests.slow ?? []).map((s) => normSuitePath(s.file)))
|
|
94
|
+
const timed = Object.entries(p.timings).map(([file, ms]) => ({ file: normSuitePath(file), ms })).sort((a, b) => b.ms - a.ms)
|
|
95
|
+
return {
|
|
96
|
+
// the budget guards the tier every edit waits on. The full tier is
|
|
97
|
+
// where slow suites belong, and it only reports
|
|
98
|
+
over: p.tier === 'quick' ? timed.filter((t) => t.ms > budget && !slow.has(t.file)) : [],
|
|
99
|
+
// half the budget, so a declaration does not flap on timing noise
|
|
100
|
+
noLongerSlow: timed.filter((t) => slow.has(t.file) && t.ms < budget / 2),
|
|
101
|
+
slowest: timed.slice(0, 3),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** what stays over budget once each suspect ran alone: a parallel timing
|
|
106
|
+
* carries the load of every other suite and every other gate on the machine,
|
|
107
|
+
* so only the alone timing can fail a suite */
|
|
108
|
+
export function confirmOver(over: Timed[], alone: Record<string, number>, budgetMs: number): Timed[] {
|
|
109
|
+
const byFile = new Map(Object.entries(alone).map(([f, ms]) => [normSuitePath(f), ms]))
|
|
110
|
+
return over.map((t) => ({ file: t.file, ms: byFile.get(t.file) ?? t.ms })).filter((t) => t.ms > budgetMs)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** the suites bun names as a crashed parallel worker */
|
|
114
|
+
export function crashedWorkers(output: string): { file: string; signal: string }[] {
|
|
115
|
+
return [...output.matchAll(/^\s*✗\s+(\S+)\s+\(worker crashed: (SIG[A-Z0-9]+)\)\s*$/gm)].map((m) => ({ file: normSuitePath(m[1]!), signal: m[2]! }))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function testsConfigProblems(t: TestsConfig): string[] {
|
|
119
|
+
const problems: string[] = []
|
|
120
|
+
for (const k of Object.keys(t)) if (!TESTS_KEYS.includes(k)) problems.push(`tests.${k} is not a tests field (fields: ${TESTS_KEYS.join(', ')})`)
|
|
121
|
+
if (t.budgetMs != null && !(typeof t.budgetMs === 'number' && t.budgetMs > 0)) problems.push('tests.budgetMs must be a positive number of milliseconds')
|
|
122
|
+
const lists: [string, { file: string }[], string[], [string, string][]][] = [
|
|
123
|
+
['slow', t.slow ?? [], SLOW_KEYS, [['catches', 'what it catches that no fast test can'], ['whyNotFast', 'why it cannot be made fast']]],
|
|
124
|
+
['serial', t.serial ?? [], SERIAL_KEYS, [['why', 'what breaks when it shares a run']]],
|
|
125
|
+
]
|
|
126
|
+
for (const [name, entries, keys, reasons] of lists) {
|
|
127
|
+
const seen = new Set<string>()
|
|
128
|
+
for (const e of entries) {
|
|
129
|
+
const file = typeof e.file === 'string' ? e.file.trim() : ''
|
|
130
|
+
if (file === '') { problems.push(`tests.${name} has an entry with no file`); continue }
|
|
131
|
+
const rel = normSuitePath(file)
|
|
132
|
+
if (isAbsolute(file) || rel === '..' || rel.startsWith('../')) problems.push(`tests.${name} ${file} must be a path inside the repo`)
|
|
133
|
+
if (seen.has(rel)) problems.push(`tests.${name} names ${rel} twice`)
|
|
134
|
+
seen.add(rel)
|
|
135
|
+
for (const k of Object.keys(e)) if (!keys.includes(k)) problems.push(`tests.${name} ${rel}: ${k} is not a field (fields: ${keys.join(', ')})`)
|
|
136
|
+
for (const [k, says] of reasons) {
|
|
137
|
+
const v = (e as Record<string, unknown>)[k]
|
|
138
|
+
if (typeof v !== 'string' || v.trim() === '') problems.push(`tests.${name} ${rel} needs ${k}: ${says}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return problems
|
|
143
|
+
}
|