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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// the floor `test` slot for a plain `bun test` script: quick tier suites in
|
|
3
|
+
// parallel workers, the full tier one at a time, the declared serial ones after,
|
|
4
|
+
// and each quick tier suite timed against the budget.
|
|
5
|
+
// The LAST stdout line is the task headline the gate prints, and it keeps bun's
|
|
6
|
+
// `Ran N tests` so the gate can still report what ran.
|
|
7
|
+
//
|
|
8
|
+
// usage: suite-cli [--budget-ms N] [--slow file]... [--serial file]... [--declared-in <where>] -- <bun test args>
|
|
9
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
|
|
10
|
+
import { tmpdir } from 'os'
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
import { FULL_TESTS_ENV } from '../ledger/gate-units.ts'
|
|
13
|
+
import { confirmOver, crashedWorkers, DEFAULT_BUDGET_MS, judgeSuites, normSuitePath, parseSuiteFlags, planPhases } from './judge-suites.ts'
|
|
14
|
+
|
|
15
|
+
type Phase = { code: number; signal: string | null; out: string; timings: Record<string, number> }
|
|
16
|
+
|
|
17
|
+
async function runPhase(args: string[], timingsFile: string | null, echo = true): Promise<Phase> {
|
|
18
|
+
const timing = timingsFile != null ? [`--timings=${timingsFile}`, '--update-timings'] : []
|
|
19
|
+
const proc = Bun.spawn(['bun', 'test', ...args, ...timing], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' })
|
|
20
|
+
let out = ''
|
|
21
|
+
const pump = async (stream: ReadableStream<Uint8Array>, sink: NodeJS.WriteStream): Promise<void> => {
|
|
22
|
+
const dec = new TextDecoder()
|
|
23
|
+
for await (const chunk of stream) {
|
|
24
|
+
const text = dec.decode(chunk)
|
|
25
|
+
out += text
|
|
26
|
+
if (echo) sink.write(text)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
await Promise.all([pump(proc.stdout, process.stdout), pump(proc.stderr, process.stderr)])
|
|
30
|
+
const code = await proc.exited
|
|
31
|
+
let timings: Record<string, number> = {}
|
|
32
|
+
try { if (timingsFile != null && existsSync(timingsFile)) timings = (JSON.parse(readFileSync(timingsFile, 'utf8')) as { files?: Record<string, number> }).files ?? {} }
|
|
33
|
+
catch { timings = {} }
|
|
34
|
+
return { code, signal: proc.signalCode ?? null, out, timings }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
|
|
38
|
+
const lastCount = (out: string, re: RegExp): number => Number([...out.matchAll(re)].at(-1)?.[1] ?? 0)
|
|
39
|
+
|
|
40
|
+
export async function suiteCli(argv: string[]): Promise<number> {
|
|
41
|
+
const parsed = parseSuiteFlags(argv)
|
|
42
|
+
if (typeof parsed === 'string') { console.log(`FAIL: suite runner: ${parsed}`); return 2 }
|
|
43
|
+
const { tests, rawArgs, declaredIn } = parsed
|
|
44
|
+
const tier = process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick'
|
|
45
|
+
const budget = tests.budgetMs ?? DEFAULT_BUDGET_MS
|
|
46
|
+
|
|
47
|
+
// a declaration naming a gone suite would silently stop excluding anything
|
|
48
|
+
const gone = [...(tests.slow ?? []).map((s) => ['slow', s.file]), ...(tests.serial ?? []).map((s) => ['serial', s.file])]
|
|
49
|
+
.filter(([, f]) => !existsSync(f!))
|
|
50
|
+
if (gone.length > 0) {
|
|
51
|
+
for (const [list, f] of gone) console.log(`FAIL: ${declaredIn}.${list} names ${normSuitePath(f!)}, which does not exist`)
|
|
52
|
+
return 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const help = Bun.spawnSync(['bun', 'test', '--help'], { stdout: 'pipe', stderr: 'pipe' })
|
|
56
|
+
const helpText = `${help.stdout.toString()}${help.stderr.toString()}`
|
|
57
|
+
// the full tier runs one suite at a time: its suites spawn git and whole
|
|
58
|
+
// gates, and sharing a run measured slower than sequential and timed out
|
|
59
|
+
const parallel = helpText.includes('--parallel') && tier === 'quick'
|
|
60
|
+
const timed = helpText.includes('--update-timings')
|
|
61
|
+
const plan = planPhases({ tests, tier, rawArgs, parallel })
|
|
62
|
+
const dir = mkdtempSync(join(tmpdir(), 'shipkit-suite-'))
|
|
63
|
+
const notes: string[] = []
|
|
64
|
+
if (!helpText.includes('--parallel')) notes.push(`skip: parallel suites (bun ${Bun.version} has no --parallel)`)
|
|
65
|
+
if (!timed) notes.push(`skip: suite budget (bun ${Bun.version} writes no --timings)`)
|
|
66
|
+
try {
|
|
67
|
+
const p1 = await runPhase(plan.parallel, timed ? join(dir, 'parallel.json') : null)
|
|
68
|
+
const p2 = plan.serial != null ? await runPhase(plan.serial, timed ? join(dir, 'serial.json') : null) : null
|
|
69
|
+
const phases = [p1, ...(p2 != null ? [p2] : [])]
|
|
70
|
+
const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
|
|
71
|
+
const failedTests = phases.reduce((n, p) => n + lastCount(p.out, /^\s*(\d+) fail$/gm), 0)
|
|
72
|
+
const timings = Object.assign({}, ...phases.map((p) => p.timings)) as Record<string, number>
|
|
73
|
+
const judged = judgeSuites({ timings, tests, tier })
|
|
74
|
+
const slowest = judged.slowest.length > 0 ? ` (slowest: ${judged.slowest.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')})` : ''
|
|
75
|
+
|
|
76
|
+
// a parallel timing carries every other suite's load, so a suspect is
|
|
77
|
+
// timed again alone, one process, before it can fail the task
|
|
78
|
+
let over = judged.over
|
|
79
|
+
if (over.length > 0) {
|
|
80
|
+
const alone = await runPhase([...rawArgs, ...over.map((t) => t.file)], join(dir, 'alone.json'), false)
|
|
81
|
+
over = confirmOver(over, alone.timings, budget)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const killed = crashedWorkers(p1.out).map((c) => `killed by ${c.signal} while running ${c.file} (out of memory, or killed from outside)`)
|
|
85
|
+
if (p1.signal != null) killed.push(`killed by ${p1.signal} during the parallel run (out of memory, or killed from outside)`)
|
|
86
|
+
if (p2?.signal != null) {
|
|
87
|
+
const unfinished = (plan.serial ?? []).filter((a) => !a.startsWith('-') && !rawArgs.includes(a) && p2.timings[normSuitePath(a)] == null)
|
|
88
|
+
killed.push(`killed by ${p2.signal} while running ${unfinished.join(' or ')} (out of memory, or killed from outside)`)
|
|
89
|
+
}
|
|
90
|
+
// never blocking: a suite that got faster must not stop a release
|
|
91
|
+
const stale = judged.noLongerSlow.map((t) => `WARN ${declaredIn}.slow declares ${t.file}, which ran in ${secs(t.ms)}, under half the ${budget}ms budget: remove the declaration`)
|
|
92
|
+
|
|
93
|
+
for (const n of notes) console.log(n)
|
|
94
|
+
for (const s of stale) console.log(s)
|
|
95
|
+
const fails: string[] = [
|
|
96
|
+
...killed.map((k) => `FAIL: ${k}`),
|
|
97
|
+
...(failedTests > 0 || (phases.some((p) => p.code !== 0 && p.signal == null) && killed.length === 0)
|
|
98
|
+
? [`FAIL: ${failedTests} test(s) failed, Ran ${ran} tests${slowest}`] : []),
|
|
99
|
+
...(over.length > 0
|
|
100
|
+
? [`FAIL: ${over.length} suite(s) over the ${budget}ms budget: ${over.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')} (timed alone). Make each fast, or declare it in ${declaredIn}.slow with what it catches and why it cannot be fast`]
|
|
101
|
+
: []),
|
|
102
|
+
]
|
|
103
|
+
// the most actionable failure prints LAST, where the gate reads its headline
|
|
104
|
+
for (const f of fails.slice(1).reverse()) console.log(f)
|
|
105
|
+
if (fails.length > 0) { console.log(fails[0]); return 1 }
|
|
106
|
+
const serialCount = plan.serial != null ? plan.serial.length - rawArgs.length : 0
|
|
107
|
+
console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites, ${parallel ? 'parallel' : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier${slowest}`)
|
|
108
|
+
return 0
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(dir, { recursive: true, force: true })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (import.meta.main) process.exit(await suiteCli(process.argv.slice(2)))
|