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
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
|
-
|
|
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
|
+
|
|
11
|
+
## 0.37.1
|
|
12
|
+
|
|
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.
|
|
14
|
+
|
|
15
|
+
- **The gate says what an undeclared floor costs, and declares nothing on a repo's behalf.** A slot with no `inputs`, no `ttl` and no `never` is measured every run, and `resolveFloor` declares none for any of the five, so a second gate over an UNCHANGED tree re-runs everything: 17.4s per gate measured on a field repo, 14.8s of it the test task, against the shape an agent actually runs (verify, commit, verify again). Nothing pointed at the repair either, since `shipkit init` scaffolded no `defaults` block, so a fresh repo stayed that way until somebody read `shipkit keys --burn-down` and knew what to write. `init` now scaffolds the commented `defaults` line naming the fix.
|
|
16
|
+
- **What this release deliberately does NOT do is auto-declare `['.']` for those slots**, and the reason is worth more than the speed. `['.']` is not the work tree, it is the TRACKED tree: `git-scan` force-adds only a path a declaration NAMES, so an ignored path enters a key by NAME and never by content. Every floor task reads ignored content in an ordinary repo, a suite through its fixtures and its local database, tsc and the two graph walks through generated types and `node_modules`. Measured on a scratch repo: warm the ledger with two green runs, break a gitignored file the suite reads, and the third gate prints `ok: test (replayed, 0.0s saved)` over a suite that exits 1. `tests/floor-cache-safety.test.ts` holds that repro, watched fail, with the declaring lookalike beside it still replaying. Speed here is bought by a REPO declaring what its own tasks read, which is the only declaration with a real hit rate anyway.
|
|
17
|
+
- Two doc sentences that argued the other way are corrected where they live: `TaskDef.inputs` no longer says `['.']` "cannot under declare", and the `git-key` header no longer promises the never-cached default is the whole safety story without naming who can declare on a unit's behalf.
|
|
18
|
+
|
|
19
|
+
- **SK004 asks git what is ignored, instead of trusting the text it wrote.** The rule compared the managed block to the expected string and stopped there, so it reported `current` over a repo where git ignored none of what the block claims. Two ways that happens, both from the field. A bare `printf 'work/\n' >> .gitignore` onto a file whose last line carried no terminator fuses the two into `sourcework/`, which ignores neither path, and the file ends with a newline afterwards so nothing textual looks wrong (cost in that repo: 1.1GB and 700 screenshots pushed across four commits, a rejected push, and a `git filter-repo` rewrite). And a negation below the block is the DOCUMENTED override, so nothing stopped one landing on `.rv-private/`, the directory holding the keyword list the leak guard itself reads. Every policy pattern is now probed through one `git check-ignore --stdin` call and a contradiction FAILs, naming the pattern and the `check-ignore -v` that reports the winning line. The three venue folders stay exempt, because unignoring one is how a live listing ships. A `.gitignore` that does not end with a newline is now a FAIL of its own, since that missing byte is the trap rather than a cosmetic defect, and `--fix` terminates the file even when the block is current. Outside a git repo the rule states the block went unverified rather than reporting a green nothing measured.
|
|
20
|
+
|
|
21
|
+
- **A check declaration the gate's own formatter wrote was unreadable to the gate.** `BLOCKING_RE` and `GATE_RE` both ended in `\s*$`, and oxfmt (which the fix pass RUNS) terminates every statement with a semicolon. So `shipkit ci` reformatted `export const blocking = false` into `export const blocking = false;`, then failed the file with `does not declare whether it blocks` and printed as the fix the exact line it had just written. Two repos in the field shipped `// oxfmt-ignore` workarounds to get a green gate. The worse half was silent: a `export const gate = false;` was not read either, so a world probe that had opted out of the gate quietly re-entered it, and a repo could declare fifteen off-gate probes and watch all fifteen run. Both readers now accept an optional `as const` and an optional semicolon through one shared tail. `INPUTS_RE`, `TTL_RE` and `NEVER_RE` never anchored the line end, so they were never affected. Pinned by three tests: the formatted shapes, the gate opt-out in both spellings, and a CONTROL that the unterminated shapes and a silent file still read as before.
|
|
2
22
|
|
|
3
23
|
## 0.37.0
|
|
4
24
|
|
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
|
|
@@ -42,7 +42,11 @@ export type DiscoveredCheck = {
|
|
|
42
42
|
|
|
43
43
|
// read without importing: checks run as subprocesses, and discovery must stay
|
|
44
44
|
// pure-fs. `export const blocking = true` (or false), nothing cleverer.
|
|
45
|
-
|
|
45
|
+
// the tail is the gate's OWN formatter: oxfmt terminates every statement, and
|
|
46
|
+
// the fix pass runs it, so a reader that refuses a semicolon refuses the shape
|
|
47
|
+
// the gate itself just wrote.
|
|
48
|
+
const TAIL = String.raw`(?:\s+as\s+const)?\s*;?\s*$`
|
|
49
|
+
const BLOCKING_RE = new RegExp(String.raw`^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)` + TAIL, 'm')
|
|
46
50
|
// `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
|
|
47
51
|
// (a computed list, a spread) is deliberately unreadable here and lands as no
|
|
48
52
|
// declaration, which means never cached: a scanner guessing at a program is
|
|
@@ -53,7 +57,7 @@ const NEVER_RE = /^\s*export\s+const\s+never\s*(?::\s*string\s*)?=\s*['"]([^'"]+
|
|
|
53
57
|
// `export const gate = false` takes a check out of `shipkit ci`. Only the
|
|
54
58
|
// literal false opts out: anything unreadable stays in the gate, because a
|
|
55
59
|
// scanner that guesses its way to SKIPPING a check is how coverage disappears
|
|
56
|
-
const GATE_RE =
|
|
60
|
+
const GATE_RE = new RegExp(String.raw`^\s*export\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*false` + TAIL, 'm')
|
|
57
61
|
const STRING_ITEM_RE = /['"]([^'"]+)['"]/g
|
|
58
62
|
|
|
59
63
|
export function discoverChecks(root: string): DiscoveredCheck[] {
|
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
|
|
@@ -68,6 +84,24 @@ function builtinFloorTask(id: string, root: string): { task: TaskDef; source: Fl
|
|
|
68
84
|
return `no package.json '${id}' script`
|
|
69
85
|
}
|
|
70
86
|
|
|
87
|
+
// NOTHING here declares a read set on a repo's behalf, and that is a decision
|
|
88
|
+
// rather than an omission. A floor slot with no `inputs` is measured every run,
|
|
89
|
+
// which costs speed and never correctness (ledger/git-key.ts owns that default).
|
|
90
|
+
//
|
|
91
|
+
// The tempting shortcut is `['.']`, on the reasoning that the whole tree cannot
|
|
92
|
+
// under declare. It can. `['.']` is the TRACKED tree: git-scan force-adds only a
|
|
93
|
+
// path a declaration NAMES, so an ignored path enters the key by name and never
|
|
94
|
+
// by content. Every floor slot reads ignored content in an ordinary repo, a
|
|
95
|
+
// suite through its fixtures and a local database, tsc and the graph walks
|
|
96
|
+
// through generated types and node_modules. Shipped for one day, that made the
|
|
97
|
+
// gate print `ok: test (replayed)` over a suite that exits 1
|
|
98
|
+
// (tests/floor-cache-safety.test.ts holds the repro).
|
|
99
|
+
//
|
|
100
|
+
// Speed here is bought by a REPO saying what its own tasks read, in
|
|
101
|
+
// `defaults`, which is the only declaration with a real hit rate anyway.
|
|
102
|
+
// `shipkit init` scaffolds the line, `shipkit keys --burn-down` names what is
|
|
103
|
+
// still undeclared, and the gate prints the cost of it once per run.
|
|
104
|
+
|
|
71
105
|
export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
|
|
72
106
|
const pkgPath = join(root, 'package.json')
|
|
73
107
|
let scripts: Record<string, string> = {}
|
|
@@ -81,6 +115,7 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
|
|
|
81
115
|
const disabled: string[] = []
|
|
82
116
|
const unfilled: { id: string; reason: string }[] = []
|
|
83
117
|
const sources: Record<string, FloorSource> = {}
|
|
118
|
+
const notes: string[] = []
|
|
84
119
|
for (const id of FLOOR_IDS) {
|
|
85
120
|
const override = c.defaults?.[id]
|
|
86
121
|
if (override === false) { disabled.push(id); continue }
|
|
@@ -98,13 +133,13 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
|
|
|
98
133
|
// routed script here would spawn the gate from inside itself, forever
|
|
99
134
|
const decorate = (t: TaskDef): TaskDef => (override == null ? t : { ...t, ...override, id })
|
|
100
135
|
const raw = scripts[rawName(id)]
|
|
101
|
-
if (raw != null) { tasks.push(decorate({ id, cmd:
|
|
102
|
-
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 }
|
|
103
138
|
const b = builtinFloorTask(id, root)
|
|
104
139
|
// a routed script with nothing behind it measures NOTHING, and the
|
|
105
140
|
// reason must say that rather than `no '<id>' script`, which is false
|
|
106
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 })
|
|
107
142
|
else { tasks.push(decorate(b.task)); sources[id] = b.source }
|
|
108
143
|
}
|
|
109
|
-
return { tasks, disabled, unfilled, sources }
|
|
144
|
+
return { tasks, disabled, unfilled, sources, notes }
|
|
110
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/core/task-runner.ts
CHANGED
|
@@ -36,8 +36,12 @@ export type TaskDef = {
|
|
|
36
36
|
* speed and never correctness. A list MISSING a path the command reads is
|
|
37
37
|
* the one that costs correctness: the key cannot move when that path
|
|
38
38
|
* changes, so the answer replays over a tree it no longer describes. Over
|
|
39
|
-
* declaring costs a re-run, under declaring is a false green
|
|
40
|
-
*
|
|
39
|
+
* declaring costs a re-run, under declaring is a false green. `['.']` is
|
|
40
|
+
* the whole TRACKED tree, and it is NOT a catch-all: an ignored path
|
|
41
|
+
* enters a key by name and never by content, so a task reading a
|
|
42
|
+
* gitignored fixture, a built dir or a .env under declares with `['.']`
|
|
43
|
+
* exactly as it would with a narrow list. A task that reads the network,
|
|
44
|
+
* a keychain or the clock declares `ttl`, never `inputs`. A task carries no code
|
|
41
45
|
* closure, so the script it runs belongs in here too.
|
|
42
46
|
* Declaring both this and `ttl` is a registration failure. */
|
|
43
47
|
inputs?: string[]
|
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/init-repo.ts
CHANGED
|
@@ -29,10 +29,14 @@ export default defineRepo({
|
|
|
29
29
|
venues: [], // e.g. ['itch', 'steam'], selects which .rv-<venue>/ contracts are REQUIRED
|
|
30
30
|
social: [], // e.g. ['x', 'discord'], the .rv-social/<channel>/ post folders (+ .rv-discord/)
|
|
31
31
|
tasks: [], // repo commit-gate tasks; deps double as race guards
|
|
32
|
+
// lint, typecheck, imports:check and cycles replay off the tracked tree.
|
|
33
|
+
// \`test\` measures every run until you say what it reads, because an
|
|
34
|
+
// ignored path (a fixture, a local db, a .env) is not in a tree key:
|
|
35
|
+
// defaults: { test: { inputs: ['tests', 'src', 'package.json'] } },
|
|
32
36
|
// .comfy-ts/ holds schema dumps naming a private model collection.
|
|
33
37
|
// gitignore (SK004) is advisory, this pre-commit gate is not
|
|
34
38
|
hooks: { blockedPaths: ['.comfy-ts/'] },
|
|
35
|
-
// the
|
|
39
|
+
// the declared reviewer agent type (rule SK008). Uncomment with YOUR
|
|
36
40
|
// standing brief, then \`shipkit check --fix SK008\` writes the agent file:
|
|
37
41
|
// reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
|
|
38
42
|
// health checks need no config. Drop a <name>.check.ts file anywhere,
|
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-key.ts
CHANGED
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
// NEITHER declares nothing. Never cached. This is the DEFAULT, and it is
|
|
17
17
|
// the whole safety story: forgetting to declare costs speed, never
|
|
18
18
|
// correctness, so the burn-down list below is a to-do rather than a
|
|
19
|
-
// hazard.
|
|
20
|
-
// the
|
|
19
|
+
// hazard. It holds only while nothing declares ON a unit's behalf:
|
|
20
|
+
// the gate floor gives `['.']` to the four slots tracked source
|
|
21
|
+
// decides and deliberately leaves `test` here (core/floor.ts says
|
|
22
|
+
// why). Declaring BOTH is a registration failure, loud, because the
|
|
23
|
+
// two answers contradict each other.
|
|
21
24
|
//
|
|
22
25
|
// CROSS WORKTREE. Every component here is content addressed or a version
|
|
23
26
|
// string; not one of them is an absolute path. Two worktrees of one repo
|
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,15 +88,23 @@ 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'
|
|
93
|
-
/** the reviewer agent type this repo
|
|
102
|
+
/** the reviewer agent type this repo declares: `.claude/agents/<name>.md`,
|
|
94
103
|
* whose system prompt points at `brief` (an md file holding the standing
|
|
95
104
|
* review framing — one home, shared across repos). `tools` overrides the
|
|
96
105
|
* read-only allowlist SK008 enforces (default: Read, Grep, Glob, Bash).
|
|
97
106
|
* Rule SK008 lints it, `check --fix SK008` writes it. Absent = no
|
|
98
|
-
*
|
|
107
|
+
* declared reviewer. */
|
|
99
108
|
reviewer?: { name: string; brief: string; tools?: string[] }
|
|
100
109
|
/** ONE WAY IN escape hatch (build/shipkit.md): governed script names this
|
|
101
110
|
* repo keeps as a direct tool call. Declared per NAME, never as a list of
|
|
@@ -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,
|