rvlib-shipkit 0.37.1 → 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 +10 -0
- package/package.json +1 -1
- package/src/cli/cmd_Rules.ts +14 -9
- package/src/core/floor.ts +20 -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/hooks/run-hooks.ts +1 -1
- package/src/ledger/gate-units.ts +2 -1
- 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 +34 -1
- package/src/rules/SK007-github-visibility.ts +4 -0
- package/src/rules/SK009-commit-ai-tells.ts +4 -0
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 0.38.0
|
|
2
|
+
|
|
3
|
+
- **A plain `bun test` floor runs its suites in parallel and times each one against a budget.** The floor `test` slot used to spawn `bun run test:raw`, which is one bun process running every suite on one core. Measured on a field repo: 71 suites in 74s, then a SIGKILL reported as `exited with code 1`, against 19.5s for the same suites in parallel workers. When the body is a literal `bun test …` (no cd, env prefix, pipe, quote or substitution, `--flag=value` allowed), the slot now runs `src/suite/suite-cli.ts`: every suite in `--parallel` workers, then the suites declared `serial` one at a time, with bun's own per-suite `--timings` read back. In the quick tier, the one every edit waits on, a suite over `repo.config.ts tests.budgetMs` (default 2000ms) runs again alone, and FAILs the task only when that alone timing is still over and it is not declared in `tests.slow` with `catches` (what no fast test catches) and `whyNotFast`. The quick tier leaves declared slow suites out, the full tier runs them, and a declared suite that runs under half the budget prints a WARN to drop the declaration without blocking. The full tier fails nothing on time and reports its three slowest suites. The full tier also runs one suite at a time: its suites spawn git and whole gates, and in parallel they measured slower than sequential and timed out. A killed suite prints `killed by SIGKILL while running <suite>`, from a crashed parallel worker or from the serial phase. The headline keeps `Ran N tests`. Any other script body runs as written, and the gate prints `skip: suite runner (…)` saying it is neither parallel nor timed. `defineRepo` refuses a declaration with a blank reason, a path outside the repo, a duplicate, or an unknown field, on the repo and on each `units[]` entry (paths relative to the unit), and the runner FAILs one naming a suite that does not exist.
|
|
4
|
+
|
|
5
|
+
- **A repo task with an unknown field now fails at config load.** `defineRepo` refused a typo inside a `defaults` slot but not inside a `tasks` entry, and `repo.config.ts` usually sits outside every tsconfig, so no type error catches it either. A field repo declared `reads: ['.']` on its `fmt` and `build` tasks, where the field is `inputs`: both loaded as undeclared, and the 10s build re-ran on every gate over an unchanged tree. The error names the task and lists the valid fields.
|
|
6
|
+
|
|
7
|
+
- **`ruleExempt`: a repo exempts one rule by id, with the reason.** `repo.config.ts ruleExempt: { SK003: '<why>' }`. The exempted rule does not lint and never autofixes. Every surface that runs it prints `skip: [SK003] SK003 exempt: <why>`, and `shipkit rules` marks it `EXEMPT here`. `defineRepo` throws on an id no built-in or repo rule carries, and on a blank reason. The reason is part of the rule's cache identity, so declaring or lifting an exemption never replays an old answer. It changes nothing a rule enforces anywhere else.
|
|
8
|
+
|
|
9
|
+
- **A repo can be one folder of a larger git work tree: `nested: true`, rule SK015.** A `repo.config.ts` inside another repository used to treat the parent's history as its own. The contract folder repair renamed a tracked folder and landed a machine commit on the parent's branch, `machineCommit` committed wherever its anchor resolved, `bump` and `release` would tag and push the parent, SK007 judged the parent's github remote, SK009 scanned the parent's whole log, and the ledger keyed a declared input like `src` on the PARENT's `src`, so an edit inside the folder never moved its key. On the disk fact alone, declared or not (the fix pass runs before any rule): the repair leaves a tracked folder alone, `machineCommit` refuses an anchor that belongs to a nested config, `bump` and `release` refuse and name the parent, SK007 and SK009 skip and name it, the hooks skip with the nesting named, and the scan keys every declared path, the whole tree included, on the folder's own subtree. SK015 FAILs a nested folder that does not declare `nested: true`, and a work tree root that does. The generated-file untrack pass was already inert there, because it needs a `.git` in the folder, and a test now pins that.
|
|
10
|
+
|
|
1
11
|
## 0.37.1
|
|
2
12
|
|
|
3
13
|
- **SK013 no longer wires a Bash guard that cannot run.** A consumer that runs shipkit from a checkout by ABSOLUTE path, with no `rvlib-shipkit` dependency and no `node_modules`, carried `bun "$CLAUDE_PROJECT_DIR/node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts"`, so every Bash call of every agent in that repo printed `Module not found`, while the rule only WARNed `not installed yet, bun install` and the gate stayed green. `bun install` installs nothing in a repo that declares no dependency, so that WARN now fires only when `package.json` declares `rvlib-shipkit`, and otherwise the missing guard FAILs. `guardCommand` glued an absolute cli path under the project dir (`$CLAUDE_PROJECT_DIR//Users/...`), and it now writes the absolute path alone. The lint resolves an absolute guard path too, where it used to pass any form it could not read. The fixer, which `shipkit ci` runs by default, repoints a guard whose script does not exist at the copy the repo actually runs: the cli path its scripts name when a guard sits beside it (a repo's own `src/cli.ts` matches the same script shape), else its declared dependency, else the shipkit doing the fixing, which is what the launcher falls back to. A guard that resolves, or waits on a declared install, stays as written, and a target that does not resolve itself is never written.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"funding": "https://github.com/sponsors/rvion",
|
|
5
5
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/cmd_Rules.ts
CHANGED
|
@@ -5,7 +5,16 @@
|
|
|
5
5
|
// is a repo whose agents cannot know what binds them.
|
|
6
6
|
import { Command, Option } from 'clipanion'
|
|
7
7
|
import { RULES } from '../rules/rule-registry.ts'
|
|
8
|
-
import { loadRepoConfig } from '../repo-config.ts'
|
|
8
|
+
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
9
|
+
|
|
10
|
+
export type RuleRow = { id: string; title: string; owner: string; blocking: boolean; fixable: boolean; source: 'shipkit' | 'repo'; exempt?: string }
|
|
11
|
+
|
|
12
|
+
/** the catalog: built-in rules, then the repo's own */
|
|
13
|
+
export function ruleRows(config: RepoConfig | null): RuleRow[] {
|
|
14
|
+
const row = (source: RuleRow['source']) => (r: { id: string; title: string; owner: string; blocking: boolean; fix?: unknown }): RuleRow =>
|
|
15
|
+
({ id: r.id, title: r.title, owner: r.owner, blocking: r.blocking, fixable: r.fix != null, source, ...(config?.ruleExempt?.[r.id] == null ? {} : { exempt: config.ruleExempt[r.id] }) })
|
|
16
|
+
return [...RULES.map(row('shipkit')), ...(config?.rules ?? []).map(row('repo'))]
|
|
17
|
+
}
|
|
9
18
|
|
|
10
19
|
export class RulesCommand extends Command {
|
|
11
20
|
static override paths = [['rules']]
|
|
@@ -24,13 +33,9 @@ export class RulesCommand extends Command {
|
|
|
24
33
|
async execute(): Promise<number> {
|
|
25
34
|
// a repo without repo.config.ts still gets the built-in catalog: this
|
|
26
35
|
// command must answer "what rules exist" from anywhere
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const rows = [
|
|
31
|
-
...RULES.map((r) => ({ id: r.id, title: r.title, owner: r.owner, blocking: r.blocking, fixable: r.fix != null, source: 'shipkit' as const })),
|
|
32
|
-
...repoRules.map((r) => ({ id: r.id, title: r.title, owner: r.owner, blocking: r.blocking, fixable: r.fix != null, source: 'repo' as const })),
|
|
33
|
-
]
|
|
36
|
+
const config = await loadRepoConfig(process.cwd()).catch(() => null)
|
|
37
|
+
const repoRules = config?.rules ?? []
|
|
38
|
+
const rows = ruleRows(config)
|
|
34
39
|
if (this.json) {
|
|
35
40
|
this.context.stdout.write(`${JSON.stringify(rows)}\n`)
|
|
36
41
|
return 0
|
|
@@ -38,7 +43,7 @@ export class RulesCommand extends Command {
|
|
|
38
43
|
// a catalog, not a health run — no wire prefixes (`ok:` would assert
|
|
39
44
|
// a passing check into anything that parses wire output)
|
|
40
45
|
for (const r of rows)
|
|
41
|
-
this.context.stdout.write(`${r.blocking ? 'BLOCKS ' : 'warns '}${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}\n`)
|
|
46
|
+
this.context.stdout.write(`${r.blocking ? 'BLOCKS ' : 'warns '}${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}${r.exempt == null ? '' : ` · EXEMPT here: ${r.exempt}`}\n`)
|
|
42
47
|
if (repoRules.length > 0)
|
|
43
48
|
this.context.stdout.write(`\n${RULES.length} built-in · ${repoRules.length} repo-local (repo.config.ts rules)\n`)
|
|
44
49
|
return 0
|
package/src/core/floor.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { join } from 'path'
|
|
|
10
10
|
import { fileURLToPath } from 'url'
|
|
11
11
|
import { rawName, routesToShipkit } from './canonical-scripts.ts'
|
|
12
12
|
import { resolveTypecheckProjects } from '../ts/tsconfig-projects.ts'
|
|
13
|
+
import { parseBunTestScript, suiteFlags } from '../suite/judge-suites.ts'
|
|
13
14
|
import type { TaskDef } from './task-runner.ts'
|
|
14
15
|
import type { RepoConfig } from '../repo-config.ts'
|
|
15
16
|
|
|
@@ -24,6 +25,21 @@ export type FloorResolution = {
|
|
|
24
25
|
unfilled: { id: string; reason: string }[]
|
|
25
26
|
/** where each registered slot came from (init prints this) */
|
|
26
27
|
sources: Record<string, FloorSource>
|
|
28
|
+
/** skip lines about how a slot runs, printed by the gate */
|
|
29
|
+
notes?: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** a package.json body for a slot. A plain `bun test` in the test slot runs
|
|
33
|
+
* through the suite runner (parallel, timed against the budget), anything
|
|
34
|
+
* else runs as written and the gate says what that costs */
|
|
35
|
+
function scriptCmd(c: RepoConfig, id: string, name: string, body: string, notes: string[]): string[] {
|
|
36
|
+
if (id !== 'test') return ['bun', 'run', name]
|
|
37
|
+
const args = parseBunTestScript(body)
|
|
38
|
+
if (args == null) {
|
|
39
|
+
notes.push(`skip: suite runner (script '${name}' is not a plain \`bun test\`, so its suites run as written: not in parallel, not timed against a budget)`)
|
|
40
|
+
return ['bun', 'run', name]
|
|
41
|
+
}
|
|
42
|
+
return ['bun', fileURLToPath(import.meta.resolve('rvlib-shipkit/src/suite/suite-cli.ts')), ...suiteFlags(c.tests), '--', ...args]
|
|
27
43
|
}
|
|
28
44
|
|
|
29
45
|
// zero-config floor (O6): with no script, imports:check/cycles resolve to
|
|
@@ -99,6 +115,7 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
|
|
|
99
115
|
const disabled: string[] = []
|
|
100
116
|
const unfilled: { id: string; reason: string }[] = []
|
|
101
117
|
const sources: Record<string, FloorSource> = {}
|
|
118
|
+
const notes: string[] = []
|
|
102
119
|
for (const id of FLOOR_IDS) {
|
|
103
120
|
const override = c.defaults?.[id]
|
|
104
121
|
if (override === false) { disabled.push(id); continue }
|
|
@@ -116,13 +133,13 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
|
|
|
116
133
|
// routed script here would spawn the gate from inside itself, forever
|
|
117
134
|
const decorate = (t: TaskDef): TaskDef => (override == null ? t : { ...t, ...override, id })
|
|
118
135
|
const raw = scripts[rawName(id)]
|
|
119
|
-
if (raw != null) { tasks.push(decorate({ id, cmd:
|
|
120
|
-
if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push(decorate({ id, cmd:
|
|
136
|
+
if (raw != null) { tasks.push(decorate({ id, cmd: scriptCmd(c, id, rawName(id), raw, notes) })); sources[id] = 'package.json script'; continue }
|
|
137
|
+
if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push(decorate({ id, cmd: scriptCmd(c, id, id, scripts[id]!, notes) })); sources[id] = 'package.json script'; continue }
|
|
121
138
|
const b = builtinFloorTask(id, root)
|
|
122
139
|
// a routed script with nothing behind it measures NOTHING, and the
|
|
123
140
|
// reason must say that rather than `no '<id>' script`, which is false
|
|
124
141
|
if (typeof b === 'string') unfilled.push({ id, reason: scripts[id] != null ? `script '${id}' routes to shipkit and there is no '${rawName(id)}' body to run` : b })
|
|
125
142
|
else { tasks.push(decorate(b.task)); sources[id] = b.source }
|
|
126
143
|
}
|
|
127
|
-
return { tasks, disabled, unfilled, sources }
|
|
144
|
+
return { tasks, disabled, unfilled, sources, notes }
|
|
128
145
|
}
|
package/src/core/locked-write.ts
CHANGED
|
@@ -146,7 +146,7 @@ export type LeaseOutcome<T> = { ok: true; value: T; waitedMs: number } | { ok: f
|
|
|
146
146
|
* that is gone or wedged, refused (never hung) past maxWaitMs. */
|
|
147
147
|
export async function withMutateLease<T>(
|
|
148
148
|
path: string,
|
|
149
|
-
o: { label: string; print?: (line: string) => void; maxWaitMs?: number; escape?: string },
|
|
149
|
+
o: { label: string; print?: (line: string) => void; maxWaitMs?: number; escape?: string; announceMs?: number },
|
|
150
150
|
fn: () => T | Promise<T>,
|
|
151
151
|
): Promise<LeaseOutcome<T>> {
|
|
152
152
|
const lockPath = `${path}.lock`
|
|
@@ -179,7 +179,7 @@ export async function withMutateLease<T>(
|
|
|
179
179
|
ok: false,
|
|
180
180
|
warn: `${o.label} lease held by ${holder == null ? 'an unreadable lock' : `pid ${holder.pid} (${holder.label})`} for ${Math.round(ageMs / 1000)}s — gave up after ${Math.round(waited / 1000)}s rather than run two of these at once. Lock: ${lockPath}${o.escape == null ? '' : `; skip this phase with ${o.escape}`}`,
|
|
181
181
|
}
|
|
182
|
-
if (waited >= LEASE_ANNOUNCE_MS && waited - announced >= LEASE_REANNOUNCE_MS) {
|
|
182
|
+
if (waited >= (o.announceMs ?? LEASE_ANNOUNCE_MS) && waited - announced >= LEASE_REANNOUNCE_MS) {
|
|
183
183
|
announced = waited
|
|
184
184
|
print(`ok: waiting ${Math.round(waited / 1000)}s for the ${o.label} lease — ${holder == null ? 'an unreadable lock holds it' : `pid ${holder.pid} holds it (${holder.label})`}, lock ${lockPath}`)
|
|
185
185
|
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// not worth a red gate. `SHIPKIT_NO_AUTOCOMMIT=1` turns it off.
|
|
19
19
|
import { existsSync } from 'fs'
|
|
20
20
|
import { join, resolve } from 'path'
|
|
21
|
+
import { nestedConfigRoot } from './nesting.ts'
|
|
21
22
|
|
|
22
23
|
const git = (cwd: string, args: string[]): { ok: boolean; out: string } => {
|
|
23
24
|
const r = Bun.spawnSync(['git', ...args], { cwd })
|
|
@@ -40,6 +41,8 @@ export function machineCommit(o: MachineCommit): boolean {
|
|
|
40
41
|
const top = git(resolve(o.anchor), ['rev-parse', '--show-toplevel'])
|
|
41
42
|
if (!top.ok) return false // not a git repo, nothing to land
|
|
42
43
|
const root = top.out
|
|
44
|
+
// an anchor inside a nested shipkit repo would land on the parent's branch
|
|
45
|
+
if (nestedConfigRoot(resolve(o.anchor), root) != null) return false
|
|
43
46
|
// a detached HEAD or a repo with no commit yet has nowhere sane to land
|
|
44
47
|
if (!git(root, ['symbolic-ref', '--quiet', 'HEAD']).ok) return false
|
|
45
48
|
if (!git(root, ['rev-parse', '--verify', 'HEAD']).ok) return false
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// a repo.config.ts can sit in ONE FOLDER of a larger git work tree (an
|
|
2
|
+
// extension folder inside a product monorepo). shipkit then owns that folder
|
|
3
|
+
// only: it never commits, untracks, tags or pushes on the parent's history, and
|
|
4
|
+
// every path it hands git names a path inside the folder. `nested: true` in
|
|
5
|
+
// repo.config.ts states it, rule SK015 fails when the declaration and the disk
|
|
6
|
+
// disagree, and the call sites below refuse on the disk fact alone, declared or
|
|
7
|
+
// not, because the fix pass runs before any rule can say so.
|
|
8
|
+
import { existsSync, realpathSync } from 'fs'
|
|
9
|
+
import { dirname, join } from 'path'
|
|
10
|
+
|
|
11
|
+
const gitLine = (cwd: string, args: string[]): string | null => {
|
|
12
|
+
try {
|
|
13
|
+
const p = Bun.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
14
|
+
return p.exitCode === 0 ? p.stdout.toString().trim() : null
|
|
15
|
+
} catch {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** the work tree root holding `root` when it is a PARENT of `root`. null when
|
|
21
|
+
* `root` is its own work tree root, or sits outside git */
|
|
22
|
+
export function nestedIn(root: string): string | null {
|
|
23
|
+
const top = gitLine(root, ['rev-parse', '--show-toplevel'])
|
|
24
|
+
if (top == null || top === '') return null
|
|
25
|
+
try {
|
|
26
|
+
return realpathSync(top) === realpathSync(root) ? null : top
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `root` relative to its work tree root, posix: '' at the root, else ending in '/' */
|
|
33
|
+
export function treePrefix(root: string): string {
|
|
34
|
+
return gitLine(root, ['rev-parse', '--show-prefix']) ?? ''
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** the nearest repo.config.ts at or above `dir` and strictly below the work
|
|
38
|
+
* tree root `top`: the nested shipkit repo `dir` belongs to, if any */
|
|
39
|
+
export function nestedConfigRoot(dir: string, top: string): string | null {
|
|
40
|
+
let cur: string
|
|
41
|
+
let stop: string
|
|
42
|
+
try {
|
|
43
|
+
cur = realpathSync(dir)
|
|
44
|
+
stop = realpathSync(top)
|
|
45
|
+
} catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
while (cur !== stop && cur.startsWith(`${stop}/`)) {
|
|
49
|
+
if (existsSync(join(cur, 'repo.config.ts'))) return cur
|
|
50
|
+
cur = dirname(cur)
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** the one refusal line every history-writing command prints */
|
|
56
|
+
export const nestedRefusal = (top: string): string =>
|
|
57
|
+
`this repo is one folder of the git repo at ${top}, and that history is not shipkit's to commit, tag or push`
|
|
@@ -22,6 +22,7 @@ import { readdirSync, renameSync, rmdirSync } from 'fs'
|
|
|
22
22
|
import { join } from 'path'
|
|
23
23
|
import { POLICY_LINES } from '../rules/SK004-gitignore-policy.ts'
|
|
24
24
|
import { machineCommit } from './machine-commit.ts'
|
|
25
|
+
import { nestedIn } from './nesting.ts'
|
|
25
26
|
|
|
26
27
|
/** the always-ignored `.rv-*` contract folders, from the policy that owns them */
|
|
27
28
|
export const contractDirNames = (): string[] =>
|
|
@@ -97,6 +98,8 @@ export function repairContractDirs(root: string): DirRepairResult {
|
|
|
97
98
|
const renamed: DirRepair[] = []
|
|
98
99
|
const removed: string[] = []
|
|
99
100
|
const uncommitted: DirRepair[] = []
|
|
101
|
+
// a nested repo never untracks: the index and the commit are the parent's
|
|
102
|
+
const nested = plan.repairs.length === 0 ? null : nestedIn(root)
|
|
100
103
|
for (const r of plan.repairs) {
|
|
101
104
|
const from = join(root, r.from)
|
|
102
105
|
const to = join(root, r.to)
|
|
@@ -113,7 +116,7 @@ export function repairContractDirs(root: string): DirRepairResult {
|
|
|
113
116
|
// says those files are local. Untracked files rename freely: there
|
|
114
117
|
// is nothing to lose and nothing to commit
|
|
115
118
|
const tracked = isTracked(root, r.from)
|
|
116
|
-
if (tracked && !isIgnoredHere(root, r.to)) continue
|
|
119
|
+
if (tracked && (nested != null || !isIgnoredHere(root, r.to))) continue
|
|
117
120
|
renameSync(from, to)
|
|
118
121
|
renamed.push(r)
|
|
119
122
|
// a repair that half happened is not the silence that was asked
|
package/src/core/run-check.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
|
21
21
|
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
22
22
|
import { reportToMotherhome } from './motherhome.ts'
|
|
23
23
|
import { RULES } from '../rules/rule-registry.ts'
|
|
24
|
-
import { runRuleLint } from '../rules/rule.ts'
|
|
24
|
+
import { ruleCacheExtra, runRuleLint } from '../rules/rule.ts'
|
|
25
25
|
import { formatLine, parseWireOutput, type Line } from './wire.ts'
|
|
26
26
|
import { writeStatus } from './write-status.ts'
|
|
27
27
|
import { gateInventory } from '../ledger/gate-units.ts'
|
|
@@ -202,7 +202,7 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
202
202
|
blocking: rule.blocking,
|
|
203
203
|
name: rule.title,
|
|
204
204
|
remainValidFor: rule.ttl,
|
|
205
|
-
cacheExtra: () => worldKey(rule.id, rule
|
|
205
|
+
cacheExtra: () => worldKey(rule.id, ruleCacheExtra(rule, { root, config: c })),
|
|
206
206
|
logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
|
|
207
207
|
})
|
|
208
208
|
}
|
package/src/core/run-ci.ts
CHANGED
|
@@ -133,6 +133,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
133
133
|
: `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
|
|
134
134
|
for (const t of all.tasks) if (skipIds.has(t.id)) sink(`skip: ${t.id} (not part of this release, the unit's own gate covers it)`)
|
|
135
135
|
for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
|
|
136
|
+
for (const n of all.notes ?? []) sink(n)
|
|
136
137
|
// THE LEDGER, opened once per run and AFTER the fix pass: the fixers
|
|
137
138
|
// rewrite source, and a scan taken before them would have every unit
|
|
138
139
|
// discarded by its own gate's repair work.
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -70,7 +70,7 @@ export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
|
|
|
70
70
|
if (top.exitCode !== 0) return { state: 'no-git', detail: 'not a git repo' }
|
|
71
71
|
const topDir = top.stdout.toString().trim()
|
|
72
72
|
try {
|
|
73
|
-
if (realpathSync(topDir) !== realpathSync(root)) return { state: 'not-root', detail: `
|
|
73
|
+
if (realpathSync(topDir) !== realpathSync(root)) return { state: 'not-root', detail: `nested in ${topDir}, which owns its own commit gates` }
|
|
74
74
|
} catch {
|
|
75
75
|
return { state: 'no-git', detail: 'work tree root unresolvable' }
|
|
76
76
|
}
|
package/src/ledger/gate-units.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { allTasks, type RepoConfig } from '../repo-config.ts'
|
|
|
11
11
|
import type { TaskDef } from '../core/task-runner.ts'
|
|
12
12
|
import { discoverChecks } from '../core/discover-checks.ts'
|
|
13
13
|
import { RULES } from '../rules/rule-registry.ts'
|
|
14
|
+
import { ruleCacheExtra } from '../rules/rule.ts'
|
|
14
15
|
import type { UnitDecl } from './git-key.ts'
|
|
15
16
|
|
|
16
17
|
/** the env var a repo's own suite reads to widen itself. It lives HERE because
|
|
@@ -103,7 +104,7 @@ export function gateInventory(root: string, c: RepoConfig, tasks?: TaskDef[]): U
|
|
|
103
104
|
code: r.code,
|
|
104
105
|
ttl: r.ttl,
|
|
105
106
|
never: r.never,
|
|
106
|
-
extra: r
|
|
107
|
+
extra: ruleCacheExtra(r, { root, config: c }),
|
|
107
108
|
},
|
|
108
109
|
title: r.title,
|
|
109
110
|
gate: true,
|
package/src/ledger/git-scan.ts
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
// retried and only a NON lock failure is fatal.
|
|
32
32
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'fs'
|
|
33
33
|
import { isAbsolute, join, normalize } from 'path'
|
|
34
|
+
import { treePrefix } from '../core/nesting.ts'
|
|
34
35
|
|
|
35
36
|
/** what a scan measured. Every OID is a git object id of the CURRENT disk
|
|
36
37
|
* content, tracked or not. */
|
|
@@ -244,15 +245,20 @@ export function scanPaths(root: string, rawPaths: string[]): ScanResult {
|
|
|
244
245
|
|
|
245
246
|
const treeOid = gitOrThrow(root, ['write-tree'], indexFile).trim()
|
|
246
247
|
const oidByPath = new Map<string, string>()
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
248
|
+
// a declared path is relative to root, and `<tree>:<path>` reads from the
|
|
249
|
+
// work tree root. In a nested repo the two differ, so every query carries
|
|
250
|
+
// the folder's prefix, and the whole tree is the folder's own subtree
|
|
251
|
+
const prefix = treePrefix(root)
|
|
252
|
+
const underRoot = (p: string): string => (p === WHOLE_TREE ? prefix.replace(/\/$/, '') : `${prefix}${p}`)
|
|
253
|
+
// at a work tree root the whole tree IS the tree the adds just produced,
|
|
254
|
+
// there is no `<tree>:` path to ask git for
|
|
255
|
+
if (wanted.includes(WHOLE_TREE) && prefix === '') oidByPath.set(WHOLE_TREE, treeOid)
|
|
250
256
|
const forceSet = new Set(forceAdd)
|
|
251
|
-
const plain = named.filter((p) => !forceSet.has(p))
|
|
257
|
+
const plain = [...named.filter((p) => !forceSet.has(p)), ...(wanted.includes(WHOLE_TREE) && prefix !== '' ? [WHOLE_TREE] : [])]
|
|
252
258
|
for (const [tree, paths, index] of [[treeOid, plain, indexFile], [null, forceAdd, ignoredIndex]] as const) {
|
|
253
259
|
if (paths.length === 0) continue
|
|
254
260
|
const root_ = tree ?? gitOrThrow(root, ['write-tree'], index).trim()
|
|
255
|
-
const query = paths.map((p) => `${root_}:${p}`).join('\n')
|
|
261
|
+
const query = paths.map((p) => `${root_}:${underRoot(p)}`).join('\n')
|
|
256
262
|
const p = Bun.spawnSync(['git', 'cat-file', '--batch-check'], {
|
|
257
263
|
cwd: root,
|
|
258
264
|
stdin: Buffer.from(`${query}\n`),
|
package/src/release/run-bump.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { releaseScope } from './release-scope.ts'
|
|
|
36
36
|
import { formatUnitError, releaseOrder, renderTag, resolveUnits, unitById, unitPrepublishScripts, type ReleaseUnit } from './units.ts'
|
|
37
37
|
import { environmentBlockers, runReleaseGate, releaseUnit } from './run-release.ts'
|
|
38
38
|
import type { RepoConfig } from '../repo-config.ts'
|
|
39
|
+
import { nestedIn, nestedRefusal } from '../core/nesting.ts'
|
|
39
40
|
|
|
40
41
|
export type BumpKind = 'major' | 'minor' | 'patch'
|
|
41
42
|
|
|
@@ -218,6 +219,8 @@ type Selected = { unit: ReleaseUnit; version: string; changelogRel: string; kind
|
|
|
218
219
|
export async function runBump(root: string, config: RepoConfig, kind: BumpKind | null, o: BumpOpts = {}): Promise<number> {
|
|
219
220
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
220
221
|
if (o.all === true && o.unit != null) { print('FAIL: pass a unit or --all, never both'); return 1 }
|
|
222
|
+
const nested = nestedIn(root)
|
|
223
|
+
if (nested != null) { print(`FAIL: ${nestedRefusal(nested)}`); return 1 }
|
|
221
224
|
|
|
222
225
|
const git: Git = (args) => gitAt(root, args)
|
|
223
226
|
const cur = git(['rev-parse', '--abbrev-ref', 'HEAD']).out
|
|
@@ -15,6 +15,7 @@ import { releaseScope } from './release-scope.ts'
|
|
|
15
15
|
import { npmPublishArgv, unitBlockers } from './unit-gate.ts'
|
|
16
16
|
import { formatUnitError, resolveUnits, unitById, type ReleaseUnit } from './units.ts'
|
|
17
17
|
import type { RepoConfig } from '../repo-config.ts'
|
|
18
|
+
import { nestedIn, nestedRefusal } from '../core/nesting.ts'
|
|
18
19
|
|
|
19
20
|
// adding a FOURTH knob here is the signal that this shape wants rethinking
|
|
20
21
|
// rather than another key (meta/gaps.md, release-path config keys accreting)
|
|
@@ -135,6 +136,8 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
|
|
|
135
136
|
/** `shipkit release [unit]` — resolve the unit, then ship it. */
|
|
136
137
|
export async function runRelease(root: string, config: RepoConfig, o: ReleaseOpts = {}): Promise<number> {
|
|
137
138
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
139
|
+
const nested = nestedIn(root)
|
|
140
|
+
if (nested != null) { print(`FAIL: ${nestedRefusal(nested)}`); return 1 }
|
|
138
141
|
const { units, errors } = resolveUnits(root, config)
|
|
139
142
|
const id = o.unit ?? 'root'
|
|
140
143
|
// only THIS unit's own resolution has to hold: a broken sibling is not a
|
package/src/release/units.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// rediscover packages (drift probes, per-lib tests, per-lib typecheck, the
|
|
9
9
|
// stranded-tag probe) reads it instead of walking the tree with its own rules.
|
|
10
10
|
import { existsSync, readFileSync, statSync } from 'fs'
|
|
11
|
+
import type { TestsConfig } from '../suite/judge-suites.ts'
|
|
11
12
|
import { basename, join, relative } from 'path'
|
|
12
13
|
import type { RepoConfig } from '../repo-config.ts'
|
|
13
14
|
|
|
@@ -29,9 +30,11 @@ export type ReleaseUnitDecl = {
|
|
|
29
30
|
githubRelease?: boolean
|
|
30
31
|
/** repo-relative tsconfig for the unit's own typecheck (default '<dir>/tsconfig.json' when it exists) */
|
|
31
32
|
tsconfig?: string
|
|
33
|
+
/** the unit's own suite budget, paths relative to the unit dir (suite/judge-suites.ts) */
|
|
34
|
+
tests?: TestsConfig
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
export const UNIT_DECL_KEYS: readonly string[] = ['dir', 'id', 'tag', 'changelog', 'npm', 'githubRelease', 'tsconfig']
|
|
37
|
+
export const UNIT_DECL_KEYS: readonly string[] = ['dir', 'id', 'tag', 'changelog', 'npm', 'githubRelease', 'tsconfig', 'tests']
|
|
35
38
|
|
|
36
39
|
/** a unit, fully resolved: no optionals, no defaults left to re-apply downstream */
|
|
37
40
|
export type ReleaseUnit = {
|
package/src/repo-config.ts
CHANGED
|
@@ -27,6 +27,7 @@ import type { HooksConfig } from './hooks/run-hooks.ts'
|
|
|
27
27
|
import type { ReleaseConfig } from './release/run-release.ts'
|
|
28
28
|
import { UNIT_DECL_KEYS, type ReleaseUnitDecl } from './release/units.ts'
|
|
29
29
|
import { TASK_DEF_KEYS, type TaskDef } from './core/task-runner.ts'
|
|
30
|
+
import { testsConfigProblems, type TestsConfig } from './suite/judge-suites.ts'
|
|
30
31
|
import type { TuiPanelDef } from './tui/tui-panel.ts'
|
|
31
32
|
import type { MdGraph } from './wiki/md-graph.ts'
|
|
32
33
|
|
|
@@ -87,6 +88,14 @@ export type RepoConfig = {
|
|
|
87
88
|
* rides into unrelated commits. `false` opts a repo out entirely, and
|
|
88
89
|
* `--frozen` suppresses it for one run the way it suppresses SK005. */
|
|
89
90
|
untrackGenerated?: boolean
|
|
91
|
+
/** this repo is ONE FOLDER of a larger git work tree, and shipkit owns that
|
|
92
|
+
* folder only: it never commits, untracks, tags or pushes on the parent's
|
|
93
|
+
* history. Rule SK015 fails when this and the disk disagree (core/nesting.ts) */
|
|
94
|
+
nested?: boolean
|
|
95
|
+
/** rule id → the reason this repo breaks that invariant on purpose. An
|
|
96
|
+
* exempted rule does not lint and prints a skip line carrying the reason.
|
|
97
|
+
* `defineRepo` throws on an unknown id or a blank reason */
|
|
98
|
+
ruleExempt?: Record<string, string>
|
|
90
99
|
/** declared github visibility INTENT (absent = 'private') — rule SK007
|
|
91
100
|
* fails loud when `gh repo view` disagrees in either direction */
|
|
92
101
|
visibility?: 'private' | 'public'
|
|
@@ -148,6 +157,10 @@ export type RepoConfig = {
|
|
|
148
157
|
* visits after a deploy, and how it logs in. The password is a secret
|
|
149
158
|
* NAME: the value never sits in config, argv or env. smoke/run-smoke.ts */
|
|
150
159
|
smoke?: SmokeConfig
|
|
160
|
+
/** the suite budget for a plain `bun test` floor: per suite time limit, the
|
|
161
|
+
* slow suites the quick tier leaves out, and the suites that cannot run in
|
|
162
|
+
* parallel, each with its reason. suite/judge-suites.ts */
|
|
163
|
+
tests?: TestsConfig
|
|
151
164
|
/** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
|
|
152
165
|
tui?: { panels?: TuiPanelDef[] }
|
|
153
166
|
/** the `shipkit serve` hub: port + optional graph hooks */
|
|
@@ -155,7 +168,7 @@ export type RepoConfig = {
|
|
|
155
168
|
logDir?: string
|
|
156
169
|
}
|
|
157
170
|
|
|
158
|
-
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke'])
|
|
171
|
+
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke', 'tests'])
|
|
159
172
|
|
|
160
173
|
export function defineRepo(c: RepoConfig): RepoConfig {
|
|
161
174
|
if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
|
|
@@ -175,6 +188,8 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
175
188
|
if (typeof u.dir !== 'string' || u.dir === '') throw new Error(`defineRepo: every units[] entry needs a 'dir'`)
|
|
176
189
|
for (const k of Object.keys(u))
|
|
177
190
|
if (!UNIT_DECL_KEYS.includes(k)) throw new Error(`defineRepo: units['${u.dir}'] unknown key '${k}' (known: ${UNIT_DECL_KEYS.join(', ')})`)
|
|
191
|
+
const problems = u.tests != null ? testsConfigProblems(u.tests) : []
|
|
192
|
+
if (problems.length > 0) throw new Error(`defineRepo: units['${u.dir}'] ${problems[0]}`)
|
|
178
193
|
}
|
|
179
194
|
for (const d of c.notReleasable ?? []) {
|
|
180
195
|
if (typeof d !== 'string' || d === '') throw new Error(`defineRepo: every notReleasable entry is a repo-relative dir path`)
|
|
@@ -195,12 +210,30 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
195
210
|
if (!(TASK_DEF_KEYS as readonly string[]).includes(f))
|
|
196
211
|
throw new Error(`defineRepo: defaults.${k}.${f} is not a task field (fields: ${TASK_DEF_KEYS.join(', ')})`)
|
|
197
212
|
}
|
|
213
|
+
// same promise for a repo task: repo.config.ts is often outside every
|
|
214
|
+
// tsconfig, so `reads` for `inputs` is no type error and the task never caches
|
|
215
|
+
for (const t of c.tasks ?? [])
|
|
216
|
+
for (const f of Object.keys(t))
|
|
217
|
+
if (!(TASK_DEF_KEYS as readonly string[]).includes(f))
|
|
218
|
+
throw new Error(`defineRepo: tasks['${t.id}'].${f} is not a task field (fields: ${TASK_DEF_KEYS.join(', ')})`)
|
|
219
|
+
// a slow or serial suite carries its case, or the declaration does not load
|
|
220
|
+
if (c.tests != null) {
|
|
221
|
+
const problems = testsConfigProblems(c.tests)
|
|
222
|
+
if (problems.length > 0) throw new Error(`defineRepo: ${problems[0]}`)
|
|
223
|
+
}
|
|
198
224
|
for (const n of c.oneWayIn?.exempt ?? [])
|
|
199
225
|
if (!(ROUTED_SCRIPTS as readonly string[]).includes(n))
|
|
200
226
|
throw new Error(`defineRepo: oneWayIn.exempt '${n}' is not a governed script name (governed: ${ROUTED_SCRIPTS.join(', ')})`)
|
|
201
227
|
// repo rules are validated against the built-ins, so a bad id, a duplicate,
|
|
202
228
|
// or an SK squatter dies at config load, not silently mid-check
|
|
203
229
|
if (c.rules != null) validateRules(c.rules, { label: 'defineRepo: rules', taken: BUILTIN_RULE_IDS })
|
|
230
|
+
// an exemption silences a gate, so it names a rule that exists and says why
|
|
231
|
+
for (const [id, reason] of Object.entries(c.ruleExempt ?? {})) {
|
|
232
|
+
if (!BUILTIN_RULE_IDS.includes(id) && !(c.rules ?? []).some((r) => r.id === id))
|
|
233
|
+
throw new Error(`defineRepo: ruleExempt.${id} names no rule, the ids are in \`shipkit rules\``)
|
|
234
|
+
if (typeof reason !== 'string' || reason.trim() === '')
|
|
235
|
+
throw new Error(`defineRepo: ruleExempt.${id} needs a reason, an exemption nobody can explain is a silenced gate`)
|
|
236
|
+
}
|
|
204
237
|
// a smoke typo would land as a run that visits nothing and exits 0
|
|
205
238
|
if (c.smoke != null) validateSmokeConfig(c.smoke)
|
|
206
239
|
// a leak-guard waiver is the one config row that can make a gate quieter,
|
|
@@ -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}`}`)
|
|
@@ -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'`)
|
|
@@ -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
|
+
}
|
|
@@ -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)))
|