rvlib-shipkit 1.0.1 → 1.1.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 +57 -0
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +30 -4
- package/src/check.ts +6 -0
- package/src/checks/SK005-rvlib-versions.check.ts +18 -3
- package/src/checks/SK021-github-login.check.ts +123 -0
- package/src/cli/cmd_Asks.ts +1 -1
- package/src/cli/cmd_Check.ts +1 -1
- package/src/cli/cmd_Ci.ts +1 -1
- package/src/cli/cmd_Fleet.ts +5 -57
- package/src/cli.ts +2 -3
- package/src/collections/checks.ts +12 -2
- package/src/collections/collection-types.ts +34 -18
- package/src/collections/comfy.ts +1 -0
- package/src/collections/posts.ts +1 -0
- package/src/collections/providers.ts +71 -43
- package/src/collections/reflections.ts +1 -0
- package/src/collections/state-rules.ts +29 -13
- package/src/collections/tests.ts +1 -0
- package/src/collections/wikis.ts +1 -0
- package/src/core/budget.ts +60 -0
- package/src/core/check-ctx.ts +2 -1
- package/src/core/check-runner.ts +43 -6
- package/src/core/fd-table.ts +37 -0
- package/src/core/git-root.ts +26 -2
- package/src/core/lib-version.ts +16 -0
- package/src/core/migrate-layout.ts +2 -1
- package/src/core/motherhome.ts +2 -4
- package/src/core/package-manager.ts +4 -2
- package/src/core/raw-access.ts +11 -3
- package/src/core/reexec.ts +26 -0
- package/src/core/run-check.ts +36 -16
- package/src/core/run-ci.ts +29 -24
- package/src/core/self-update.ts +59 -24
- package/src/core/update-policy.ts +25 -0
- package/src/fleet/scan-fleet.ts +4 -15
- package/src/fleet/update-fleet.ts +85 -0
- package/src/ledger/gate-checks.ts +19 -5
- package/src/ledger/git-key.ts +10 -5
- package/src/ledger/git-scan.ts +29 -17
- package/src/ledger/ledger.ts +14 -6
- package/src/ledger/repo-view.ts +8 -6
- package/src/ledger/run-log.ts +10 -4
- package/src/ledger/run-timeline.ts +21 -9
- package/src/problems/problem-board.ts +13 -2
- package/src/problems/problem-line.ts +9 -0
- package/src/repo-config.ts +22 -1
- package/src/serve/hub-app/check-view.tsx +12 -11
- package/src/serve/hub-app/cols-svc.ts +16 -9
- package/src/serve/hub-app/dur.ts +10 -1
- package/src/serve/hub-app/header-bar.tsx +11 -0
- package/src/serve/hub-app/help.tsx +1 -0
- package/src/serve/hub-app/hub-st.ts +3 -4
- package/src/serve/hub-app/hub-types.ts +5 -0
- package/src/serve/hub-app/hub.css +3 -0
- package/src/serve/hub-app/timeline-view.tsx +4 -4
- package/src/serve/hub-app/tree-build.ts +1 -2
- package/src/serve/hub-problems.ts +35 -0
- package/src/serve/project-gutter.ts +3 -2
- package/src/serve/run-serve.ts +51 -12
- package/src/serve/serve-hub.ts +10 -3
- package/src/serve/stale-restart.ts +77 -0
- package/src/suite/suite-cli.ts +14 -10
- package/src/ts/source-graph.ts +3 -2
|
@@ -22,6 +22,7 @@ import { discoverChecks } from '../core/discover-checks.ts'
|
|
|
22
22
|
import { checkCacheExtra, describeFirst, type TaskDef, type CheckFrom } from '../check.ts'
|
|
23
23
|
import { CHECK_TIMEOUT_MS, type RunnableCheck, type CheckBody } from '../core/check-runner.ts'
|
|
24
24
|
import { parseDuration } from '../core/wire.ts'
|
|
25
|
+
import { budgetOf } from '../core/budget.ts'
|
|
25
26
|
import { whenOfLegacyCheck, whenOfDisabledSlot, whenOfRule, whenOfTask, whenOfCheck, type WhenDecl } from '../core/when.ts'
|
|
26
27
|
import type { CheckDecl } from './git-key.ts'
|
|
27
28
|
import { populationOf } from '../problems/problem-line.ts'
|
|
@@ -34,9 +35,9 @@ import { declaredTestUnits, inputsOutsideUnits, isTestFile } from '../suite/test
|
|
|
34
35
|
/** why an undeclared `run` check cannot be keyed on its reads, or null when it
|
|
35
36
|
* can. A built-in is walked inside the toolkit, a repo check inside the repo
|
|
36
37
|
* with the toolkit left out (the toolchain id covers it) */
|
|
37
|
-
function rawAccessOf(root: string, u: DiscoveredCheck, configs: Map<string, RvtConfig>): string | null {
|
|
38
|
+
function rawAccessOf(root: string, u: DiscoveredCheck, configs: Map<string, RvtConfig>, texts: Map<string, string>): string | null {
|
|
38
39
|
try {
|
|
39
|
-
const r = u.rel == null ? rawAccessInClosure(LIB_ROOT, u.path, { configs }) : rawAccessInClosure(root, u.path, { skip: LIB_ROOT, configs })
|
|
40
|
+
const r = u.rel == null ? rawAccessInClosure(LIB_ROOT, u.path, { configs, texts }) : rawAccessInClosure(root, u.path, { skip: LIB_ROOT, configs, texts })
|
|
40
41
|
return r == null ? null : rawAccessLine(r)
|
|
41
42
|
} catch (e) {
|
|
42
43
|
return `the modules ${u.rel ?? u.path} imports could not be listed (${String(e).slice(0, 120)}): declare inputs or never`
|
|
@@ -103,6 +104,9 @@ export type CheckRow = {
|
|
|
103
104
|
/** the wall clock ceiling it DECLARED, null when it named none and takes
|
|
104
105
|
* the default for its kind of body */
|
|
105
106
|
timeoutMs: number | null
|
|
107
|
+
/** what a healthy run takes, RESOLVED: the repo's `budgets`, else what
|
|
108
|
+
* the check declared, else the default for its kind (core/budget.ts) */
|
|
109
|
+
budgetMs: number
|
|
106
110
|
/** excluded from a default run unless named with `--with` */
|
|
107
111
|
optIn: boolean
|
|
108
112
|
/** an idempotent repair exists: `shipkit check --fix <id>` applies it,
|
|
@@ -150,7 +154,7 @@ const ttlMsOf = (ttl: string | undefined): number => (ttl == null || ttl === ''
|
|
|
150
154
|
|
|
151
155
|
/** the runnable half of a row: everything the runner needs, and nothing it
|
|
152
156
|
* has to look up again */
|
|
153
|
-
function runnableOf(row: CheckRow, body: CheckBody, o: { fix?: unknown; pointer?: string | null } = {}): RunnableCheck {
|
|
157
|
+
function runnableOf(row: CheckRow, body: CheckBody, o: { fix?: unknown; pointer?: string | null; unbudgeted?: boolean } = {}): RunnableCheck {
|
|
154
158
|
return {
|
|
155
159
|
id: row.decl.id,
|
|
156
160
|
title: row.title,
|
|
@@ -161,6 +165,7 @@ function runnableOf(row: CheckRow, body: CheckBody, o: { fix?: unknown; pointer?
|
|
|
161
165
|
deps: row.deps ?? [],
|
|
162
166
|
group: row.group,
|
|
163
167
|
timeoutMs: row.timeoutMs ?? (row.build ? BUILD_TIMEOUT_MS : CHECK_TIMEOUT_MS),
|
|
168
|
+
...(o.unbudgeted === true ? {} : { budgetMs: row.budgetMs }),
|
|
164
169
|
ttlMs: ttlMsOf(row.decl.ttl),
|
|
165
170
|
rotation: row.decl.extra,
|
|
166
171
|
writes: row.decl.writes === true,
|
|
@@ -251,6 +256,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
251
256
|
owner: t.cmd == null ? null : t.cmd.join(' '),
|
|
252
257
|
group: t.group ?? null,
|
|
253
258
|
timeoutMs: t.timeoutMs ?? null,
|
|
259
|
+
budgetMs: budgetOf(t.id, undefined, { build: true, cmd: t.cmd != null, world: t.ttl != null }, c.budgets),
|
|
254
260
|
optIn: t.optIn === true,
|
|
255
261
|
// a build step runs a TOOL, and repairing what a tool reports is
|
|
256
262
|
// that tool's own job (a formatter, a codemod), never the gate's
|
|
@@ -259,7 +265,10 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
259
265
|
deps: t.deps ?? null,
|
|
260
266
|
from: taskFrom(t.id),
|
|
261
267
|
}
|
|
262
|
-
|
|
268
|
+
// at the full tier a build step runs every slow suite on purpose, and
|
|
269
|
+
// its env (and so its cache key) differs from the quick run, so its
|
|
270
|
+
// time is not judged and its answer never stands in for a quick one
|
|
271
|
+
out.push({ row, runnable: runnableOf(row, taskBody(t), { unbudgeted: full }) })
|
|
263
272
|
}
|
|
264
273
|
// a slot `defaults: { <id>: false }` turned off never resolves to a task,
|
|
265
274
|
// so without this it had no row at all: no `runs in` value, no reason, and
|
|
@@ -277,6 +286,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
277
286
|
owner: null,
|
|
278
287
|
group: null,
|
|
279
288
|
timeoutMs: null,
|
|
289
|
+
budgetMs: budgetOf(id, undefined, { build: true, cmd: false, world: false }, c.budgets),
|
|
280
290
|
optIn: false,
|
|
281
291
|
fixable: false,
|
|
282
292
|
when: whenOfDisabledSlot(id),
|
|
@@ -290,6 +300,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
290
300
|
}
|
|
291
301
|
// every `*.check.ts` this repo and the toolkit declare
|
|
292
302
|
const configs = new Map<string, RvtConfig>()
|
|
303
|
+
const texts = new Map<string, string>()
|
|
293
304
|
for (const u of checks) {
|
|
294
305
|
const w = whenOfCheck(c, u)
|
|
295
306
|
// an in process body declaring nothing is keyed on what it reads
|
|
@@ -299,7 +310,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
299
310
|
const undeclared = u.def.run != null && u.def.inputs == null && u.def.ttl == null && u.def.never == null
|
|
300
311
|
// conservative: any module its body can reach that touches the disk or
|
|
301
312
|
// a process around `c` keeps it off its reads (core/raw-access.ts)
|
|
302
|
-
const raw = undeclared ? rawAccessOf(root, u, configs) : null
|
|
313
|
+
const raw = undeclared ? rawAccessOf(root, u, configs, texts) : null
|
|
303
314
|
const reads = undeclared && raw == null
|
|
304
315
|
const extra = checkCacheExtra({ id: u.id, cacheKey: u.def.cacheKey }, env, w)
|
|
305
316
|
const row: CheckRow = {
|
|
@@ -323,6 +334,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
323
334
|
owner: u.owner,
|
|
324
335
|
group: u.def.group ?? null,
|
|
325
336
|
timeoutMs: u.def.timeoutMs ?? null,
|
|
337
|
+
budgetMs: budgetOf(u.id, u.def.budgetMs, { build: u.def.build === true, cmd: u.def.cmd != null, world: u.def.ttl != null }, c.budgets),
|
|
326
338
|
optIn: false,
|
|
327
339
|
fixable: u.def.fix != null,
|
|
328
340
|
when: w,
|
|
@@ -358,6 +370,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
358
370
|
owner: r.owner,
|
|
359
371
|
group: null,
|
|
360
372
|
timeoutMs: null,
|
|
373
|
+
budgetMs: budgetOf(r.id, undefined, { build: false, cmd: false, world: r.ttl != null }, c.budgets),
|
|
361
374
|
optIn: false,
|
|
362
375
|
fixable: r.fix != null,
|
|
363
376
|
when: whenOfRule(c, r.id),
|
|
@@ -401,6 +414,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
401
414
|
owner: rel,
|
|
402
415
|
group: task?.group ?? null,
|
|
403
416
|
timeoutMs: task?.timeoutMs ?? null,
|
|
417
|
+
budgetMs: budgetOf(d.name, undefined, { build: false, cmd: true, world: d.ttl != null }, c.budgets),
|
|
404
418
|
optIn: false,
|
|
405
419
|
// the reader is a source scanner, so it cannot carry a function
|
|
406
420
|
fixable: false,
|
package/src/ledger/git-key.ts
CHANGED
|
@@ -173,6 +173,9 @@ export type KeyLayerOpts = {
|
|
|
173
173
|
cliEntry: string
|
|
174
174
|
/** module-graph config; loaded from the repo when omitted */
|
|
175
175
|
cfg?: RvtConfig
|
|
176
|
+
/** the checks this run executes. Only they are scanned and hashed, and any
|
|
177
|
+
* other id gets no key. Omitted = every check */
|
|
178
|
+
scope?: ReadonlySet<string>
|
|
176
179
|
}
|
|
177
180
|
|
|
178
181
|
/**
|
|
@@ -191,6 +194,7 @@ export class KeyLayer {
|
|
|
191
194
|
private readonly codeOids: Map<string, string>,
|
|
192
195
|
private readonly toolchain: { id: string; uncacheable: string[] },
|
|
193
196
|
private readonly scanPathsUsed: string[],
|
|
197
|
+
private readonly scope: ReadonlySet<string> | null,
|
|
194
198
|
/** wall time of the WHOLE layer: toolchain identity, the module-graph
|
|
195
199
|
* closures, the git scan and the closure hashing. Reported rather
|
|
196
200
|
* than the scan alone, because the scan alone is the flattering
|
|
@@ -237,7 +241,8 @@ export class KeyLayer {
|
|
|
237
241
|
const closureByCheck = new Map<string, string[]>()
|
|
238
242
|
// computed for EVERY check with code, not only the content ones: a
|
|
239
243
|
// world check's TTL cache must break when the probe itself is rewritten
|
|
240
|
-
|
|
244
|
+
const scoped = o.scope == null ? decls : decls.filter((d) => o.scope!.has(d.id))
|
|
245
|
+
for (const d of scoped) {
|
|
241
246
|
const entries = d.code ?? []
|
|
242
247
|
if (entries.length > 0) closureByCheck.set(d.id, importClosure(cfg, entries))
|
|
243
248
|
}
|
|
@@ -246,14 +251,13 @@ export class KeyLayer {
|
|
|
246
251
|
// are mostly directories, mostly unchanged, and git's stat cache
|
|
247
252
|
// answers them without reading a byte.
|
|
248
253
|
const paths = new Set<string>()
|
|
249
|
-
for (const d of
|
|
254
|
+
for (const d of scoped) {
|
|
250
255
|
if (classify(d).population !== 'content') continue
|
|
251
256
|
for (const p of d.inputs ?? []) paths.add(p)
|
|
252
257
|
}
|
|
253
258
|
|
|
254
259
|
// the closure is INDIVIDUAL FILES belonging to no declared path, so it
|
|
255
|
-
//
|
|
256
|
-
// add` spawn per file (measured: 15ms for the whole set)
|
|
260
|
+
// is hashed in process (hashFiles) instead of staged one `git add` each
|
|
257
261
|
const closureFiles = new Set<string>()
|
|
258
262
|
for (const files of closureByCheck.values()) for (const f of files) closureFiles.add(f)
|
|
259
263
|
|
|
@@ -268,7 +272,7 @@ export class KeyLayer {
|
|
|
268
272
|
// downstream may key on it. Loud, and the whole run goes uncached.
|
|
269
273
|
scanError = e instanceof GitScanError ? e.message : String(e)
|
|
270
274
|
}
|
|
271
|
-
return new KeyLayer(root, decls, registration, scan, scanError, closureByCheck, codeOids, toolchain, [...paths].sort(), performance.now() - started)
|
|
275
|
+
return new KeyLayer(root, decls, registration, scan, scanError, closureByCheck, codeOids, toolchain, [...paths].sort(), o.scope ?? null, performance.now() - started)
|
|
272
276
|
}
|
|
273
277
|
|
|
274
278
|
/** the key for one check, or the reason there is none. */
|
|
@@ -284,6 +288,7 @@ export class KeyLayer {
|
|
|
284
288
|
codeKey: own.length === 0 || !ownReadable ? null : createHash('sha256').update(codeLines.join('\n')).digest('hex').slice(0, 16),
|
|
285
289
|
}
|
|
286
290
|
if (c.error != null) return { ...base, reason: c.error }
|
|
291
|
+
if (this.scope != null && !this.scope.has(d.id)) return { ...base, reason: 'not one of the checks this run executes, so nothing was scanned for it' }
|
|
287
292
|
if (c.population === 'world') return { ...base, reason: `world check — TTL ${d.ttl}, no content key` }
|
|
288
293
|
if (c.population === 'none') return { ...base, reason: c.reason ?? 'undeclared' }
|
|
289
294
|
if (this.scanError != null) return { ...base, reason: `git scan failed, nothing may be keyed: ${this.scanError}` }
|
package/src/ledger/git-scan.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { isAbsolute, join, normalize, resolve } from 'path'
|
|
|
35
35
|
import { probeGitRoot } from '../core/git-root.ts'
|
|
36
36
|
import { treePrefix } from '../core/nesting.ts'
|
|
37
37
|
import { gitBin } from '../core/git-bin.ts'
|
|
38
|
+
import { blobOid } from '../core/read-set.ts'
|
|
38
39
|
|
|
39
40
|
/** what a scan measured. Every OID is a git object id of the CURRENT disk
|
|
40
41
|
* content, tracked or not. */
|
|
@@ -59,6 +60,14 @@ export type ScanResult = {
|
|
|
59
60
|
|
|
60
61
|
export class GitScanError extends Error {}
|
|
61
62
|
|
|
63
|
+
export type ScanOpts = {
|
|
64
|
+
/** the caller reads the whole tree's id and nothing else, so git is never
|
|
65
|
+
* asked what it ignores: that answer only feeds force-adds and the
|
|
66
|
+
* excluded list, and `git add -- .` leaves ignored files out by itself.
|
|
67
|
+
* `excluded` and `ignored` come back empty */
|
|
68
|
+
treeOnly?: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
/** the scratch index FORMAT. Bump it whenever what the scan stages changes.
|
|
63
72
|
* The index is persistent, and anything staged into it stays there until
|
|
64
73
|
* something stages over it: a run that force-added a path a later version no
|
|
@@ -208,10 +217,15 @@ const underNodeModules = (p: string): boolean => p.split('/').includes('node_mod
|
|
|
208
217
|
* stale describes files nobody has, and a key built on it is a false green
|
|
209
218
|
* waiting for its first hit.
|
|
210
219
|
*/
|
|
211
|
-
export function scanPaths(root: string, rawPaths: string[], prior?: ScanResult): ScanResult {
|
|
220
|
+
export function scanPaths(root: string, rawPaths: string[], prior?: ScanResult, o: ScanOpts = {}): ScanResult {
|
|
212
221
|
const started = performance.now()
|
|
222
|
+
if (o.treeOnly === true && (rawPaths.length !== 1 || normalizeInput(rawPaths[0]!) !== WHOLE_TREE))
|
|
223
|
+
throw new GitScanError(`treeOnly scans the whole tree and nothing else, asked for: ${rawPaths.join(', ')}`)
|
|
213
224
|
const { scratch: indexFile, ignored: ignoredIndex, real: realIndex } = indexPaths(root)
|
|
214
225
|
const wanted = [...new Set(rawPaths.map(normalizeInput))].sort()
|
|
226
|
+
// nothing asked for: the repo is still validated above, and no tree is
|
|
227
|
+
// written that nobody reads (a run of read checks declares no path)
|
|
228
|
+
if (wanted.length === 0) return { treeOid: '', oidByPath: new Map(), missing: [], excluded: new Map(), ignored: [], ignoredTreeOid: null, ms: performance.now() - started }
|
|
215
229
|
const missing = wanted.filter((p) => !existsSync(join(root, p)))
|
|
216
230
|
const present = wanted.filter((p) => existsSync(join(root, p)))
|
|
217
231
|
const toAdd = dropCovered(present)
|
|
@@ -227,7 +241,9 @@ export function scanPaths(root: string, rawPaths: string[], prior?: ScanResult):
|
|
|
227
241
|
// A path that became ignored in between makes its plain add fail, which
|
|
228
242
|
// throws, and a throw stores nothing: the one failure mode stays loud.
|
|
229
243
|
const sameMissing = prior != null && prior.missing.join('\n') === [...new Set(missing)].sort().join('\n')
|
|
230
|
-
const ignored =
|
|
244
|
+
const ignored = o.treeOnly === true
|
|
245
|
+
? []
|
|
246
|
+
: prior != null && sameMissing
|
|
231
247
|
? prior.ignored
|
|
232
248
|
: toAdd.length === 0
|
|
233
249
|
? []
|
|
@@ -363,14 +379,16 @@ function excludedBy(wanted: string[], ignored: string[], forced: Set<string>): M
|
|
|
363
379
|
}
|
|
364
380
|
|
|
365
381
|
/**
|
|
366
|
-
* blob ids for a list of FILES,
|
|
382
|
+
* blob ids for a list of FILES, hashed in process: no git, no index, no lock.
|
|
367
383
|
*
|
|
368
384
|
* The scratch index is the right tool for a declared input, which is usually a
|
|
369
385
|
* directory and usually unchanged (git's stat cache answers without reading).
|
|
370
386
|
* It is the wrong tool for the module-graph closure: those are individual
|
|
371
|
-
* files, they belong to no declared path, and
|
|
372
|
-
*
|
|
373
|
-
*
|
|
387
|
+
* files, they belong to no declared path, and a check's code is a handful of
|
|
388
|
+
* small sources that cost less to read than one spawn. The id is the blob id
|
|
389
|
+
* of the bytes on disk, the same one the read recorder uses (`blobOid`). Git
|
|
390
|
+
* would apply clean filters (eol, lfs) first, so this id is only ever STRICTER:
|
|
391
|
+
* a filter-only change is a miss, never a false hit.
|
|
374
392
|
*
|
|
375
393
|
* A file that is not on disk is skipped and reported, never guessed at.
|
|
376
394
|
*/
|
|
@@ -379,16 +397,10 @@ export function hashFiles(root: string, files: string[]): { oids: Map<string, st
|
|
|
379
397
|
const wanted = [...new Set(files.map(normalizeInput))].sort()
|
|
380
398
|
const missing = wanted.filter((f) => !existsSync(join(root, f)))
|
|
381
399
|
const present = wanted.filter((f) => existsSync(join(root, f)))
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
stderr: 'pipe',
|
|
388
|
-
})
|
|
389
|
-
if (p.exitCode !== 0) throw new GitScanError(`git hash-object exited ${p.exitCode}: ${p.stderr.toString().trim().split('\n').slice(0, 3).join(' / ')}`)
|
|
390
|
-
const lines = p.stdout.toString().split('\n').filter((l) => l !== '')
|
|
391
|
-
if (lines.length !== present.length) throw new GitScanError(`git hash-object answered ${lines.length} of ${present.length} files`)
|
|
392
|
-
present.forEach((f, i) => oids.set(f, lines[i]!.trim()))
|
|
400
|
+
for (const f of present) {
|
|
401
|
+
try { oids.set(f, blobOid(readFileSync(join(root, f)))) } catch (e) {
|
|
402
|
+
throw new GitScanError(`cannot read ${f} to hash it: ${e instanceof Error ? e.message : String(e)}`)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
393
405
|
return { oids, missing }
|
|
394
406
|
}
|
package/src/ledger/ledger.ts
CHANGED
|
@@ -92,6 +92,8 @@ export type LedgerOpts = {
|
|
|
92
92
|
fresh?: boolean
|
|
93
93
|
/** module-graph config, loaded from the repo when omitted */
|
|
94
94
|
cfg?: RvtConfig
|
|
95
|
+
/** the checks this run executes (KeyLayerOpts.scope) */
|
|
96
|
+
scope?: ReadonlySet<string>
|
|
95
97
|
now?: () => number
|
|
96
98
|
}
|
|
97
99
|
|
|
@@ -133,7 +135,7 @@ export class Ledger {
|
|
|
133
135
|
) {}
|
|
134
136
|
|
|
135
137
|
static open(root: string, decls: CheckDecl[], o: LedgerOpts): Ledger {
|
|
136
|
-
const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg })
|
|
138
|
+
const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg, scope: o.scope })
|
|
137
139
|
const fresh = o.fresh === true || process.env[FRESH_ENV] != null
|
|
138
140
|
const reading = (o.read ?? ledgerReadEnabled()) && !fresh
|
|
139
141
|
return new Ledger(layer, storeRoot(root), reading, o.now ?? Date.now)
|
|
@@ -166,7 +168,7 @@ export class Ledger {
|
|
|
166
168
|
if (!this.reading) { this.misses++; return null }
|
|
167
169
|
const e = readStored<Stored>(this.path(k), k.key)
|
|
168
170
|
if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; return null }
|
|
169
|
-
if (k.population === 'reads' && (e.payload.reads == null || this.driftOf(e.payload.reads, this.view()) != null)) { this.misses++; return null }
|
|
171
|
+
if (k.population === 'reads' && (e.payload.reads == null || this.driftOf(e.payload.reads, () => this.view()) != null)) { this.misses++; return null }
|
|
170
172
|
this.hits++
|
|
171
173
|
return e.payload
|
|
172
174
|
}
|
|
@@ -178,8 +180,14 @@ export class Ledger {
|
|
|
178
180
|
return readStored<Stored>(this.path(k), k.key)?.payload.reads ?? null
|
|
179
181
|
}
|
|
180
182
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
+
/** the view is asked for only when a glob needs it: a read set with no
|
|
184
|
+
* listing is validated on file content alone, and a whole tree git scan
|
|
185
|
+
* per replay would buy nothing */
|
|
186
|
+
private driftOf(reads: ReadSet, view: () => RepoView): string | null {
|
|
187
|
+
return readSetDrift(this.layer.root, reads, (g) => {
|
|
188
|
+
const v = view()
|
|
189
|
+
return v.fallback != null ? null : v.list(g)
|
|
190
|
+
})
|
|
183
191
|
}
|
|
184
192
|
|
|
185
193
|
/** where askers of this check's exact question meet while one of them is
|
|
@@ -230,11 +238,11 @@ export class Ledger {
|
|
|
230
238
|
// a fresh list, never this run's: the question is whether the tree
|
|
231
239
|
// still is what the body read
|
|
232
240
|
let fresh: RepoView | null = null
|
|
241
|
+
const freshView = (): RepoView => (fresh ??= RepoView.open(this.layer.root, this.viewMemo))
|
|
233
242
|
for (const [id, outcome] of this.buffered) {
|
|
234
243
|
if (touched.has(id)) { discarded.push(id); continue }
|
|
235
244
|
if (outcome.reads != null) {
|
|
236
|
-
|
|
237
|
-
const moved = this.driftOf(outcome.reads, fresh)
|
|
245
|
+
const moved = this.driftOf(outcome.reads, freshView)
|
|
238
246
|
if (moved != null) { discarded.push(id); drifted.push(moved); continue }
|
|
239
247
|
}
|
|
240
248
|
const k = this.key(id)
|
package/src/ledger/repo-view.ts
CHANGED
|
@@ -23,12 +23,12 @@ export class RepoView {
|
|
|
23
23
|
readonly scan: ScanResult | null = null,
|
|
24
24
|
) {}
|
|
25
25
|
|
|
26
|
-
/** `prior`: an earlier view of the same root in this run.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
/** `prior`: an earlier view of the same root in this run. The tree is
|
|
27
|
+
* still staged and hashed fresh, so the list is today's, and an unchanged
|
|
28
|
+
* tree reuses the prior's answer */
|
|
29
29
|
static open(root: string, prior?: RepoView | null): RepoView {
|
|
30
30
|
try {
|
|
31
|
-
const scan = scanPaths(root, [WHOLE_TREE], prior?.scan ?? undefined)
|
|
31
|
+
const scan = scanPaths(root, [WHOLE_TREE], prior?.scan ?? undefined, { treeOnly: true })
|
|
32
32
|
const tree = scan.oidByPath.get(WHOLE_TREE)
|
|
33
33
|
if (tree == null) throw new GitScanError('git could not name the work tree')
|
|
34
34
|
return new RepoView(filesOf(root, tree), null, scan)
|
|
@@ -45,14 +45,16 @@ export class RepoView {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/** a tree id names its content, so its file list is a pure function of the
|
|
48
|
-
* id and one listing serves every view of that tree
|
|
48
|
+
* id and one listing serves every view of that tree. `--full-tree` keeps it
|
|
49
|
+
* so: without it git filters the listing by the cwd's path under the work
|
|
50
|
+
* tree, and a nested folder's own subtree lists nothing */
|
|
49
51
|
const listed = new Map<string, string[]>()
|
|
50
52
|
const LISTED_MAX = 64
|
|
51
53
|
|
|
52
54
|
function filesOf(root: string, tree: string): string[] {
|
|
53
55
|
const hit = listed.get(tree)
|
|
54
56
|
if (hit != null) return hit
|
|
55
|
-
const p = Bun.spawnSync([gitBin(), 'ls-tree', '-r', '-z', '--name-only', tree], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
57
|
+
const p = Bun.spawnSync([gitBin(), 'ls-tree', '--full-tree', '-r', '-z', '--name-only', tree], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
56
58
|
if (p.exitCode !== 0) throw new GitScanError(`git ls-tree exited ${p.exitCode}: ${p.stderr.toString().trim()}`)
|
|
57
59
|
const files = p.stdout.toString().split('\0').filter((f) => f !== '').sort()
|
|
58
60
|
if (listed.size >= LISTED_MAX) listed.delete(listed.keys().next().value!)
|
package/src/ledger/run-log.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { Database } from 'bun:sqlite'
|
|
22
22
|
import { existsSync } from 'fs'
|
|
23
23
|
import type { Line } from '../core/wire.ts'
|
|
24
|
-
import { dbPath, INSERT_RUN, openDb, UPSERT_INVOCATION, UPSERT_LAST, UPSERT_LIVE } from './db.ts'
|
|
24
|
+
import { BUSY_MS, dbPath, INSERT_RUN, openDb, UPSERT_INVOCATION, UPSERT_LAST, UPSERT_LIVE } from './db.ts'
|
|
25
25
|
import { LIVE_BEAT_MS, liveRowDead, type LiveRow, type LiveState } from './live-runs.ts'
|
|
26
26
|
import { KEEP_MS } from './store.ts'
|
|
27
27
|
|
|
@@ -187,10 +187,16 @@ export class RunLog {
|
|
|
187
187
|
const hadLive = this.liveSent !== ''
|
|
188
188
|
this.quiet()
|
|
189
189
|
this.liveSent = ''
|
|
190
|
-
|
|
190
|
+
// the live row's handle writes the run too: an open and a close are
|
|
191
|
+
// most of what a write costs. It waits like a fresh one would
|
|
192
|
+
const live = this.liveDb
|
|
191
193
|
this.liveDb = null
|
|
192
|
-
if (this.path === '' || (rows.length === 0 && last.length === 0 && !hadLive)) return []
|
|
193
|
-
|
|
194
|
+
if (this.path === '' || (rows.length === 0 && last.length === 0 && !hadLive)) { try { live?.close() } catch { /* already gone */ } return [] }
|
|
195
|
+
let opened: { db: Database } | null = null
|
|
196
|
+
if (live != null) {
|
|
197
|
+
try { live.exec(`pragma busy_timeout = ${Math.max(0, Math.round(this.busyMs ?? BUSY_MS))}`); opened = { db: live } } catch { try { live.close() } catch { /* already gone */ } }
|
|
198
|
+
}
|
|
199
|
+
opened ??= openDb(this.path, { busyMs: this.busyMs })
|
|
194
200
|
if (opened == null) return unwritable(this.path)
|
|
195
201
|
try {
|
|
196
202
|
const insert = opened.db.query(INSERT_RUN)
|
|
@@ -45,7 +45,9 @@ export type TimelineInvocation = {
|
|
|
45
45
|
/** an invocation in a list: its numbers without its lanes */
|
|
46
46
|
export type InvocationSummary = Omit<TimelineInvocation, 'bars'> & { checks: number }
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
/** `over` its ceiling, `near` it (past half), or over its `budget` on the
|
|
49
|
+
* mean of its quick tier runs */
|
|
50
|
+
export type SlowCheck = { id: string; meanMs: number; worstMs: number; samples: number; ceilingMs: number | null; budgetMs: number | null; flag: 'over' | 'near' | 'budget' | null }
|
|
49
51
|
|
|
50
52
|
export type SuiteToFix = {
|
|
51
53
|
check: string; unit: string; file: string; at: number; ms: number; budgetMs: number; tier: 'quick' | 'full'; red: boolean; slow: boolean
|
|
@@ -72,6 +74,8 @@ export type TimelineInput = {
|
|
|
72
74
|
suites: SuiteRow[]
|
|
73
75
|
/** each check's kill time, ms */
|
|
74
76
|
ceilings: Record<string, number>
|
|
77
|
+
/** each check's resolved budget (core/budget.ts), for the slowest list */
|
|
78
|
+
budgets?: Record<string, number>
|
|
75
79
|
/** the checks the repo declares today. Given, `slowest` lists only these:
|
|
76
80
|
* a deleted check's rows stay in the run history, and no edit can make it
|
|
77
81
|
* faster */
|
|
@@ -120,23 +124,31 @@ export function invocationsOf(runs: RunRow[], invocations: InvocationRow[]): Tim
|
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
/** the checks that cost the most when they really run: mean and worst of the
|
|
123
|
-
* MEASURED runs, flagged when the worst reached its ceiling or half of it
|
|
124
|
-
|
|
127
|
+
* MEASURED runs, flagged when the worst reached its ceiling or half of it,
|
|
128
|
+
* else when the mean of its QUICK tier runs is over its budget (the full
|
|
129
|
+
* tier runs every slow suite on purpose, and is never judged) */
|
|
130
|
+
export function slowestChecks(runs: RunRow[], ceilings: Record<string, number>, size = TIMELINE_LIST, inventory?: readonly string[], budgets: Record<string, number> = {}): SlowCheck[] {
|
|
125
131
|
const live = inventory == null ? null : new Set(inventory)
|
|
126
132
|
const ms = new Map<string, number[]>()
|
|
133
|
+
const quick = new Map<string, number[]>()
|
|
134
|
+
const add = (m: Map<string, number[]>, id: string, v: number): void => { m.set(id, [...(m.get(id) ?? []), v]) }
|
|
127
135
|
for (const r of runs) {
|
|
128
136
|
if (r.replayed || r.status === 'skip') continue
|
|
129
137
|
if (live != null && !live.has(r.id)) continue
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
ms.set(r.id, list)
|
|
138
|
+
add(ms, r.id, r.ms)
|
|
139
|
+
if (r.full !== true) add(quick, r.id, r.ms)
|
|
133
140
|
}
|
|
141
|
+
const mean = (list: number[]): number => Math.round(list.reduce((a, b) => a + b, 0) / list.length)
|
|
134
142
|
const out: SlowCheck[] = []
|
|
135
143
|
for (const [id, list] of ms) {
|
|
136
144
|
const worstMs = Math.max(...list)
|
|
137
145
|
const ceilingMs = ceilings[id] ?? null
|
|
138
|
-
const
|
|
139
|
-
|
|
146
|
+
const budgetMs = budgets[id] ?? null
|
|
147
|
+
const q = quick.get(id) ?? []
|
|
148
|
+
const flag = ceilingMs != null && worstMs >= ceilingMs ? 'over'
|
|
149
|
+
: ceilingMs != null && worstMs > ceilingMs * NEAR_CEILING_SHARE ? 'near'
|
|
150
|
+
: budgetMs != null && q.length > 0 && mean(q) > budgetMs ? 'budget' : null
|
|
151
|
+
out.push({ id, meanMs: mean(list), worstMs, samples: list.length, ceilingMs, budgetMs, flag })
|
|
140
152
|
}
|
|
141
153
|
return out.sort((a, b) => b.meanMs - a.meanMs || a.id.localeCompare(b.id)).slice(0, size)
|
|
142
154
|
}
|
|
@@ -164,7 +176,7 @@ export function shapeTimeline(o: TimelineInput): Timeline {
|
|
|
164
176
|
const size = o.listSize ?? TIMELINE_LIST
|
|
165
177
|
return {
|
|
166
178
|
invocations: all.slice(0, o.keep ?? TIMELINE_INVOCATIONS),
|
|
167
|
-
slowest: slowestChecks(o.runs, o.ceilings, size, o.inventory),
|
|
179
|
+
slowest: slowestChecks(o.runs, o.ceilings, size, o.inventory, o.budgets),
|
|
168
180
|
longest: [...all].sort((a, b) => b.wallMs - a.wallMs || b.started - a.started).slice(0, size).map(({ bars, ...rest }) => ({ ...rest, checks: bars.length })),
|
|
169
181
|
testFiles: testFilesToFix(o.suites, o.declaredSlow),
|
|
170
182
|
suitesRecorded: o.suites.length > 0,
|
|
@@ -2,10 +2,21 @@
|
|
|
2
2
|
// asked about three checks replaces those three and leaves the rest standing:
|
|
3
3
|
// vscode clears the whole pane at every frame, so a narrowed run that printed
|
|
4
4
|
// only its own findings would erase every other check's.
|
|
5
|
-
import {
|
|
5
|
+
import type { HubProblem } from '../serve/hub-problems.ts'
|
|
6
|
+
import { frameLines, hubProblemLine, type Problem } from './problem-line.ts'
|
|
6
7
|
|
|
7
8
|
export class ProblemBoard {
|
|
8
9
|
private readonly byCheck = new Map<string, Problem[]>()
|
|
10
|
+
private hub: Problem[] = []
|
|
11
|
+
|
|
12
|
+
/** what is wrong with the hub itself (serve/hub-problems.ts), which no
|
|
13
|
+
* check owns. Returns whether the board moved */
|
|
14
|
+
setHub(problems: HubProblem[]): boolean {
|
|
15
|
+
const next = problems.map(hubProblemLine)
|
|
16
|
+
const moved = JSON.stringify(next) !== JSON.stringify(this.hub)
|
|
17
|
+
this.hub = next
|
|
18
|
+
return moved
|
|
19
|
+
}
|
|
9
20
|
|
|
10
21
|
/** a check reached a verdict. What it carries has already passed the pane
|
|
11
22
|
* rule (problem-line.ts feedsProblemsPane), so an empty list clears it */
|
|
@@ -17,7 +28,7 @@ export class ProblemBoard {
|
|
|
17
28
|
/** every finding standing, sorted by file then line, so a frame reads the
|
|
18
29
|
* same whatever order the checks finished in */
|
|
19
30
|
all(): Problem[] {
|
|
20
|
-
return [...this.byCheck.values()].flat().sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col || a.check.localeCompare(b.check))
|
|
31
|
+
return [...this.hub, ...this.byCheck.values()].flat().sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col || a.check.localeCompare(b.check))
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
/** one check's verdict, as the runner reports it. A skip is no verdict
|
|
@@ -39,6 +39,15 @@ export const ENDS_PATTERN = `^${FRAME_END_HEAD}\\b`
|
|
|
39
39
|
* (`imports:check`) both split right */
|
|
40
40
|
export const PROBLEM_PATTERN = '^(.+?):(\\d+):(\\d+): (error|warning) (\\S+?): (.*)$'
|
|
41
41
|
|
|
42
|
+
/** where a problem of the hub itself is anchored: it has no file, and the
|
|
43
|
+
* matcher needs one, so it sits on line 1 of the file that declares the
|
|
44
|
+
* hub. Check id `hub` */
|
|
45
|
+
export const HUB_ANCHOR = 'repo.config.ts'
|
|
46
|
+
|
|
47
|
+
export function hubProblemLine(p: { text: string; severity: 'red' | 'yellow' }): Problem {
|
|
48
|
+
return { file: HUB_ANCHOR, line: 1, col: 1, severity: p.severity === 'red' ? 'error' : 'warning', check: 'hub', message: p.text }
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
export function formatProblem(p: Problem): string {
|
|
43
52
|
const message = p.message.replace(/\s*\n\s*/g, ' ').trim()
|
|
44
53
|
return `${p.file}:${p.line}:${p.col}: ${p.severity} ${p.check}: ${message}`
|
package/src/repo-config.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { validateSmokeConfig, type SmokeConfig } from './smoke/run-smoke.ts'
|
|
|
20
20
|
import { RULE_ID_RE, validateRules } from './rules/rule.ts'
|
|
21
21
|
import { builtinCheckIds } from './core/discover-checks.ts'
|
|
22
22
|
import { parseWhen } from './core/when.ts'
|
|
23
|
+
import { budgetValueProblem } from './core/budget.ts'
|
|
23
24
|
import type { Rule } from './check.ts'
|
|
24
25
|
import { leakAllowProblems } from './hooks/leak-guard.ts'
|
|
25
26
|
import type { HooksConfig } from './hooks/run-hooks.ts'
|
|
@@ -104,6 +105,14 @@ export type RepoConfig = {
|
|
|
104
105
|
* Its last recorded run, from a gate or a health pass, is the secret's
|
|
105
106
|
* verdict. Absent: no check probes it, and the row says so */
|
|
106
107
|
probedBy?: string
|
|
108
|
+
/** how much breaks without it, which decides its colour when it is
|
|
109
|
+
* missing or its probe fails. `core`: the toolkit's own features stop
|
|
110
|
+
* (a push, creating a repo, a release), so it is red, and a missing
|
|
111
|
+
* one reaches `shipkit needs` as red. `used`: a feature this repo uses today, so
|
|
112
|
+
* it is orange, a needs line that never pings. `minor`: proactive,
|
|
113
|
+
* for something nothing uses yet, so it is dim and stays out of
|
|
114
|
+
* `shipkit needs`. Default `used` */
|
|
115
|
+
level?: 'core' | 'used' | 'minor'
|
|
107
116
|
}[]
|
|
108
117
|
/** store-command template for a missing secret tile, `{name}` substituted, as in your secret manager's set command */
|
|
109
118
|
credsCommand?: string
|
|
@@ -144,6 +153,12 @@ export type RepoConfig = {
|
|
|
144
153
|
* reason, and on an id no check carries. The hub writes this
|
|
145
154
|
* map through the managed block in this file (core/when-block.ts). */
|
|
146
155
|
when?: Record<string, string>
|
|
156
|
+
/** WHAT A HEALTHY RUN OF EACH CHECK TAKES, in ms, keyed by check id. A
|
|
157
|
+
* check that passes over its budget prints a WARN and reads yellow, and
|
|
158
|
+
* never blocks. It wins over the check's own `budgetMs` and the default
|
|
159
|
+
* for its kind (core/budget.ts). An id no check carries fails the run,
|
|
160
|
+
* the same as a `when` entry. */
|
|
161
|
+
budgets?: Record<string, number>
|
|
147
162
|
/** DEPRECATED, read into `when` as `off:<reason>`: rule id → the reason
|
|
148
163
|
* this repo breaks that invariant on purpose. An exempted rule does not
|
|
149
164
|
* lint and prints a skip line carrying the reason. `defineRepo` throws on
|
|
@@ -241,7 +256,7 @@ export type RepoConfig = {
|
|
|
241
256
|
logDir?: string
|
|
242
257
|
}
|
|
243
258
|
|
|
244
|
-
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke', 'tests'])
|
|
259
|
+
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke', 'tests'])
|
|
245
260
|
|
|
246
261
|
export function defineRepo(c: RepoConfig): RepoConfig {
|
|
247
262
|
if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
|
|
@@ -319,6 +334,12 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
319
334
|
if (RULE_ID_RE.test(id) && !builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
|
|
320
335
|
throw new Error(`defineRepo: when.${id} names no rule, the ids are in \`shipkit checks\``)
|
|
321
336
|
}
|
|
337
|
+
// a budget turns a slow pass yellow, so a value it cannot compare with dies here
|
|
338
|
+
for (const [id, v] of Object.entries(c.budgets ?? {})) {
|
|
339
|
+
if (id.trim() === '') throw new Error(`defineRepo: budgets has an entry with no check id`)
|
|
340
|
+
const bad = budgetValueProblem(v)
|
|
341
|
+
if (bad != null) throw new Error(`defineRepo: budgets.${id} ${bad}`)
|
|
342
|
+
}
|
|
322
343
|
// an exemption silences a gate, so it names a rule that exists and says why
|
|
323
344
|
for (const [id, reason] of Object.entries(c.ruleExempt ?? {})) {
|
|
324
345
|
if (!builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
|
|
@@ -47,10 +47,10 @@ const VERDICT_WORD: Record<'pass' | 'warn' | 'fail' | 'skip', string> = {
|
|
|
47
47
|
// ── the pieces ───────────────────────────────────────────────────────────
|
|
48
48
|
|
|
49
49
|
/** one duration, in the one unit, in the one tone */
|
|
50
|
-
const Dur = ({ ms, ceiling }: { ms: number | null | undefined; ceiling?: number }) =>
|
|
50
|
+
const Dur = ({ ms, ceiling, budget }: { ms: number | null | undefined; ceiling?: number; budget?: number }) =>
|
|
51
51
|
ms == null
|
|
52
52
|
? <span className="dim">not timed yet</span>
|
|
53
|
-
: <span className={`dnum ${durationTone(ms, ceiling) ?? ''}`}>{fmtMs(ms)}</span>
|
|
53
|
+
: <span className={`dnum ${durationTone(ms, ceiling, budget) ?? ''}`}>{fmtMs(ms)}</span>
|
|
54
54
|
|
|
55
55
|
const MdHelp = ({ text }: { text: string }) => <Help text={<span className="md" dangerouslySetInnerHTML={{ __html: renderMd(text) }} />} />
|
|
56
56
|
|
|
@@ -177,14 +177,14 @@ const RunWhen = observer(({ app, u }: { app: HubSt; u: CheckFacts }) => {
|
|
|
177
177
|
|
|
178
178
|
/** the last N outcomes as a bar row: an average hides the shape, and the
|
|
179
179
|
* reds it cannot even see */
|
|
180
|
-
const Bars = ({ runs, ceiling }: { runs: RecentRun[]; ceiling: number }) => {
|
|
180
|
+
const Bars = ({ runs, ceiling, budget }: { runs: RecentRun[]; ceiling: number; budget: number }) => {
|
|
181
181
|
if (runs.length === 0) return <div className="dim">no run on record yet</div>
|
|
182
182
|
const top = Math.max(...runs.map((r) => r.ms), 1)
|
|
183
183
|
return (
|
|
184
184
|
<div className="bars">
|
|
185
185
|
{runs.map((r, n) => (
|
|
186
186
|
<i key={`${r.at}-${n}`}
|
|
187
|
-
className={`bar ${r.replayed ? 'rep' : r.status === 'fail' ? 'red' : r.status === 'warn' ? 'warn' : r.status === 'skip' ? 'rep' : durationTone(r.ms, ceiling) ?? 'plain'}`}
|
|
187
|
+
className={`bar ${r.replayed ? 'rep' : r.status === 'fail' ? 'red' : r.status === 'warn' ? 'warn' : r.status === 'skip' ? 'rep' : durationTone(r.ms, ceiling, budget) ?? 'plain'}`}
|
|
188
188
|
style={{ height: `${r.replayed ? 8 : Math.max(10, Math.round((r.ms / top) * 100))}%` }}
|
|
189
189
|
title={`${stampWords(r.at)}: ${r.replayed ? CACHED : `${VERDICT_WORD[r.status]} in ${fmtMs(r.ms)}`}`} />
|
|
190
190
|
))}
|
|
@@ -200,22 +200,23 @@ export const LastRunValue = observer(({ app, u }: { app: HubSt; u: CheckFacts })
|
|
|
200
200
|
<>
|
|
201
201
|
<span className={`tl ${l.tone}`}>{l.word}</span>
|
|
202
202
|
{u.lastAt == null ? null : <> <TimeAgo clock={app.clock} at={u.lastAt} /></>}
|
|
203
|
-
{u.lastMs == null || u.replayed ? null : <> in <Dur ms={u.lastMs} ceiling={u.timeoutMs} /></>}
|
|
203
|
+
{u.lastMs == null || u.replayed ? null : <> in <Dur ms={u.lastMs} ceiling={u.timeoutMs} budget={u.budgetMs} /></>}
|
|
204
204
|
{l.verdict == null ? null : <span className={`tl ${l.tone}`}>, {l.verdict}</span>}
|
|
205
205
|
</>
|
|
206
206
|
)
|
|
207
207
|
})
|
|
208
208
|
|
|
209
209
|
export const Timings = ({ u }: { u: CheckFacts }) => (
|
|
210
|
-
<Row k="timings" wide help="what this check costs: its last run, the mean of the runs that really ran, the worst one on record, and the limit it is killed at">
|
|
210
|
+
<Row k="timings" wide help="what this check costs: its last run, the mean of the runs that really ran, the worst one on record, what a healthy run takes, and the limit it is killed at">
|
|
211
211
|
<span className="ctimes">
|
|
212
|
-
<span><span className="ck">last</span> <Dur ms={u.lastMs} ceiling={u.timeoutMs} /></span>
|
|
213
|
-
<span><span className="ck">average</span> {u.samples === 0 ? <span className="dim">not timed yet</span> : <><Dur ms={u.avgMs} ceiling={u.timeoutMs} /> <span className="dim">over {u.samples}</span></>}</span>
|
|
214
|
-
<span><span className="ck">slowest</span> <Dur ms={u.slowestMs} ceiling={u.timeoutMs} />{u.failures > 0 ? <span className="dim">, {u.failures} red</span> : null}</span>
|
|
215
|
-
{/* a limit, never
|
|
212
|
+
<span><span className="ck">last</span> <Dur ms={u.lastMs} ceiling={u.timeoutMs} budget={u.budgetMs} /></span>
|
|
213
|
+
<span><span className="ck">average</span> {u.samples === 0 ? <span className="dim">not timed yet</span> : <><Dur ms={u.avgMs} ceiling={u.timeoutMs} budget={u.budgetMs} /> <span className="dim">over {u.samples}</span></>}</span>
|
|
214
|
+
<span><span className="ck">slowest</span> <Dur ms={u.slowestMs} ceiling={u.timeoutMs} budget={u.budgetMs} />{u.failures > 0 ? <span className="dim">, {u.failures} red</span> : null}</span>
|
|
215
|
+
{/* a budget and a limit, never measurements: they wear no tone of their own */}
|
|
216
|
+
<span><span className="ck">budget</span> <span className="dnum dim">{fmtMs(u.budgetMs)}</span> <Help term="budget" /></span>
|
|
216
217
|
<span><span className="ck">limit</span> <span className="dnum dim">{fmtMs(u.timeoutMs)}</span> <Help term="ceiling" /></span>
|
|
217
218
|
</span>
|
|
218
|
-
<Bars runs={u.recent ?? []} ceiling={u.timeoutMs} />
|
|
219
|
+
<Bars runs={u.recent ?? []} ceiling={u.timeoutMs} budget={u.budgetMs} />
|
|
219
220
|
</Row>
|
|
220
221
|
)
|
|
221
222
|
|