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.
@@ -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 mandated reviewer agent type. A repo that declares
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 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
+ 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 mandated reviewer type is unavailable, so every review runs on whatever framing the spawner improvised`)
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. `$CLAUDE_PROJECT_DIR` because a hook's cwd
40
- * is not promised to be the repo root */
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
- /** a guard under `node_modules/` that is not there yet says the dependency is
49
- * not installed, which `bun install` fixes and no edit to this repo can. Any
50
- * other unresolvable path is a config this repo chose and got wrong. */
51
- const isUninstalledDep = (rel: string): boolean => rel.startsWith('node_modules/')
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 m = /["']?\$(?:\{)?CLAUDE_PROJECT_DIR(?:\})?\/([^"'\s]+)["']?/.exec(command)
75
- const rel = m?.[1]
76
- if (rel == null) return null // a form this rule cannot resolve is not a form it may condemn
77
- return existsSync(join(root, rel)) ? null : rel
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
- const readBin = (root: string): string | undefined => {
81
- try { return inferBin((JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {}) }
82
- catch { return undefined }
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: guardCommand(readBin(env.root)) }] }] }
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) {
@@ -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
+ }