rvlib-shipkit 0.34.0 → 0.35.1
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 +8 -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 +13 -7
- package/src/core/floor.ts +13 -4
- package/src/core/motherhome.ts +54 -1
- package/src/core/run-check.ts +103 -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/src/rules/SK013-one-way-in-policy.ts +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.35.1
|
|
4
|
+
|
|
5
|
+
- **SK013's green line claimed a replacement that could not be named.** With the guard declared but not yet installed, the rule WARNed correctly and then printed `closes 14/14 raw runner(s), with the replacement named` directly underneath it. Both lines were about the same file and only one of them was true. The summary now reports what the file actually delivers: the guard is `declared, not yet runnable` until its script resolves.
|
|
6
|
+
|
|
7
|
+
## 0.35.0
|
|
8
|
+
|
|
9
|
+
- **`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.
|
|
10
|
+
|
|
3
11
|
## 0.34.0
|
|
4
12
|
|
|
5
13
|
- **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.1",
|
|
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. */
|
|
@@ -34,6 +32,12 @@ export type DiscoveredCheck = {
|
|
|
34
32
|
/** `export const never = 'why'`, read statically — a DECIDED never
|
|
35
33
|
* cacheable, which takes the check off the burn-down list. */
|
|
36
34
|
never: string | null
|
|
35
|
+
/** `export const gate = false`, read statically — this check is NOT part of
|
|
36
|
+
* `shipkit ci`. `ci` answers "is my change good"; a check that answers
|
|
37
|
+
* something else, and that can sit red for months without anything being
|
|
38
|
+
* wrong, does not belong in the answer. `shipkit check` still runs it.
|
|
39
|
+
* Default true: a check is in the gate unless it opts out by name. */
|
|
40
|
+
gate: boolean
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
// read without importing: checks run as subprocesses, and discovery must stay
|
|
@@ -46,6 +50,10 @@ const BLOCKING_RE = /^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(tru
|
|
|
46
50
|
const INPUTS_RE = /^\s*export\s+const\s+inputs\s*(?::[^=]*)?=\s*\[([^\]]*)\]/m
|
|
47
51
|
const TTL_RE = /^\s*export\s+const\s+ttl\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
|
|
48
52
|
const NEVER_RE = /^\s*export\s+const\s+never\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
|
|
53
|
+
// `export const gate = false` takes a check out of `shipkit ci`. Only the
|
|
54
|
+
// literal false opts out: anything unreadable stays in the gate, because a
|
|
55
|
+
// scanner that guesses its way to SKIPPING a check is how coverage disappears
|
|
56
|
+
const GATE_RE = /^\s*export\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*false\s*$/m
|
|
49
57
|
const STRING_ITEM_RE = /['"]([^'"]+)['"]/g
|
|
50
58
|
|
|
51
59
|
export function discoverChecks(root: string): DiscoveredCheck[] {
|
|
@@ -67,16 +75,13 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
|
|
|
67
75
|
return files.map((f) => {
|
|
68
76
|
const base = baseOf(f)
|
|
69
77
|
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
78
|
let blocking: boolean | null = null
|
|
74
79
|
let inputs: string[] | null = null
|
|
75
80
|
let ttl: string | null = null
|
|
76
81
|
let never: string | null = null
|
|
82
|
+
let gate = true
|
|
77
83
|
try {
|
|
78
84
|
const src = readFileSync(f, 'utf8')
|
|
79
|
-
selfCached = /PageValidator|CheckSuite/.test(src)
|
|
80
85
|
const m = src.match(BLOCKING_RE)
|
|
81
86
|
blocking = m == null ? null : m[1] === 'true'
|
|
82
87
|
const im = src.match(INPUTS_RE)
|
|
@@ -86,7 +91,8 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
|
|
|
86
91
|
}
|
|
87
92
|
ttl = src.match(TTL_RE)?.[1] ?? null
|
|
88
93
|
never = src.match(NEVER_RE)?.[1] ?? null
|
|
94
|
+
gate = src.match(GATE_RE) == null
|
|
89
95
|
} catch {}
|
|
90
|
-
return { path: f, name,
|
|
96
|
+
return { path: f, name, blocking, inputs, ttl, never, gate }
|
|
91
97
|
})
|
|
92
98
|
}
|
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 }
|