rvlib-shipkit 0.34.0 → 0.35.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 +4 -0
- package/package.json +1 -1
- package/src/cli/cmd_Ci.ts +3 -0
- package/src/cli/cmd_Keys.ts +10 -4
- package/src/core/check-suite.ts +37 -92
- package/src/core/discover-checks.ts +1 -7
- package/src/core/floor.ts +13 -4
- package/src/core/motherhome.ts +54 -1
- package/src/core/run-check.ts +92 -12
- package/src/core/run-ci.ts +103 -29
- package/src/core/task-runner.ts +7 -0
- package/src/ledger/gate-units.ts +29 -3
- package/src/ledger/git-key.ts +56 -5
- package/src/ledger/git-scan.ts +15 -2
- package/src/ledger/ledger.ts +202 -0
- package/src/ledger/store.ts +122 -0
- package/src/ledger/toolchain-identity.ts +30 -0
- package/src/pb/collection-scaffold.ts +4 -4
- package/src/release/run-release.ts +5 -0
- package/src/release/run-releases.ts +7 -1
- package/src/repo-config.ts +26 -4
- package/src/rules/SK004-gitignore-policy.ts +1 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.35.0
|
|
4
|
+
|
|
5
|
+
- **`shipkit pb collection` mints imports of a package that exists.** The pocketbase data layer and the Mantine shell are one package now, `rvlib-pb`: the generated `columns.tsx`, `grid.tsx` and `index.ts` import `Column`, `LiveGrid` and `defineCollectionUI` from `rvlib-pb/ui`, and `LiveList` from `rvlib-pb`. The two tiers are different subpaths on purpose, so a generated grid that took its type from the ui subpath would typecheck and would put `@mantine/core` in the tsconfig of anything that reads it. This generator ships inside the tarball and writes into repos it never sees, so a stale specifier here keeps producing broken scaffolds long after a rename lands: the suite now asserts every import string the generator emits, which nothing did before.
|
|
6
|
+
|
|
3
7
|
## 0.34.0
|
|
4
8
|
|
|
5
9
|
- **SK013's autofix was manufacturing the FAIL its own lint then raised.** It wired a PreToolUse hook at `node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts` whether or not that path existed, so any repo that runs shipkit through the global shim without installing the package came out of a repair pass permanently red. A machine-clearable red that a machine just created is a design bug. The hook is still declared in every case, so it starts working the moment the dependency lands, with no second fix run: what changed is the verdict. A guard under `node_modules/` that is not there yet is an uninstalled dependency, so it WARNs and names `bun install`, with the deny list standing as the whole floor meanwhile. A guard path the repo chose and got wrong still FAILs, because that one never resolves on its own.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"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",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli/cmd_Ci.ts
CHANGED
|
@@ -16,6 +16,7 @@ export class CiCommand extends Command {
|
|
|
16
16
|
['leave repo config files alone', 'shipkit ci --no-fix-rules'],
|
|
17
17
|
['stop at the first failure', 'shipkit ci --bail'],
|
|
18
18
|
['one task + its dep closure', 'shipkit ci --only typecheck'],
|
|
19
|
+
['re-measure everything, replay nothing', 'shipkit ci --fresh'],
|
|
19
20
|
['include an opt-in task', 'shipkit ci --with up:itch'],
|
|
20
21
|
],
|
|
21
22
|
})
|
|
@@ -28,6 +29,7 @@ export class CiCommand extends Command {
|
|
|
28
29
|
skip = Option.Array('--skip', [], { description: 'skip a task group (repeatable)' })
|
|
29
30
|
withIds = Option.Array('--with', [], { description: 'include an opt-in task (repeatable)' })
|
|
30
31
|
only = Option.Array('--only', [], { description: 'run only this task + its dep closure (repeatable)' })
|
|
32
|
+
fresh = Option.Boolean('--fresh', false, { description: 'measure everything: no unit replays from the ledger, and what this run measures is stored fresh' })
|
|
31
33
|
full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
|
|
32
34
|
|
|
33
35
|
async execute(): Promise<number> {
|
|
@@ -45,6 +47,7 @@ export class CiCommand extends Command {
|
|
|
45
47
|
skipGroups: this.skip,
|
|
46
48
|
withIds: this.withIds,
|
|
47
49
|
onlyIds: this.only,
|
|
50
|
+
fresh: this.fresh,
|
|
48
51
|
...(this.full ? { tests: 'full' as const } : {}),
|
|
49
52
|
print: (l) => this.context.stdout.write(`${l}\n`),
|
|
50
53
|
})
|
package/src/cli/cmd_Keys.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { createHash } from 'crypto'
|
|
9
9
|
import { Command, Option } from 'clipanion'
|
|
10
10
|
import { loadRepoConfig } from '../repo-config.ts'
|
|
11
|
-
import { gateUnits } from '../ledger/gate-units.ts'
|
|
11
|
+
import { envTier, FULL_TESTS_ENV, gateUnits, tieredTasks } from '../ledger/gate-units.ts'
|
|
12
12
|
import { KeyLayer } from '../ledger/git-key.ts'
|
|
13
13
|
|
|
14
14
|
export class KeysCommand extends Command {
|
|
@@ -22,6 +22,7 @@ export class KeysCommand extends Command {
|
|
|
22
22
|
['only the ones nothing can cache yet', 'shipkit keys --burn-down'],
|
|
23
23
|
['why this key is what it is', 'shipkit keys --why typecheck'],
|
|
24
24
|
['re-hash after the fact and report drift', 'shipkit keys --rescan'],
|
|
25
|
+
['the keys a --full gate asks for', 'shipkit keys --full'],
|
|
25
26
|
['machine-readable', 'shipkit keys --json'],
|
|
26
27
|
],
|
|
27
28
|
})
|
|
@@ -29,19 +30,24 @@ export class KeysCommand extends Command {
|
|
|
29
30
|
json = Option.Boolean('--json', false, { description: 'machine-readable { units: UnitKey[], burnDown, errors, scanMs }' })
|
|
30
31
|
burnDown = Option.Boolean('--burn-down', false, { description: 'list only the units nothing can cache yet, and why' })
|
|
31
32
|
why = Option.String('--why', { required: false, description: 'print every component that went into ONE unit\'s key' })
|
|
33
|
+
full = Option.Boolean('--full', false, { description: `render the keys a FULL-tier run asks for (same as ${FULL_TESTS_ENV}=1). The tier is in every task's key` })
|
|
32
34
|
rescan = Option.Boolean('--rescan', false, { description: 'run the post-run re-hash and name every input that moved since the scan' })
|
|
33
35
|
|
|
34
36
|
async execute(): Promise<number> {
|
|
35
37
|
const root = process.cwd()
|
|
36
38
|
const c = await loadRepoConfig(root)
|
|
37
|
-
|
|
39
|
+
// the TIER the gate would run, applied here too: `--full` rewrites
|
|
40
|
+
// every task's env, which is in the key, so a surface that renders keys
|
|
41
|
+
// without it renders a tier nobody asked for
|
|
42
|
+
const tier = this.full ? 'full' : envTier()
|
|
43
|
+
const decls = gateUnits(root, c, tieredTasks(c, root, tier))
|
|
38
44
|
const layer = KeyLayer.open(root, decls, { cliEntry: new URL('../cli.ts', import.meta.url).pathname })
|
|
39
45
|
const keys = layer.keys()
|
|
40
46
|
const out = (l: string): void => { this.context.stdout.write(`${l}\n`) }
|
|
41
47
|
|
|
42
48
|
if (this.json) {
|
|
43
49
|
const drift = this.rescan ? layer.rescan() : null
|
|
44
|
-
out(JSON.stringify({ units: keys, burnDown: layer.registration.burnDown, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
|
|
50
|
+
out(JSON.stringify({ tier, units: keys, burnDown: layer.registration.burnDown, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
|
|
45
51
|
return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
|
|
46
52
|
}
|
|
47
53
|
|
|
@@ -90,7 +96,7 @@ export class KeysCommand extends Command {
|
|
|
90
96
|
const world = keys.filter((k) => k.population === 'world').length
|
|
91
97
|
const none = keys.filter((k) => k.population === 'none').length
|
|
92
98
|
const todo = layer.registration.burnDown.length
|
|
93
|
-
out(`\n${keys.length} units — ${content} content, ${world} world, ${none} uncacheable (${none - todo} decided, ${todo} still undeclared) · key layer ${Math.round(layer.openMs)}ms, of which ${Math.round(layer.scanMs)}ms of git`)
|
|
99
|
+
out(`\n${keys.length} units — ${content} content, ${world} world, ${none} uncacheable (${none - todo} decided, ${todo} still undeclared) · ${tier} test tier · key layer ${Math.round(layer.openMs)}ms, of which ${Math.round(layer.scanMs)}ms of git`)
|
|
94
100
|
if (todo > 0 && !this.burnDown) out(` the burn-down: shipkit keys --burn-down`)
|
|
95
101
|
|
|
96
102
|
if (this.rescan) {
|
package/src/core/check-suite.ts
CHANGED
|
@@ -6,56 +6,23 @@
|
|
|
6
6
|
// WARN/FAIL evict. Busts: version bump, secret rotation (key hashes the
|
|
7
7
|
// secret value), `<freshEnv>=1`.
|
|
8
8
|
//
|
|
9
|
-
// STORAGE
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// runner that narrowed itself with --only. It replaced one aggregate JSON
|
|
16
|
-
// read at the top of the run and rewritten at the bottom, which lost every
|
|
17
|
-
// entry another runner wrote in between and served a torn read as an empty
|
|
18
|
-
// cache.
|
|
9
|
+
// STORAGE is not this module's concern any more: ledger/store.ts owns the
|
|
10
|
+
// one store, its shape, and its housekeeping (one immutable file per key,
|
|
11
|
+
// tmp + rename, age-based sweep). This is the WORLD half of the three
|
|
12
|
+
// populations — an answer nothing on disk describes, remembered for a TTL and
|
|
13
|
+
// busted by rotation. The CONTENT half is keyed instead of timed and lives in
|
|
14
|
+
// ledger/ledger.ts. Same store, different key.
|
|
19
15
|
//
|
|
20
16
|
// Secret VALUES never enter this module's state beyond the injected
|
|
21
17
|
// getSecret call + a truncated hash for cache-busting.
|
|
22
|
-
import { existsSync
|
|
23
|
-
import { dirname, join } from 'path'
|
|
18
|
+
import { existsSync } from 'fs'
|
|
24
19
|
import { CheckCtx, RateLimited, ToolUnresolved } from './check-ctx.ts'
|
|
25
|
-
import {
|
|
20
|
+
import { dropStored, entryPath, FRESH_ENV, readStored, sweepStore, writeStored } from '../ledger/store.ts'
|
|
26
21
|
import { fmtStamp, formatLine, parseDuration, type Line } from './wire.ts'
|
|
27
22
|
|
|
28
23
|
type Logic = (t: CheckCtx) => unknown
|
|
29
24
|
type Prep = { skip?: string; cacheExtra?: string }
|
|
30
25
|
type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic; blocking: boolean }
|
|
31
|
-
type CacheEntry = { version: number; key: string; at: number; lines: Line[] }
|
|
32
|
-
|
|
33
|
-
/** a missing or half-written file reads as "no answer", which is the safe
|
|
34
|
-
* side: the check runs. tmp + rename means half-written cannot happen from a
|
|
35
|
-
* shipkit write, so this only ever catches a truncated file from something
|
|
36
|
-
* else, and treats it the same way. */
|
|
37
|
-
const readEntry = (path: string): CacheEntry | null => {
|
|
38
|
-
try {
|
|
39
|
-
const e = JSON.parse(readFileSync(path, 'utf8')) as Partial<CacheEntry>
|
|
40
|
-
return Array.isArray(e.lines) && typeof e.at === 'number' && typeof e.version === 'number' && typeof e.key === 'string' ? (e as CacheEntry) : null
|
|
41
|
-
} catch { return null }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const writeEntry = (path: string, e: CacheEntry): void => {
|
|
45
|
-
try {
|
|
46
|
-
mkdirSync(dirname(path), { recursive: true })
|
|
47
|
-
atomicWrite(path, JSON.stringify(e))
|
|
48
|
-
} catch {
|
|
49
|
-
// a cache that cannot be written is slow, never wrong: the answer was
|
|
50
|
-
// already printed, and the next run measures again
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const slug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
|
|
55
|
-
|
|
56
|
-
/** how long a superseded key's file survives. Far past the longest TTL any
|
|
57
|
-
* check declares, so a live entry is never swept out from under a replay. */
|
|
58
|
-
const SUPERSEDED_KEEP_MS = 30 * 24 * 60 * 60 * 1000
|
|
59
26
|
|
|
60
27
|
export type GetSecret = (name: string, field: 'get' | 'account') => string | null
|
|
61
28
|
|
|
@@ -66,15 +33,19 @@ export type SecretSpec = {
|
|
|
66
33
|
skipHint: string
|
|
67
34
|
}
|
|
68
35
|
|
|
69
|
-
export type CheckOutcome = { lines: Line[]; status: 'pass' | 'fail' | 'warn' | 'skip'; cached: boolean }
|
|
36
|
+
export type CheckOutcome = { lines: Line[]; status: 'pass' | 'fail' | 'warn' | 'skip'; cached: boolean; /** wall time of the LOGIC, ms — 0 on a replay, since a replay measured nothing */ ms: number }
|
|
70
37
|
|
|
71
38
|
export type CheckSuiteOpts = {
|
|
72
39
|
name: string
|
|
73
|
-
|
|
40
|
+
/** the ONE store root (ledger/store.ts storeRoot). Injected rather than
|
|
41
|
+
* resolved here so a test can point a suite at a scratch dir. */
|
|
42
|
+
storeDir: string
|
|
74
43
|
now?: () => number
|
|
75
44
|
/** secret backend — consumers inject their own store; default = nothing stored, every secret check skips */
|
|
76
45
|
getSecret?: GetSecret
|
|
77
|
-
/** env var that busts
|
|
46
|
+
/** TEST SEAM: the env var that busts this suite's replays. Production is
|
|
47
|
+
* always the one house-wide flag (ledger/store.ts FRESH_ENV); a test
|
|
48
|
+
* overrides it so busting one suite cannot poison a neighbour's run. */
|
|
78
49
|
freshEnv?: string
|
|
79
50
|
/** per-check completion tap (the TUI streams results in) — cached replays
|
|
80
51
|
* report cached:true, prep-skips (unstored secret) report status 'skip' */
|
|
@@ -85,7 +56,7 @@ export class CheckSuite {
|
|
|
85
56
|
readonly name: string
|
|
86
57
|
private runners: Runner[] = []
|
|
87
58
|
private toolPaths = new Map<string, string>()
|
|
88
|
-
private
|
|
59
|
+
private storeDir: string
|
|
89
60
|
private now: () => number
|
|
90
61
|
private getSecret: GetSecret
|
|
91
62
|
private freshEnv: string
|
|
@@ -93,10 +64,10 @@ export class CheckSuite {
|
|
|
93
64
|
|
|
94
65
|
constructor(opts: CheckSuiteOpts) {
|
|
95
66
|
this.name = opts.name
|
|
96
|
-
this.
|
|
67
|
+
this.storeDir = opts.storeDir
|
|
97
68
|
this.now = opts.now ?? Date.now
|
|
98
69
|
this.getSecret = opts.getSecret ?? (() => null)
|
|
99
|
-
this.freshEnv = opts.freshEnv ??
|
|
70
|
+
this.freshEnv = opts.freshEnv ?? FRESH_ENV
|
|
100
71
|
this.onResult = opts.onResult
|
|
101
72
|
}
|
|
102
73
|
|
|
@@ -166,32 +137,12 @@ export class CheckSuite {
|
|
|
166
137
|
})
|
|
167
138
|
}
|
|
168
139
|
|
|
169
|
-
/** the key
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
private sweepSuperseded(): void {
|
|
176
|
-
const dir = join(this.cacheDir, slug(this.name))
|
|
177
|
-
const cutoff = this.now() - SUPERSEDED_KEEP_MS
|
|
178
|
-
try {
|
|
179
|
-
for (const f of readdirSync(dir)) {
|
|
180
|
-
if (!f.endsWith('.json')) continue
|
|
181
|
-
const p = join(dir, f)
|
|
182
|
-
try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { force: true }) } catch { /* raced with another runner: it is gone either way */ }
|
|
183
|
-
}
|
|
184
|
-
} catch {
|
|
185
|
-
// no dir yet, or unreadable: housekeeping never fails a run
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** where this check's answer for THIS key lives. The key is in the file
|
|
190
|
-
* NAME, so a run whose key moved writes a new file rather than rewriting
|
|
191
|
-
* the old one, and two runners with different keys never collide. */
|
|
192
|
-
private entryPath(r: Runner, key: string): string {
|
|
193
|
-
const h = new Bun.CryptoHasher('sha256').update(`${r.name}\0${r.version}\0${key}`).digest('hex').slice(0, 12)
|
|
194
|
-
return join(this.cacheDir, slug(this.name), `${slug(r.name)}.${h}.json`)
|
|
140
|
+
/** the key a world answer is remembered under: the check's own version
|
|
141
|
+
* plus whatever the prep declared (a secret's value hash, a rule's
|
|
142
|
+
* cacheKey). A version bump and a rotation both move it, so neither can
|
|
143
|
+
* replay across the change. */
|
|
144
|
+
private cacheKey(r: Runner, extra: string): string {
|
|
145
|
+
return `v${r.version}\0${extra}`
|
|
195
146
|
}
|
|
196
147
|
|
|
197
148
|
// test seam — run() prints + exits; execute() returns the structured result
|
|
@@ -201,15 +152,14 @@ export class CheckSuite {
|
|
|
201
152
|
private async runOne(r: Runner): Promise<{ lines: Line[]; skip: string | null; ran: boolean; failed: boolean }> {
|
|
202
153
|
const prep = r.prep?.() ?? {}
|
|
203
154
|
if (prep.skip != null) {
|
|
204
|
-
this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false })
|
|
155
|
+
this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false, ms: 0 })
|
|
205
156
|
return { lines: [], skip: prep.skip, ran: false, failed: false }
|
|
206
157
|
}
|
|
207
|
-
const key = prep.cacheExtra ?? ''
|
|
208
|
-
const
|
|
209
|
-
const entry =
|
|
158
|
+
const key = this.cacheKey(r, prep.cacheExtra ?? '')
|
|
159
|
+
const path = entryPath(this.storeDir, this.name, r.name, key)
|
|
160
|
+
const entry = readStored<Line[]>(path, key)
|
|
210
161
|
const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
|
|
211
|
-
&& entry != null && entry.
|
|
212
|
-
&& this.now() - entry.at < r.ttlMs && this.now() >= entry.at
|
|
162
|
+
&& entry != null && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
|
|
213
163
|
if (fresh) {
|
|
214
164
|
// the stamp was baked in when the check RAN (below): a replay
|
|
215
165
|
// prints the stored lines VERBATIM, so the live run and every
|
|
@@ -219,11 +169,12 @@ export class CheckSuite {
|
|
|
219
169
|
// alternation, so neither side annotates
|
|
220
170
|
// a replayed WORLD negative keeps its severity: it is still the
|
|
221
171
|
// answer, and demoting it to a pass would hide a real finding
|
|
222
|
-
const replayedWarn = entry.
|
|
223
|
-
this.onResult?.(r.name, { lines: entry.
|
|
224
|
-
return { lines: entry.
|
|
172
|
+
const replayedWarn = entry.payload.some((l) => l.kind === 'warn' || l.kind === 'fail')
|
|
173
|
+
this.onResult?.(r.name, { lines: entry.payload, status: replayedWarn ? 'warn' : 'pass', cached: true, ms: 0 })
|
|
174
|
+
return { lines: entry.payload, skip: null, ran: true, failed: false }
|
|
225
175
|
}
|
|
226
176
|
const t = new CheckCtx(this.toolPaths)
|
|
177
|
+
const started = performance.now()
|
|
227
178
|
try {
|
|
228
179
|
await r.logic(t)
|
|
229
180
|
} catch (e) {
|
|
@@ -260,29 +211,23 @@ export class CheckSuite {
|
|
|
260
211
|
const lines = cacheable
|
|
261
212
|
? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (${clean ? 'cached' : 'measured'} at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + r.ttlMs)})` } : l))
|
|
262
213
|
: t.lines
|
|
263
|
-
this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
|
|
214
|
+
this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false, ms: performance.now() - started })
|
|
264
215
|
// this check writes its OWN file, and only its own: nothing here reads
|
|
265
216
|
// or rewrites a neighbour's entry, so a racing runner can lose nothing
|
|
266
217
|
if (r.ttlMs > 0) {
|
|
267
|
-
if (cacheable)
|
|
218
|
+
if (cacheable) writeStored(path, key, lines, this.now())
|
|
268
219
|
// an unmeasured run evicts whatever was stored: a later run must
|
|
269
220
|
// re-probe rather than replay an answer recorded before the outage.
|
|
270
221
|
// A racing runner may have written a good answer into that same
|
|
271
222
|
// file a moment ago and lose it here, which costs one re-probe and
|
|
272
223
|
// can never produce a stale green, so it is the safe direction.
|
|
273
|
-
else if (entry != null)
|
|
224
|
+
else if (entry != null) dropStored(path)
|
|
274
225
|
}
|
|
275
226
|
return { lines, skip: null, ran: true, failed: t.failed }
|
|
276
227
|
}
|
|
277
228
|
|
|
278
229
|
async execute(): Promise<{ lines: Line[]; exitCode: number }> {
|
|
279
|
-
|
|
280
|
-
// nothing reads it any more, and leaving it would have a future reader
|
|
281
|
-
// believe there are two cache layers. DIES when every consumer repo is
|
|
282
|
-
// past rvlib-shipkit 0.34.0, since after that no tree can still carry
|
|
283
|
-
// one; it costs an unlink of a path that is not there.
|
|
284
|
-
rmSync(join(this.cacheDir, `${this.name}.json`), { force: true })
|
|
285
|
-
this.sweepSuperseded()
|
|
230
|
+
sweepStore(this.storeDir, this.name, this.now())
|
|
286
231
|
|
|
287
232
|
// CONCURRENT, bounded. Checks are independent by contract: each answers
|
|
288
233
|
// one question about the world or the repo and shares nothing with its
|
|
@@ -17,8 +17,6 @@ export type DiscoveredCheck = {
|
|
|
17
17
|
path: string
|
|
18
18
|
/** display + cache name — file base, disambiguated by relative path on collision */
|
|
19
19
|
name: string
|
|
20
|
-
/** file runs its own CheckSuite (own TTL cache) — the orchestrator must not double-cache it */
|
|
21
|
-
selfCached: boolean
|
|
22
20
|
/** `export const blocking = true | false`, read statically. null = the file
|
|
23
21
|
* never said, which is an error: a check that silently does not block is
|
|
24
22
|
* the failure nobody sees. */
|
|
@@ -67,16 +65,12 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
|
|
|
67
65
|
return files.map((f) => {
|
|
68
66
|
const base = baseOf(f)
|
|
69
67
|
const name = (seen.get(base) ?? 0) > 1 ? relative(root, f).slice(0, -SUFFIX.length) : base
|
|
70
|
-
// a check built on CheckSuite/PageValidator replays from its OWN
|
|
71
|
-
// TTL cache — caching it again up here would double the staleness
|
|
72
|
-
let selfCached = false
|
|
73
68
|
let blocking: boolean | null = null
|
|
74
69
|
let inputs: string[] | null = null
|
|
75
70
|
let ttl: string | null = null
|
|
76
71
|
let never: string | null = null
|
|
77
72
|
try {
|
|
78
73
|
const src = readFileSync(f, 'utf8')
|
|
79
|
-
selfCached = /PageValidator|CheckSuite/.test(src)
|
|
80
74
|
const m = src.match(BLOCKING_RE)
|
|
81
75
|
blocking = m == null ? null : m[1] === 'true'
|
|
82
76
|
const im = src.match(INPUTS_RE)
|
|
@@ -87,6 +81,6 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
|
|
|
87
81
|
ttl = src.match(TTL_RE)?.[1] ?? null
|
|
88
82
|
never = src.match(NEVER_RE)?.[1] ?? null
|
|
89
83
|
} catch {}
|
|
90
|
-
return { path: f, name,
|
|
84
|
+
return { path: f, name, blocking, inputs, ttl, never }
|
|
91
85
|
})
|
|
92
86
|
}
|
package/src/core/floor.ts
CHANGED
|
@@ -84,18 +84,27 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
|
|
|
84
84
|
for (const id of FLOOR_IDS) {
|
|
85
85
|
const override = c.defaults?.[id]
|
|
86
86
|
if (override === false) { disabled.push(id); continue }
|
|
87
|
-
|
|
87
|
+
// a FULL override brings its own body and replaces the slot. One that
|
|
88
|
+
// brings none DECORATES whatever fills it, which is how a repo declares
|
|
89
|
+
// what its `test` script reads without restating the command: the read
|
|
90
|
+
// set is repo knowledge, the command is not.
|
|
91
|
+
if (override != null && (override.cmd != null || override.logic != null)) {
|
|
92
|
+
tasks.push({ ...override, id: override.id ?? id } as TaskDef)
|
|
93
|
+
sources[id] = 'repo.config override'
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
88
96
|
// ONE WAY IN: `<id>` routes to `shipkit ci --only <id>`, so the TOOL
|
|
89
97
|
// body lives at `<id>:raw` and that is what the gate runs. Running the
|
|
90
98
|
// routed script here would spawn the gate from inside itself, forever
|
|
99
|
+
const decorate = (t: TaskDef): TaskDef => (override == null ? t : { ...t, ...override, id })
|
|
91
100
|
const raw = scripts[rawName(id)]
|
|
92
|
-
if (raw != null) { tasks.push({ id, cmd: ['bun', 'run', rawName(id)] }); sources[id] = 'package.json script'; continue }
|
|
93
|
-
if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push({ id, cmd: ['bun', 'run', id] }); sources[id] = 'package.json script'; continue }
|
|
101
|
+
if (raw != null) { tasks.push(decorate({ id, cmd: ['bun', 'run', rawName(id)] })); sources[id] = 'package.json script'; continue }
|
|
102
|
+
if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push(decorate({ id, cmd: ['bun', 'run', id] })); sources[id] = 'package.json script'; continue }
|
|
94
103
|
const b = builtinFloorTask(id, root)
|
|
95
104
|
// a routed script with nothing behind it measures NOTHING, and the
|
|
96
105
|
// reason must say that rather than `no '<id>' script`, which is false
|
|
97
106
|
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 })
|
|
98
|
-
else { tasks.push(b.task); sources[id] = b.source }
|
|
107
|
+
else { tasks.push(decorate(b.task)); sources[id] = b.source }
|
|
99
108
|
}
|
|
100
109
|
return { tasks, disabled, unfilled, sources }
|
|
101
110
|
}
|
package/src/core/motherhome.ts
CHANGED
|
@@ -24,6 +24,58 @@ type Row = { name: string; root: string; version: string; mode: string; seen: st
|
|
|
24
24
|
|
|
25
25
|
const rowLine = (r: Row): string => `- ${r.name} — \`${r.root}\` — shipkit ${r.version} (${r.mode}) — last seen ${r.seen}`
|
|
26
26
|
|
|
27
|
+
const git = (cwd: string, args: string[]): { ok: boolean; out: string } => {
|
|
28
|
+
const r = Bun.spawnSync(['git', ...args], { cwd })
|
|
29
|
+
return { ok: r.exitCode === 0, out: `${r.stdout.toString()}${r.stderr.toString()}`.trim() }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Commit the row we just wrote, and ONLY that file.
|
|
34
|
+
*
|
|
35
|
+
* This page is machine written and rewrites whenever a repo's shipkit version
|
|
36
|
+
* or consumption mode changes, so left alone it dirties the docs repo from a
|
|
37
|
+
* gate run nobody associates with it, and the dirt then rides into an unrelated
|
|
38
|
+
* commit or blocks a release. The row is not worth a human's attention in
|
|
39
|
+
* either direction, so the machine that writes it also lands it.
|
|
40
|
+
*
|
|
41
|
+
* Scoped hard, because several agents share this checkout:
|
|
42
|
+
* - `git commit -- <path>` takes the WORKING TREE version of that one path and
|
|
43
|
+
* never reads the index, so a neighbour's staged work cannot ride along.
|
|
44
|
+
* - it refuses mid merge, mid rebase, on a detached HEAD, and before the first
|
|
45
|
+
* commit, where an automatic commit is never what anyone wants.
|
|
46
|
+
* - it never pushes. Visibility is the human's call.
|
|
47
|
+
* - the hooks run, as they do for any commit. They are text checks of about a
|
|
48
|
+
* second, and this fires only when the row's DATA changed, not every gate.
|
|
49
|
+
*
|
|
50
|
+
* Never throws and never fails the calling command: a registry row is not worth
|
|
51
|
+
* a red gate. `SHIPKIT_NO_AUTOCOMMIT=1` turns it off.
|
|
52
|
+
*/
|
|
53
|
+
function autoCommitRow(page: string, name: string): string {
|
|
54
|
+
if (process.env['SHIPKIT_NO_AUTOCOMMIT'] === '1') return ''
|
|
55
|
+
try {
|
|
56
|
+
const dir = resolve(page, '..')
|
|
57
|
+
const top = git(dir, ['rev-parse', '--show-toplevel'])
|
|
58
|
+
if (!top.ok) return '' // the docs tree is not a git repo, nothing to land
|
|
59
|
+
const root = top.out
|
|
60
|
+
// a detached HEAD or a repo with no commit yet has nowhere sane to land
|
|
61
|
+
if (!git(root, ['symbolic-ref', '--quiet', 'HEAD']).ok) return ''
|
|
62
|
+
if (!git(root, ['rev-parse', '--verify', 'HEAD']).ok) return ''
|
|
63
|
+
for (const f of ['MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD'])
|
|
64
|
+
if (existsSync(join(root, '.git', f))) return ''
|
|
65
|
+
const rel = page.startsWith(`${root}/`) ? page.slice(root.length + 1) : page
|
|
66
|
+
// one retry: a neighbour committing right now holds index.lock
|
|
67
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
68
|
+
const r = git(root, ['commit', '-m', `chore(machine): refresh local-projects row (${name})`, '--', rel])
|
|
69
|
+
if (r.ok) return ' + committed'
|
|
70
|
+
if (!r.out.includes('index.lock')) return ''
|
|
71
|
+
Bun.sleepSync(300)
|
|
72
|
+
}
|
|
73
|
+
return ''
|
|
74
|
+
} catch {
|
|
75
|
+
return ''
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
27
79
|
const parseRow = (line: string): Row | null => {
|
|
28
80
|
const m = line.match(/^- (.+?) — `(.+?)` — shipkit (\S+) \((.+?)\) — last seen (\d{4}-\d{2}-\d{2})$/)
|
|
29
81
|
return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
|
|
@@ -90,7 +142,8 @@ export function reportToMotherhome(root: string, config: RepoConfig, today: stri
|
|
|
90
142
|
// prune rows for repos that no longer exist at their recorded path
|
|
91
143
|
const next = [...rows.filter((row) => row.root !== me.root && existsSync(row.root)), me].sort((a, z) => a.name.localeCompare(z.name))
|
|
92
144
|
atomicWrite(page, [...lines.slice(0, b + 1), ...next.map(rowLine), ...lines.slice(e)].join('\n'))
|
|
93
|
-
|
|
145
|
+
const committed = autoCommitRow(page, me.name)
|
|
146
|
+
return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})${committed}` }
|
|
94
147
|
})
|
|
95
148
|
// lock lost to a racing reporter — self-heals on the next run
|
|
96
149
|
return r.ok ? r.value : { wrote: false }
|
package/src/core/run-check.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
// periodic health — contracts + every *.check.ts in the repo. Discovery IS
|
|
2
2
|
// the registration (core/discover-checks.ts); each check runs as a
|
|
3
|
-
// subprocess: wire lines parsed back, exit code is truth.
|
|
4
|
-
//
|
|
5
|
-
// here
|
|
6
|
-
//
|
|
3
|
+
// subprocess: wire lines parsed back, exit code is truth.
|
|
4
|
+
//
|
|
5
|
+
// NOTHING here decides what may be remembered any more. A rule or a check is
|
|
6
|
+
// replayed when it DECLARED how ("inputs" makes it content-keyed and the
|
|
7
|
+
// ledger owns it, "ttl" makes it a world probe and CheckSuite owns it), and
|
|
8
|
+
// otherwise it is measured every run. The blanket 30-minute orchestrator TTL
|
|
9
|
+
// that used to cover every plain check died with that: it cached by default,
|
|
10
|
+
// on top of whatever the check cached for itself, so one question had two
|
|
11
|
+
// staleness windows and neither was declared by anyone. `--fresh`
|
|
12
|
+
// (SHIPKIT_FRESH=1, which travels to children) busts the one that is left.
|
|
7
13
|
import { join, relative } from 'path'
|
|
8
14
|
import { CheckSuite, type CheckOutcome } from './check-suite.ts'
|
|
15
|
+
import { gateUnits } from '../ledger/gate-units.ts'
|
|
16
|
+
import { Ledger, stampReplayed } from '../ledger/ledger.ts'
|
|
17
|
+
import { storeRoot } from '../ledger/store.ts'
|
|
9
18
|
import { CheckCtx } from './check-ctx.ts'
|
|
10
19
|
import { discoverChecks } from './discover-checks.ts'
|
|
11
20
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
@@ -18,9 +27,6 @@ import { writeStatus } from './write-status.ts'
|
|
|
18
27
|
import { SHARE_ENV } from '../release/releases-share.ts'
|
|
19
28
|
import { rmSync } from 'fs'
|
|
20
29
|
|
|
21
|
-
// orchestrator cache for checks WITHOUT their own suite — short on purpose:
|
|
22
|
-
// this layer only absorbs back-to-back runs, real TTLs live in the checks
|
|
23
|
-
const DISCOVERED_TTL = '30m'
|
|
24
30
|
const CHECK_TIMEOUT_MS = 120_000
|
|
25
31
|
|
|
26
32
|
export type CheckOpts = {
|
|
@@ -50,6 +56,15 @@ export type CheckOpts = {
|
|
|
50
56
|
* probed every third-party service the repo knows about. One flag, one
|
|
51
57
|
* meaning, across all three surfaces. */
|
|
52
58
|
only?: string[]
|
|
59
|
+
/** the run's ledger, when the caller already opened one (runCi does). A
|
|
60
|
+
* standalone `shipkit check` opens its own. */
|
|
61
|
+
ledger?: Ledger | null
|
|
62
|
+
/** refresh the motherhome registry row. runCi passes false and does it
|
|
63
|
+
* ITSELF, after the ledger has committed: that row is a TRACKED file under
|
|
64
|
+
* `wiki/`, so writing it before the post-run re-hash makes the gate look
|
|
65
|
+
* like an agent editing the tree and throws away every result the run just
|
|
66
|
+
* measured. Two reports per gate run was duplication anyway. */
|
|
67
|
+
reportHome?: boolean
|
|
53
68
|
}
|
|
54
69
|
|
|
55
70
|
/** does this rule or check answer to one of the `--only` ids? Matched against
|
|
@@ -83,7 +98,61 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
83
98
|
const prevFrozen = process.env.SHIPKIT_FROZEN
|
|
84
99
|
if (o.fresh === true) process.env.SHIPKIT_FRESH = '1'
|
|
85
100
|
if (o.frozen === true) process.env.SHIPKIT_FROZEN = '1'
|
|
86
|
-
|
|
101
|
+
// the caller's ledger, or this run's own. `shipkit check` is a full run
|
|
102
|
+
// of every rule and check, so it deserves the same replay the gate gets.
|
|
103
|
+
let ledger: Ledger | null = o.ledger ?? null
|
|
104
|
+
if (ledger === null && o.ledger === undefined) {
|
|
105
|
+
try {
|
|
106
|
+
ledger = Ledger.open(root, gateUnits(root, c), { cliEntry: new URL('../cli.ts', import.meta.url).pathname, fresh: o.fresh })
|
|
107
|
+
} catch { ledger = null }
|
|
108
|
+
}
|
|
109
|
+
const replayed = new Set<string>()
|
|
110
|
+
const unitIdByName = new Map<string, string>()
|
|
111
|
+
/** what a TTL'd answer is remembered under, beside its rotation key. A TTL
|
|
112
|
+
* says how long the WORLD keeps; it cannot say that the probe was
|
|
113
|
+
* rewritten, or that a different shipkit measured it. Without these two a
|
|
114
|
+
* fixed check replayed its old verdict for the rest of its TTL. */
|
|
115
|
+
const worldKey = (unitId: string, rotation?: string): string => {
|
|
116
|
+
let code = ''
|
|
117
|
+
try { code = ledger?.layer.keyFor(unitId).codeKey ?? '' } catch { code = '' }
|
|
118
|
+
return `tc=${ledger?.layer.toolchainId ?? ''}\0code=${code}\0${rotation ?? ''}`
|
|
119
|
+
}
|
|
120
|
+
const suite = new CheckSuite({
|
|
121
|
+
name: c.name,
|
|
122
|
+
storeDir: storeRoot(root),
|
|
123
|
+
onResult: (name, r) => {
|
|
124
|
+
o.onResult?.(name, r)
|
|
125
|
+
// a replay measured nothing, and a red is never stored. A check
|
|
126
|
+
// that cannot block had its FAIL rewritten to a warn before this
|
|
127
|
+
// fires, so the demotion list is what says it failed: a content
|
|
128
|
+
// key has no TTL, so storing that red would pin it until an
|
|
129
|
+
// unrelated edit moved the tree, and "the box is off" is fixed by
|
|
130
|
+
// turning the box on, which changes no file.
|
|
131
|
+
if (ledger == null || r.cached || replayed.has(name) || r.status === 'skip' || r.status === 'fail') return
|
|
132
|
+
if (advisoryFailures.includes(name)) return
|
|
133
|
+
const id = unitIdByName.get(name)
|
|
134
|
+
if (id == null) return
|
|
135
|
+
ledger.record(id, { status: r.status, lines: r.lines, measuredMs: r.ms })
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
/** a stored answer, re-emitted as a runner that does no work. Keeping it in
|
|
139
|
+
* the suite rather than splicing lines afterwards is what preserves
|
|
140
|
+
* declaration order, which the wire contract is frozen on. */
|
|
141
|
+
const addReplay = (unitId: string, name: string, blocking: boolean): boolean => {
|
|
142
|
+
const hit = ledger?.replay(unitId)
|
|
143
|
+
if (hit == null) return false
|
|
144
|
+
replayed.add(name)
|
|
145
|
+
unitIdByName.set(name, unitId)
|
|
146
|
+
suite.addCheck({
|
|
147
|
+
blocking,
|
|
148
|
+
name,
|
|
149
|
+
logic: (t) => {
|
|
150
|
+
for (const l of stampReplayed(hit.lines, hit.measuredMs)) t.lines.push(l)
|
|
151
|
+
if (t.lines.some((l) => l.kind === 'warn' || l.kind === 'fix-warn')) t.warned = true
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
87
156
|
// built-in SK rules (rules/rule-registry.ts) THEN the repo's own
|
|
88
157
|
// (repo.config rules: []), id-tagged, crash-safe, fix-pointer floor
|
|
89
158
|
// (rules/rule.ts runRuleLint owns the semantics). `shipkit ci` runs this
|
|
@@ -104,11 +173,13 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
104
173
|
for (const rule of [...RULES, ...(c.rules ?? [])]) {
|
|
105
174
|
if (!matchesOnly(o.only, rule.id, rule.title)) continue
|
|
106
175
|
if (o.onlyBlocking === true && !rule.blocking) continue
|
|
176
|
+
unitIdByName.set(rule.title, rule.id)
|
|
177
|
+
if (addReplay(rule.id, rule.title, rule.blocking)) continue
|
|
107
178
|
suite.addCheck({
|
|
108
179
|
blocking: rule.blocking,
|
|
109
180
|
name: rule.title,
|
|
110
181
|
remainValidFor: rule.ttl,
|
|
111
|
-
cacheExtra:
|
|
182
|
+
cacheExtra: () => worldKey(rule.id, rule.cacheKey?.({ root, config: c })),
|
|
112
183
|
logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
|
|
113
184
|
})
|
|
114
185
|
}
|
|
@@ -123,7 +194,7 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
123
194
|
const childEnv: Record<string, string | undefined> = {
|
|
124
195
|
...process.env,
|
|
125
196
|
[SHARE_ENV]: sharePath,
|
|
126
|
-
...(o.fresh === true ? { SHIPKIT_FRESH: '1'
|
|
197
|
+
...(o.fresh === true ? { SHIPKIT_FRESH: '1' } : {}),
|
|
127
198
|
}
|
|
128
199
|
for (const d of discoverChecks(root)) {
|
|
129
200
|
const rel = relative(root, d.path)
|
|
@@ -133,12 +204,17 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
133
204
|
const blocking = d.blocking ?? c.checkDefaults?.blocking
|
|
134
205
|
if (blocking == null) undeclared.push(rel)
|
|
135
206
|
if (o.onlyBlocking === true && blocking !== true) continue
|
|
207
|
+
unitIdByName.set(d.name, d.name)
|
|
208
|
+
if (addReplay(d.name, d.name, blocking ?? true)) continue
|
|
136
209
|
suite.addCheck({
|
|
137
210
|
// a check about the WORLD may remember a measured negative; one
|
|
138
211
|
// about this repo never does (check-suite.ts, the rule)
|
|
139
212
|
blocking: blocking ?? true,
|
|
140
213
|
name: d.name,
|
|
141
|
-
|
|
214
|
+
// a WORLD check says how long its answer keeps; everything else is
|
|
215
|
+
// measured every run rather than blanket-cached by this layer
|
|
216
|
+
remainValidFor: d.ttl ?? undefined,
|
|
217
|
+
cacheExtra: () => worldKey(d.name),
|
|
142
218
|
logic: async (t) => {
|
|
143
219
|
const r = await spawnWithTimeout(['bun', d.path], { cwd: root, timeoutMs: CHECK_TIMEOUT_MS, env: childEnv })
|
|
144
220
|
for (const l of parseWireOutput(r.out)) t.lines.push(l)
|
|
@@ -159,7 +235,11 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
159
235
|
if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
|
|
160
236
|
// frozen = a release-shaped run: reporting would re-dirty the tree the
|
|
161
237
|
// caller is about to demand clean (field wart 2026-08-05, second head)
|
|
162
|
-
if (o.frozen !== true) reportToMotherhome(root, c)
|
|
238
|
+
if (o.frozen !== true && o.reportHome !== false) reportToMotherhome(root, c)
|
|
239
|
+
// the caller that OPENED the ledger owns committing it: runCi commits once
|
|
240
|
+
// after its tasks and its checks, so a task and a check in one run share
|
|
241
|
+
// one post-run re-hash
|
|
242
|
+
if (o.ledger === undefined && ledger != null) r.lines.push(...ledger.header(ledger.commit()))
|
|
163
243
|
return { lines: r.lines, blocked: r.exitCode !== 0, advisoryFailures, undeclared }
|
|
164
244
|
}
|
|
165
245
|
|