rvlib-shipkit 1.2.0 → 1.3.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 +67 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +20 -6
- package/src/checks/SK001-package-json-shape.check.ts +14 -3
- package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
- package/src/checks/SK004-gitignore-policy.check.ts +11 -3
- package/src/checks/SK005-rvlib-versions.check.ts +13 -1
- package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
- package/src/checks/SK007-github-visibility.check.ts +9 -9
- package/src/checks/SK008-reviewer-agent.check.ts +14 -8
- package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
- package/src/checks/SK024-identity-colors.check.ts +5 -0
- package/src/checks/SK025-release-drift.check.ts +58 -0
- package/src/checks/SK026-asks-autoclose.check.ts +44 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
- package/src/checks/SK028-readme.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Ci.ts +5 -0
- package/src/cli/cmd_CrossRepoActivity.ts +116 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Keys.ts +17 -3
- package/src/cli/cmd_Overview.ts +98 -0
- package/src/cli/cmd_Planned.ts +117 -0
- package/src/cli.ts +9 -1
- package/src/collections/checks.ts +3 -2
- package/src/collections/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-runner.ts +40 -12
- package/src/core/discover-checks.ts +2 -0
- package/src/core/file-stamp.ts +20 -0
- package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
- package/src/core/identity-color.ts +1 -1
- package/src/core/repo-facts.ts +14 -0
- package/src/core/retention.ts +14 -5
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +43 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/wire.ts +11 -0
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +135 -0
- package/src/home/activity.ts +249 -0
- package/src/home/global-units.ts +3 -2
- package/src/home/home-routes.ts +32 -5
- package/src/home/pm.ts +37 -4
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +35 -8
- package/src/home/run-daemon.ts +6 -6
- package/src/home/run-pm.ts +1 -0
- package/src/home/serve-web.ts +6 -3
- package/src/home/supervise.ts +16 -1
- package/src/home/widget-text.ts +119 -0
- package/src/hooks/run-hooks.ts +20 -3
- package/src/ledger/db.ts +1 -1
- package/src/ledger/gate-checks.ts +17 -3
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +39 -10
- package/src/ledger/store.ts +19 -2
- package/src/ledger/world-cache.ts +51 -4
- package/src/plan/cadence.ts +21 -5
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +138 -13
- package/src/plan/repo-source.ts +120 -8
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +11 -3
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-words.ts +2 -0
- package/src/serve/hub-app/controls.css +32 -0
- package/src/serve/hub-app/cycle-button.tsx +36 -0
- package/src/serve/hub-app/dock-anim.ts +25 -0
- package/src/serve/hub-app/dock-st.ts +329 -0
- package/src/serve/hub-app/dock-view.tsx +68 -0
- package/src/serve/hub-app/dock.css +35 -0
- package/src/serve/hub-app/hash-route.ts +7 -1
- package/src/serve/hub-app/header-bar.tsx +79 -104
- package/src/serve/hub-app/home-st.ts +49 -12
- package/src/serve/hub-app/home-strip.css +65 -22
- package/src/serve/hub-app/home-strip.tsx +228 -97
- package/src/serve/hub-app/home.html +4 -0
- package/src/serve/hub-app/hub-app.tsx +96 -160
- package/src/serve/hub-app/hub.css +77 -37
- package/src/serve/hub-app/icons.tsx +23 -3
- package/src/serve/hub-app/index.html +4 -0
- package/src/serve/hub-app/keys.ts +42 -7
- package/src/serve/hub-app/live-svc.ts +24 -64
- package/src/serve/hub-app/machine-st.ts +64 -1
- package/src/serve/hub-app/md.ts +38 -5
- package/src/serve/hub-app/next-run.ts +1 -1
- package/src/serve/hub-app/palette-st.ts +11 -7
- package/src/serve/hub-app/palette-view.tsx +47 -0
- package/src/serve/hub-app/pill-row.tsx +65 -0
- package/src/serve/hub-app/planned-st.ts +21 -85
- package/src/serve/hub-app/planned-view.tsx +12 -7
- package/src/serve/hub-app/planned.css +5 -2
- package/src/serve/hub-app/project-list.css +43 -21
- package/src/serve/hub-app/project-list.tsx +122 -23
- package/src/serve/hub-app/projects-svc.ts +120 -12
- package/src/serve/hub-app/readme-svc.ts +38 -0
- package/src/serve/hub-app/readme-view.tsx +19 -0
- package/src/serve/hub-app/readme.css +11 -0
- package/src/serve/hub-app/repo-sections.tsx +61 -0
- package/src/serve/hub-app/repo-st.ts +25 -6
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/sidebar.tsx +84 -0
- package/src/serve/hub-app/tests-view.tsx +31 -4
- package/src/serve/hub-app/timeline-chart.tsx +31 -27
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +2 -2
- package/src/serve/hub-app/timeline-view.tsx +20 -3
- package/src/serve/hub-runtime.ts +17 -9
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +100 -20
- package/src/serve/project-gutter.ts +33 -11
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/serve-hub.ts +13 -8
- package/src/serve/tracked-watch.ts +33 -1
- package/src/shipkit-files.ts +20 -4
- package/src/checks/SK023-stores-under-cap.check.ts +0 -49
- package/src/checks/contracts.check.ts +0 -20
package/src/cli/cmd_Keys.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
import { createHash } from 'crypto'
|
|
9
9
|
import { Command, Option } from 'clipanion'
|
|
10
10
|
import { loadRepoConfig } from '../repo-config.ts'
|
|
11
|
-
import { envTier, FULL_TESTS_ENV,
|
|
11
|
+
import { envTier, FULL_TESTS_ENV, gateInventory } from '../ledger/gate-checks.ts'
|
|
12
|
+
import { planGap, plannable } from '../plan/repo-source.ts'
|
|
12
13
|
import { KeyLayer } from '../ledger/git-key.ts'
|
|
13
14
|
import { Ledger } from '../ledger/ledger.ts'
|
|
14
15
|
import { gateBinary } from '../ledger/toolchain-identity.ts'
|
|
@@ -42,14 +43,22 @@ export class KeysCommand extends Command {
|
|
|
42
43
|
// every task's env, which is in the key, so a surface that renders keys
|
|
43
44
|
// without it renders a tier nobody asked for
|
|
44
45
|
const tier = this.full ? 'full' : envTier()
|
|
45
|
-
const
|
|
46
|
+
const rows = await gateInventory(root, c, { tier })
|
|
47
|
+
const decls = rows.map((r) => r.decl)
|
|
48
|
+
// the plan's side of the burn-down: a check nothing keys and that
|
|
49
|
+
// declares no cadence is re-asked on a guess. A built-in there is a
|
|
50
|
+
// toolkit bug, a repo check rides the default range until it declares
|
|
51
|
+
const planGaps = rows.filter(plannable).flatMap((r) => {
|
|
52
|
+
const gap = planGap(r)
|
|
53
|
+
return gap == null ? [] : [{ id: r.decl.id, from: r.from, gap }]
|
|
54
|
+
})
|
|
46
55
|
const layer = KeyLayer.open(root, decls, { cliEntry: gateBinary() })
|
|
47
56
|
const keys = layer.keys()
|
|
48
57
|
const out = (l: string): void => { this.context.stdout.write(`${l}\n`) }
|
|
49
58
|
|
|
50
59
|
if (this.json) {
|
|
51
60
|
const drift = this.rescan ? layer.rescan() : null
|
|
52
|
-
out(JSON.stringify({ tier, checks: keys, burnDown: layer.registration.burnDown, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
|
|
61
|
+
out(JSON.stringify({ tier, checks: keys, burnDown: layer.registration.burnDown, planGaps, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
|
|
53
62
|
return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
|
|
54
63
|
}
|
|
55
64
|
|
|
@@ -115,6 +124,11 @@ export class KeysCommand extends Command {
|
|
|
115
124
|
const decided = new Set(layer.decls.filter((d) => d.never != null && d.never !== '').map((d) => d.id))
|
|
116
125
|
const shown = this.burnDown ? keys.filter((k) => k.population === 'none' && !decided.has(k.id)) : keys
|
|
117
126
|
for (const k of shown) out(`${k.population.padEnd(7)} ${k.id.padEnd(28)} ${k.key ?? `— ${k.reason ?? ''}`}`)
|
|
127
|
+
if (planGaps.length > 0) out('')
|
|
128
|
+
for (const g of planGaps)
|
|
129
|
+
out(g.from === 'repo'
|
|
130
|
+
? `WARN ${g.id}: ${g.gap}, so the plan runs it every 20h..28h. Declare inputs, a cacheKey, ttl or every`
|
|
131
|
+
: `FAIL: built-in ${g.id}: ${g.gap}. A toolkit bug: the plan waits for a save until it declares one`)
|
|
118
132
|
|
|
119
133
|
const content = keys.filter((k) => k.population === 'content').length
|
|
120
134
|
const reads = keys.filter((k) => k.population === 'reads').length
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// shipkit overview: the overview page in the console, top to bottom as the
|
|
2
|
+
// page: the monitor widgets as the daemon last rendered them, their html read
|
|
3
|
+
// as text by widget-text.ts, then the processes through
|
|
4
|
+
// globalProcessesAnswer (the rows `shipkit gp` prints: pm, the daemon and the
|
|
5
|
+
// web first, then the global processes). Nothing renders a widget here: the
|
|
6
|
+
// daemon holds the render, and while it is down the monitor is unknown.
|
|
7
|
+
import { Command, Option } from 'clipanion'
|
|
8
|
+
import { daemonUrl } from '../home/home-paths.ts'
|
|
9
|
+
import type { WidgetsAnswer } from '../home/home-routes.ts'
|
|
10
|
+
import type { ChildView, PmAnswer } from '../home/pm.ts'
|
|
11
|
+
import { splitWidgetHead, widgetText } from '../home/widget-text.ts'
|
|
12
|
+
import { askDaemon, askPm, globalProcessesAnswer, localProcessDeps, renderGlobalProcesses, type GlobalProcessesAnswer, type GlobalProcessesDeps } from './cmd_GlobalProcesses.ts'
|
|
13
|
+
|
|
14
|
+
export type OverviewAnswer = {
|
|
15
|
+
now: number
|
|
16
|
+
/** pm as it answered: its own pid and version, its children; null when it did not */
|
|
17
|
+
pm: { pid: number; since: number | null; version: string; children: ChildView[] } | null
|
|
18
|
+
/** the daemon's identity, asked of the daemon itself; null when it does not answer */
|
|
19
|
+
daemon: { version: string } | null
|
|
20
|
+
/** the global processes, exactly as `shipkit global-processes --json` */
|
|
21
|
+
processes: GlobalProcessesAnswer
|
|
22
|
+
/** the daemon's widgets route payload; null when the daemon does not answer */
|
|
23
|
+
widgets: WidgetsAnswer | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type OverviewDeps = Pick<GlobalProcessesDeps, 'declared' | 'order'> & {
|
|
27
|
+
pm: () => Promise<PmAnswer | null>
|
|
28
|
+
daemon: () => Promise<{ version: string } | null>
|
|
29
|
+
widgets: () => Promise<WidgetsAnswer | null>
|
|
30
|
+
now: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function getJson<T>(url: string): Promise<T | null> {
|
|
34
|
+
try {
|
|
35
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(3_000) })
|
|
36
|
+
return r.ok ? ((await r.json()) as T) : null
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** the daemon's widgets route payload, or null */
|
|
43
|
+
export const askWidgets = (url = daemonUrl()): Promise<WidgetsAnswer | null> => getJson<WidgetsAnswer>(`${url}/api/home/widgets.json`)
|
|
44
|
+
|
|
45
|
+
export async function overviewAnswer(d: OverviewDeps): Promise<OverviewAnswer> {
|
|
46
|
+
const [pm, daemon, widgets] = await Promise.all([d.pm(), d.daemon(), d.widgets()])
|
|
47
|
+
const processes = await globalProcessesAnswer({ pm: async () => pm, daemon: async () => daemon, declared: d.declared, order: d.order, now: d.now })
|
|
48
|
+
return { now: d.now, pm: pm == null ? null : { pid: pm.pid, since: pm.since ?? null, version: pm.version, children: pm.children }, daemon, processes, widgets }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const indent = (lines: string[], by = ' '): string[] => lines.map((l) => by + l)
|
|
52
|
+
|
|
53
|
+
/** the page as text: the monitor, then the processes (shipkit's own first),
|
|
54
|
+
* each section saying what is unknown and why when pm or the daemon does not
|
|
55
|
+
* answer */
|
|
56
|
+
export function renderOverview(a: OverviewAnswer): string[] {
|
|
57
|
+
const out: string[] = ['monitor']
|
|
58
|
+
if (a.widgets == null) out.push(` the daemon does not answer on ${daemonUrl()}, and it renders the widgets: the monitor is unknown`)
|
|
59
|
+
else {
|
|
60
|
+
if (a.widgets.widgets.length === 0 && a.widgets.errors.length === 0) out.push(' no widget declared: a repo declares one in a <name>.shipkit.ts file, as export default widget({...})')
|
|
61
|
+
for (const x of a.widgets.widgets) {
|
|
62
|
+
const { head, body } = splitWidgetHead(x.html ?? '')
|
|
63
|
+
out.push(` ${x.title}${head == null ? '' : ` · ${widgetText(head).join(' ')}`}`)
|
|
64
|
+
if (x.error != null) out.push(` WARN ${x.error}`)
|
|
65
|
+
if (x.html == null) out.push(' not rendered yet')
|
|
66
|
+
else out.push(...indent(widgetText(body), ' '))
|
|
67
|
+
}
|
|
68
|
+
for (const e of a.widgets.errors) out.push(` WARN ${e}`)
|
|
69
|
+
}
|
|
70
|
+
out.push('processes', ...indent(renderGlobalProcesses(a.processes)))
|
|
71
|
+
if (a.daemon == null) out.push(` the daemon does not answer on ${daemonUrl()}`)
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class OverviewCommand extends Command {
|
|
76
|
+
static override paths = [['overview']]
|
|
77
|
+
|
|
78
|
+
static override usage = Command.Usage({
|
|
79
|
+
category: 'Home',
|
|
80
|
+
description: 'the overview page in the console: every monitor widget as text, from the html the daemon last rendered, then the processes as `shipkit gp` prints them (pm, the daemon and the web with pid, uptime and version, then every global process). When pm or the daemon does not answer, it says which and prints what is still known. Always exits 0',
|
|
81
|
+
examples: [
|
|
82
|
+
['the overview', 'shipkit overview'],
|
|
83
|
+
['the answers the page reads, as json', 'shipkit overview --json'],
|
|
84
|
+
],
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
json = Option.Boolean('--json', false, { description: 'the answers as json: { now, pm, daemon, processes, widgets }, processes as `shipkit gp --json`, widgets the daemon route\'s payload' })
|
|
88
|
+
|
|
89
|
+
async execute(): Promise<number> {
|
|
90
|
+
const a = await overviewAnswer({ ...localProcessDeps(process.cwd()), pm: () => askPm(), daemon: () => askDaemon(), widgets: () => askWidgets(), now: Date.now() })
|
|
91
|
+
if (this.json) {
|
|
92
|
+
this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
for (const l of renderOverview(a)) this.context.stdout.write(`${l}\n`)
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// shipkit planned: the planned page in the console. The plan lives in the
|
|
2
|
+
// daemon (its marks, the running batch, the catch up spread all exist only
|
|
3
|
+
// there), so this asks the daemon's route, the one the page polls, and only
|
|
4
|
+
// renders it: the order and every word come from plan-timeline.ts, the page's
|
|
5
|
+
// own functions. Nothing is computed from disk: a plan rebuilt here would load
|
|
6
|
+
// every repo's checks and still not know what the daemon's plan holds.
|
|
7
|
+
import { Command, Option } from 'clipanion'
|
|
8
|
+
import { daemonUrl } from '../home/home-paths.ts'
|
|
9
|
+
import type { PlannedAnswer } from '../home/home-routes.ts'
|
|
10
|
+
import { markOf, spanShort, timeline, titleWords, waitingRows, waitingWords, whenWords, type Section } from '../plan/plan-timeline.ts'
|
|
11
|
+
import type { PlanRow } from '../plan/planner.ts'
|
|
12
|
+
|
|
13
|
+
/** the daemon's planned answer, or null when it does not answer */
|
|
14
|
+
export async function askPlanned(url = daemonUrl()): Promise<PlannedAnswer | null> {
|
|
15
|
+
try {
|
|
16
|
+
const r = await fetch(`${url}/api/home/planned.json`, { signal: AbortSignal.timeout(8_000) })
|
|
17
|
+
if (!r.ok) return null
|
|
18
|
+
return (await r.json()) as PlannedAnswer
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** the answer for one repo: its rows and its load error only */
|
|
25
|
+
export function onlyRepo(a: PlannedAnswer, repo: string): PlannedAnswer {
|
|
26
|
+
return { ...a, rows: a.rows.filter((r) => r.repo === repo), errors: a.errors.filter((e) => e.repo === repo) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pad = (s: string, n: number): string => (s.length >= n ? s : s + ' '.repeat(n - s.length))
|
|
30
|
+
|
|
31
|
+
const lastWords = (r: PlanRow, now: number): string =>
|
|
32
|
+
r.last == null ? 'never ran' : `last ${r.last.status} ${spanShort(Math.max(0, now - r.last.at))} ago${r.last.streak > 1 ? ` (${r.last.streak} in a row)` : ''}`
|
|
33
|
+
|
|
34
|
+
const SECTION_WORDS: Record<Section, string> = { now: 'now', next: 'next' }
|
|
35
|
+
|
|
36
|
+
/** the page's one list as text: its three sections in its order, a line per
|
|
37
|
+
* row (when, repo, check and title, mark, last result) and the rule under
|
|
38
|
+
* it; the checks that only wait for a save are counted, or listed with `all` */
|
|
39
|
+
export function renderPlanned(a: PlannedAnswer, o: { all?: boolean } = {}): string[] {
|
|
40
|
+
const out: string[] = [...(a.held ?? [])]
|
|
41
|
+
const list = timeline(a.rows)
|
|
42
|
+
const idle = a.rows.filter((r) => r.state === 'idle')
|
|
43
|
+
const w = {
|
|
44
|
+
when: Math.max(0, ...list.map((x) => whenWords(x.row, a.now).length)),
|
|
45
|
+
what: Math.max(0, ...list.map((x) => `${x.row.repo} ${x.row.check}`.length)),
|
|
46
|
+
}
|
|
47
|
+
let prev: Section | null = null
|
|
48
|
+
for (const { section, row } of list) {
|
|
49
|
+
if (section !== prev) {
|
|
50
|
+
out.push(SECTION_WORDS[section])
|
|
51
|
+
prev = section
|
|
52
|
+
}
|
|
53
|
+
const m = markOf(row)
|
|
54
|
+
const title = titleWords(row.title, row.check)
|
|
55
|
+
const cells = [` ${pad(whenWords(row, a.now), w.when)}`, pad(`${row.repo} ${row.check}`, w.what), m.words, lastWords(row, a.now)]
|
|
56
|
+
out.push(cells.filter((c) => c !== '').join(' '))
|
|
57
|
+
out.push(` ${title == null ? '' : `${title} · `}${row.rule}`)
|
|
58
|
+
if (row.error != null) out.push(` error: ${row.error}`)
|
|
59
|
+
}
|
|
60
|
+
if (list.length === 0) out.push(a.rows.length === 0 ? 'nothing is planned' : 'nothing has a time of its own')
|
|
61
|
+
const waiting = waitingRows(a.rows)
|
|
62
|
+
if (waiting.length > 0) {
|
|
63
|
+
out.push(`${waitingWords(a.rows)}${o.all === true ? '' : ' (--all lists them)'}`)
|
|
64
|
+
if (o.all === true) for (const r of waiting) out.push(` ${r.repo} ${r.check} ${lastWords(r, a.now)} · ${r.rule}`)
|
|
65
|
+
}
|
|
66
|
+
if (o.all === true && idle.length > 0) {
|
|
67
|
+
out.push('on a save only')
|
|
68
|
+
for (const r of [...idle].sort((x, y) => x.repo.localeCompare(y.repo) || x.check.localeCompare(y.check))) out.push(` ${r.repo} ${r.check} ${lastWords(r, a.now)} · ${r.rule}`)
|
|
69
|
+
}
|
|
70
|
+
for (const e of a.errors) out.push(`WARN ${e.repo}: ${e.error}`)
|
|
71
|
+
const n = { now: list.filter((x) => x.section === 'now').length, next: list.filter((x) => x.section === 'next').length }
|
|
72
|
+
out.push(`${n.now} now, ${n.next} next, ${waiting.length} waiting, ${idle.length} on a save only${o.all === true || idle.length === 0 ? '' : ' (--all lists them)'}`)
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class PlannedCommand extends Command {
|
|
77
|
+
static override paths = [['planned']]
|
|
78
|
+
|
|
79
|
+
static override usage = Command.Usage({
|
|
80
|
+
category: 'Home',
|
|
81
|
+
description: 'the planned page in the console: every check the machine runs on its own time, running now, then next, each with its repo, check and title, when, the rule that planned it and its last result. Asks the daemon, which holds the plan. With the daemon down it says so and exits 0',
|
|
82
|
+
examples: [
|
|
83
|
+
['the plan, as the page lists it', 'shipkit planned'],
|
|
84
|
+
['one repo only', 'shipkit planned --repo my-app'],
|
|
85
|
+
['also the checks that only a save runs', 'shipkit planned --all'],
|
|
86
|
+
['the route\'s exact payload', 'shipkit planned --json'],
|
|
87
|
+
],
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
json = Option.Boolean('--json', false, { description: 'the daemon route\'s exact payload: { now, rows, errors, running, held }' })
|
|
91
|
+
repo = Option.String('--repo', { required: false, description: 'only this repo\'s rows' })
|
|
92
|
+
all = Option.Boolean('--all', false, { description: 'also list the checks that only a save runs (the page leaves them out)' })
|
|
93
|
+
|
|
94
|
+
async execute(): Promise<number> {
|
|
95
|
+
const got = await askPlanned()
|
|
96
|
+
if (got == null) {
|
|
97
|
+
const why = `the daemon does not answer on ${daemonUrl()}, and the plan lives there: no plan is known (\`shipkit pm\` starts it)`
|
|
98
|
+
if (this.json) {
|
|
99
|
+
this.context.stdout.write('null\n')
|
|
100
|
+
this.context.stderr.write(`${why}\n`)
|
|
101
|
+
} else this.context.stdout.write(`${why}\n`)
|
|
102
|
+
return 0
|
|
103
|
+
}
|
|
104
|
+
const a = this.repo == null ? got : onlyRepo(got, this.repo)
|
|
105
|
+
if (this.json) {
|
|
106
|
+
this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
|
|
107
|
+
return 0
|
|
108
|
+
}
|
|
109
|
+
if (this.repo != null && a.rows.length === 0 && a.errors.length === 0) {
|
|
110
|
+
const known = [...new Set(got.rows.map((r) => r.repo))].sort()
|
|
111
|
+
this.context.stdout.write(`no planned check in ${this.repo}; the plan holds ${known.join(', ') || 'no repo'}\n`)
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
for (const l of renderPlanned(a, { all: this.all })) this.context.stdout.write(`${l}\n`)
|
|
115
|
+
return 0
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -9,7 +9,11 @@ import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenComma
|
|
|
9
9
|
import { BumpCommand } from './cli/cmd_Bump.ts'
|
|
10
10
|
import { CheckCommand } from './cli/cmd_Check.ts'
|
|
11
11
|
import { CommandsCommand } from './cli/cmd_Commands.ts'
|
|
12
|
+
import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity.ts'
|
|
12
13
|
import { ConfigKeysCommand } from './cli/cmd_ConfigKeys.ts'
|
|
14
|
+
import { GlobalProcessesCommand } from './cli/cmd_GlobalProcesses.ts'
|
|
15
|
+
import { PlannedCommand } from './cli/cmd_Planned.ts'
|
|
16
|
+
import { OverviewCommand } from './cli/cmd_Overview.ts'
|
|
13
17
|
import { CiCommand } from './cli/cmd_Ci.ts'
|
|
14
18
|
import { ContractsCommand } from './cli/cmd_Contracts.ts'
|
|
15
19
|
import { FleetCommand } from './cli/cmd_Fleet.ts'
|
|
@@ -86,6 +90,10 @@ export function buildCli(): Cli {
|
|
|
86
90
|
cli.register(SmokeCommand)
|
|
87
91
|
cli.register(ReflectCommand)
|
|
88
92
|
cli.register(KeysCommand)
|
|
93
|
+
cli.register(GlobalProcessesCommand)
|
|
94
|
+
cli.register(CrossRepoActivityCommand)
|
|
95
|
+
cli.register(PlannedCommand)
|
|
96
|
+
cli.register(OverviewCommand)
|
|
89
97
|
cli.register(ChecksCommand)
|
|
90
98
|
cli.register(UnitsAliasCommand)
|
|
91
99
|
cli.register(CommandsCommand)
|
|
@@ -100,7 +108,7 @@ export function buildCli(): Cli {
|
|
|
100
108
|
// this one (one-lib.ts), so the machine's version runs in every repo. Then pm
|
|
101
109
|
// is started when it is down (autostart.ts), before the command runs
|
|
102
110
|
if (import.meta.main) {
|
|
103
|
-
const argv = process.argv.slice(2)
|
|
111
|
+
const argv = aliasArgs(process.argv.slice(2))
|
|
104
112
|
const { handoff } = await import('./home/handoff.ts')
|
|
105
113
|
handoff(argv)
|
|
106
114
|
const { useOneLib } = await import('./core/one-lib.ts')
|
|
@@ -18,7 +18,7 @@ import { homedir } from 'os'
|
|
|
18
18
|
import { isAbsolute, join, relative, resolve } from 'path'
|
|
19
19
|
import { rawName, routesToShipkit } from '../core/canonical-scripts.ts'
|
|
20
20
|
import { FLOOR_IDS } from '../core/floor.ts'
|
|
21
|
-
import { formatLine,
|
|
21
|
+
import { FOREVER_MS, formatLine, ttlMsOf } from '../core/wire.ts'
|
|
22
22
|
import { gateInventory, type CheckRow } from '../ledger/gate-checks.ts'
|
|
23
23
|
import { readLastLines, readRuns, runsPath, type LastLine, type RunRow } from '../ledger/run-log.ts'
|
|
24
24
|
import { checkStatsFor, type CheckStat } from '../ledger/check-stats.ts'
|
|
@@ -166,7 +166,8 @@ function instanceOf(root: string, c: RepoConfig, row: CheckRow, stat: CheckStat
|
|
|
166
166
|
export function keepsUntil(ttl: string | undefined, stat: Pick<CheckStat, 'measuredAt' | 'measuredStatus'> | undefined): number | null {
|
|
167
167
|
if (ttl == null || ttl === '' || stat?.measuredAt == null) return null
|
|
168
168
|
let ms = 0
|
|
169
|
-
try { ms =
|
|
169
|
+
try { ms = ttlMsOf(ttl) } catch { return null }
|
|
170
|
+
if (ms >= FOREVER_MS && stat.measuredStatus === 'pass') return null
|
|
170
171
|
if (ms <= 0) return null
|
|
171
172
|
return stat.measuredAt + (stat.measuredStatus === 'pass' ? ms : Math.min(ms, NEGATIVE_TTL_CAP_MS))
|
|
172
173
|
}
|
|
@@ -31,7 +31,7 @@ import { testsCollection } from './tests.ts'
|
|
|
31
31
|
import { checksCollection } from './checks.ts'
|
|
32
32
|
import { LAYOUT } from '../core/layout.ts'
|
|
33
33
|
import { isBudgetLine } from '../core/budget.ts'
|
|
34
|
-
import { GH_LOGIN_CRED } from '../
|
|
34
|
+
import { GH_LOGIN_CRED } from '../core/gh-login.ts'
|
|
35
35
|
|
|
36
36
|
const askDirOf = (root: string, c: RepoConfig): string => join(root, c.asks?.dir ?? LAYOUT.asks)
|
|
37
37
|
// read path must not mint an asks/ dir (listAsks mkdirs on read)
|
|
@@ -127,9 +127,15 @@ export type TestCheckRow = {
|
|
|
127
127
|
shape: Shape | null
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** one file of any test check, named with the check that runs it */
|
|
131
|
+
export type TestFileRow = FileTime & { check: string }
|
|
132
|
+
|
|
130
133
|
export type TestsRootView = {
|
|
131
134
|
/** slowest wall first, the never ran ones last */
|
|
132
135
|
checks: TestCheckRow[]
|
|
136
|
+
/** every file of every test check, slowest first, the never ran ones
|
|
137
|
+
* last: each check's own list, merged */
|
|
138
|
+
files: TestFileRow[]
|
|
133
139
|
failing: FailingFile[]
|
|
134
140
|
}
|
|
135
141
|
|
|
@@ -270,8 +276,10 @@ function lastRunOf(checkId: string, at: number, files: FileTime[], sumMs: number
|
|
|
270
276
|
|
|
271
277
|
export function shapeTestsRoot(o: TestTimeInput): TestsRootView {
|
|
272
278
|
const ids = [...new Set(o.facts.filter((f) => f.standing === 'runs' && f.checkId != null).map((f) => f.checkId!))]
|
|
279
|
+
const files: TestFileRow[] = []
|
|
273
280
|
const checks = ids.map((checkId): TestCheckRow => {
|
|
274
281
|
const v = shapeTestCheck(checkId, o)
|
|
282
|
+
for (const f of v.files) files.push({ ...f, check: checkId })
|
|
275
283
|
const top = v.files.find((f) => f.ms != null)
|
|
276
284
|
return {
|
|
277
285
|
checkId,
|
|
@@ -284,5 +292,6 @@ export function shapeTestsRoot(o: TestTimeInput): TestsRootView {
|
|
|
284
292
|
shape: v.last?.shape ?? null,
|
|
285
293
|
}
|
|
286
294
|
}).sort((a, b) => (b.wallMs ?? -1) - (a.wallMs ?? -1) || a.checkId.localeCompare(b.checkId))
|
|
287
|
-
|
|
295
|
+
files.sort((a, b) => (b.ms ?? -1) - (a.ms ?? -1) || a.file.localeCompare(b.file))
|
|
296
|
+
return { checks, files, failing: failingOf(o.facts) }
|
|
288
297
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// AGENT SPAWN, found statically: code that starts a Claude or agent process on
|
|
2
|
+
// its own (a spawn or exec of `claude`, `claude -p`, the agent sdk). No tool
|
|
3
|
+
// here starts an agent unless its owner asks in person, so the sweep tests
|
|
4
|
+
// (tests/no-agent-spawn.test.ts, and the house repo's own) fail on any hit.
|
|
5
|
+
//
|
|
6
|
+
// a string that only MENTIONS claude (a url, `~/.claude/projects`, a help
|
|
7
|
+
// line naming the product) is no spawn, and comments are skipped.
|
|
8
|
+
|
|
9
|
+
export type AgentSpawn = { line: number; what: string }
|
|
10
|
+
|
|
11
|
+
const SHAPES: { re: RegExp; what: string }[] = [
|
|
12
|
+
// the sdks that run an agent loop in process or spawn the cli
|
|
13
|
+
{ re: /(['"`])@anthropic-ai\/(?:claude-agent-sdk|claude-code)(?:\/[^'"`]*)?\1/, what: 'imports the agent sdk' },
|
|
14
|
+
// an argv whose command is claude: spawn(['claude', …]), c.exec(['claude'])
|
|
15
|
+
{ re: /\[\s*(['"`])(?:[^'"`\s]*\/)?claude\1/, what: 'an argv that runs claude' },
|
|
16
|
+
// spawn('claude', …), execSync('claude -p …'), execFile('claude')
|
|
17
|
+
{ re: /\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execa|execaSync|fork)\s*\(\s*(['"`])(?:[^'"`\s]*\/)?claude(?:\s[^'"`]*)?\1/, what: 'a process call that runs claude' },
|
|
18
|
+
// the bun shell: $`claude -p …`
|
|
19
|
+
{ re: /\$\s*`\s*(?:[^`\s]*\/)?claude(?:\s|`)/, what: 'a bun shell line that runs claude' },
|
|
20
|
+
// the headless form, wherever the string goes
|
|
21
|
+
{ re: /(['"`])(?:[^'"`\s]*\/)?claude\s+(?:-p|--print)\b/, what: 'a claude -p command line' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/** the first agent spawn in one source text, 1-based line, comments skipped */
|
|
25
|
+
export function agentSpawnIn(src: string): AgentSpawn | null {
|
|
26
|
+
const lines = src.split('\n')
|
|
27
|
+
let inBlock = false
|
|
28
|
+
for (let i = 0; i < lines.length; i++) {
|
|
29
|
+
let l = lines[i]!
|
|
30
|
+
if (inBlock) {
|
|
31
|
+
const end = l.indexOf('*/')
|
|
32
|
+
if (end < 0) continue
|
|
33
|
+
l = l.slice(end + 2)
|
|
34
|
+
inBlock = false
|
|
35
|
+
}
|
|
36
|
+
l = l.replace(/\/\*.*?\*\//g, '')
|
|
37
|
+
const open = l.indexOf('/*')
|
|
38
|
+
if (open >= 0) {
|
|
39
|
+
inBlock = true
|
|
40
|
+
l = l.slice(0, open)
|
|
41
|
+
}
|
|
42
|
+
l = l.replace(/(^|\s)\/\/.*$/, '$1')
|
|
43
|
+
for (const s of SHAPES) if (s.re.test(l)) return { line: i + 1, what: s.what }
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `archived: true` in repo.config.ts: the repo stays known and listed, and
|
|
2
|
+
// nothing runs there (no hub load, so no watch, no plan rows, no gates, no
|
|
3
|
+
// processes, no widgets, no identity fill). Read and written as TEXT, never by
|
|
4
|
+
// loading the config: the daemon and the page rows read many configs per poll,
|
|
5
|
+
// and the toggle moves that one key and no other byte.
|
|
6
|
+
import { readFileSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { atomicWrite } from './locked-write.ts'
|
|
9
|
+
import { configDepths } from './identity-color.ts'
|
|
10
|
+
|
|
11
|
+
const KEY_RE = /(?:^|[{,])\s*(['"]?)archived\1\s*:\s*(true|false)\b/gm
|
|
12
|
+
|
|
13
|
+
/** every top level `archived` key of `defineRepo({…})` with its value and the
|
|
14
|
+
* offsets of the whole `archived: <value>` token */
|
|
15
|
+
function archivedKeys(text: string): { on: boolean; start: number; end: number }[] {
|
|
16
|
+
const depth = configDepths(text)
|
|
17
|
+
const out: { on: boolean; start: number; end: number }[] = []
|
|
18
|
+
for (const m of text.matchAll(KEY_RE)) {
|
|
19
|
+
const lead = /^[{,]/.test(m[0]) ? 1 : 0
|
|
20
|
+
const sep = lead === 1 ? m.index : m.index - 1
|
|
21
|
+
if (sep < 0 || depth[sep] !== 1) continue
|
|
22
|
+
const start = m.index + lead + /^[{,]?(\s*)/.exec(m[0])![1]!.length
|
|
23
|
+
out.push({ on: m[2] === 'true', start, end: m.index + m[0].length })
|
|
24
|
+
}
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** does the config text archive the repo */
|
|
29
|
+
export const archivedOfText = (text: string): boolean => archivedKeys(text).some((k) => k.on)
|
|
30
|
+
|
|
31
|
+
/** the config text with the repo archived or not. On: the key reads true, a
|
|
32
|
+
* missing one lands as the first key of `defineRepo({`. Off: the key goes,
|
|
33
|
+
* so a round trip gives the text back byte for byte */
|
|
34
|
+
export function setArchivedText(text: string, on: boolean): string {
|
|
35
|
+
const keys = archivedKeys(text)
|
|
36
|
+
if (keys.length > 1) throw new Error('repo.config.ts declares `archived` more than once, set it by hand')
|
|
37
|
+
const k = keys[0]
|
|
38
|
+
if (on) {
|
|
39
|
+
if (k != null) return k.on ? text : `${text.slice(0, k.start)}archived: true${text.slice(k.end)}`
|
|
40
|
+
const lines = text.split('\n')
|
|
41
|
+
const open = lines.findIndex((l) => /defineRepo\(\s*\{/.test(l))
|
|
42
|
+
if (open === -1) throw new Error('repo.config.ts has no `defineRepo({` to write `archived` into, set it by hand')
|
|
43
|
+
const line = lines[open]!
|
|
44
|
+
if (/defineRepo\(\s*\{\s*$/.test(line)) {
|
|
45
|
+
const next = lines.slice(open + 1).find((l) => l.trim() !== '' && !/^\s*}/.test(l))
|
|
46
|
+
const pad = next != null ? /^(\s*)/.exec(next)![1]! : `${/^(\s*)/.exec(line)![1]!} `
|
|
47
|
+
return [...lines.slice(0, open + 1), `${pad}archived: true,`, ...lines.slice(open + 1)].join('\n')
|
|
48
|
+
}
|
|
49
|
+
lines[open] = line.replace(/defineRepo\(\s*\{\s*/, (m) => `${m}archived: true, `)
|
|
50
|
+
return lines.join('\n')
|
|
51
|
+
}
|
|
52
|
+
if (k == null) return text
|
|
53
|
+
// a line of its own goes whole, an inline key goes with its comma
|
|
54
|
+
const lineStart = text.lastIndexOf('\n', k.start - 1) + 1
|
|
55
|
+
const lineEnd = text.indexOf('\n', k.end)
|
|
56
|
+
const rest = text.slice(k.end, lineEnd === -1 ? text.length : lineEnd)
|
|
57
|
+
if (text.slice(lineStart, k.start).trim() === '' && /^\s*,?\s*$/.test(rest)) return text.slice(0, lineStart) + text.slice(lineEnd === -1 ? text.length : lineEnd + 1)
|
|
58
|
+
const after = /^\s*,\s*/.exec(text.slice(k.end))
|
|
59
|
+
if (after != null) return text.slice(0, k.start) + text.slice(k.end + after[0].length)
|
|
60
|
+
const before = /,\s*$/.exec(text.slice(0, k.start))
|
|
61
|
+
return before != null ? text.slice(0, k.start - before[0].length) + text.slice(k.end) : text.slice(0, k.start) + text.slice(k.end)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const CONFIG_FILE = 'repo.config.ts'
|
|
65
|
+
|
|
66
|
+
/** is this repo archived; false when it has no readable config */
|
|
67
|
+
export function isArchived(root: string): boolean {
|
|
68
|
+
try {
|
|
69
|
+
return archivedOfText(readFileSync(join(root, CONFIG_FILE), 'utf8'))
|
|
70
|
+
} catch {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** archive the repo or bring it back: only that key of its config moves */
|
|
76
|
+
export function writeArchived(root: string, on: boolean): boolean {
|
|
77
|
+
const path = join(root, CONFIG_FILE)
|
|
78
|
+
const text = readFileSync(path, 'utf8')
|
|
79
|
+
const out = setArchivedText(text, on)
|
|
80
|
+
if (out !== text) atomicWrite(path, out)
|
|
81
|
+
return on
|
|
82
|
+
}
|
|
@@ -65,12 +65,21 @@ export function routesToShipkit(body: string): boolean {
|
|
|
65
65
|
return /\bshipkit\b\S*(?:\s+\S+)*?\s+ci(?:\s|$)/.test(body.split(/&&|\|\||;/)[0] ?? body)
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
/** is this path shipkit's own cli: a file named exactly `cli.ts` under a
|
|
69
|
+
* directory whose name says shipkit. A repo's own cli (`src/cli.ts`,
|
|
70
|
+
* `src/cli/comfy-ts-cli.ts`) is not, and taking it for shipkit writes
|
|
71
|
+
* scripts that call a command that cli does not have */
|
|
72
|
+
export function isShipkitCli(path: string): boolean {
|
|
73
|
+
const parts = path.split('/')
|
|
74
|
+
return parts.at(-1) === 'cli.ts' && parts.slice(0, -1).some((d) => /shipkit/.test(d))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// recover the consumption style from existing scripts: any script already
|
|
78
|
+
// running shipkit's cli by path fixes the style for new bodies
|
|
70
79
|
export function inferBin(scripts: Record<string, string>): string | undefined {
|
|
71
80
|
for (const body of Object.values(scripts)) {
|
|
72
81
|
const m = body.match(/^bun (?:--watch )?(\S+cli\.ts) /)
|
|
73
|
-
if (m != null && m[1] !== 'node_modules/rvlib-shipkit/src/cli.ts') return m[1]
|
|
82
|
+
if (m != null && isShipkitCli(m[1]!) && m[1] !== 'node_modules/rvlib-shipkit/src/cli.ts') return m[1]
|
|
74
83
|
}
|
|
75
84
|
return undefined
|
|
76
85
|
}
|
package/src/core/check-runner.ts
CHANGED
|
@@ -39,7 +39,7 @@ import { parallelCap } from './parallel.ts'
|
|
|
39
39
|
import { machinePool, SLOT_HELD_ENV, takeSlot, type SlotPool } from './slots.ts'
|
|
40
40
|
import { budgetLine, machineLoad } from './budget.ts'
|
|
41
41
|
import { killTree, spawnWithTimeout } from './spawn-timeout.ts'
|
|
42
|
-
import { formatLine, parseWireOutput, type Line } from './wire.ts'
|
|
42
|
+
import { FOREVER_MS, formatLine, parseWireOutput, type Line } from './wire.ts'
|
|
43
43
|
import { offLine, type WhenDecl } from './when.ts'
|
|
44
44
|
import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
|
|
45
45
|
import { Ledger, stampCached } from '../ledger/ledger.ts'
|
|
@@ -47,7 +47,7 @@ import { attachedStamp } from './outcome-words.ts'
|
|
|
47
47
|
import type { RunLog, RunStatus } from '../ledger/run-log.ts'
|
|
48
48
|
import { liveStateOf } from '../ledger/live-runs.ts'
|
|
49
49
|
import { CHECK_ID_ENV } from '../ledger/suite-log.ts'
|
|
50
|
-
import { rememberWorld, replayWorld, type WorldCache } from '../ledger/world-cache.ts'
|
|
50
|
+
import { rememberWorld, replayWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
|
|
51
51
|
import { extractProblems } from '../problems/extract.ts'
|
|
52
52
|
import { feedsProblemsPane, type Population, type Problem } from '../problems/problem-line.ts'
|
|
53
53
|
|
|
@@ -191,6 +191,9 @@ export type RunChecksOpts = {
|
|
|
191
191
|
* gate the whole repo, since the check's own gate already ran. */
|
|
192
192
|
exclude?: string[]
|
|
193
193
|
print?: (line: string) => void
|
|
194
|
+
/** where the green summary goes when the caller prints its own verdict
|
|
195
|
+
* (`shipkit ci` does). Default: print */
|
|
196
|
+
detail?: (line: string) => void
|
|
194
197
|
color?: boolean
|
|
195
198
|
onCheck?: (e: CheckEvent) => void
|
|
196
199
|
}
|
|
@@ -227,11 +230,21 @@ function closure(checks: RunnableCheck[], ids: string[]): Set<string> {
|
|
|
227
230
|
return keep
|
|
228
231
|
}
|
|
229
232
|
|
|
233
|
+
/** how many tests a suite ran, read from the headline it prints last
|
|
234
|
+
* (`Ran 920 tests across 12 files.`), null when it printed none */
|
|
235
|
+
export function testsRan(lastLine: string): number | null {
|
|
236
|
+
const m = /Ran (\d+) tests?/.exec(lastLine)
|
|
237
|
+
return m == null ? null : Number(m[1])
|
|
238
|
+
}
|
|
239
|
+
|
|
230
240
|
export type Selection = {
|
|
231
241
|
/** what this run measures */
|
|
232
242
|
selected: RunnableCheck[]
|
|
233
243
|
/** turned off by name, with the reason: never run, always reported */
|
|
234
244
|
off: RunnableCheck[]
|
|
245
|
+
/** off, and outside what `--only` asked about: reported where the caller
|
|
246
|
+
* keeps what nobody acts on */
|
|
247
|
+
offElsewhere?: RunnableCheck[]
|
|
235
248
|
/** not asked about by this run, and why */
|
|
236
249
|
parked: { id: string; text: string }[]
|
|
237
250
|
/** asked for, and it never said whether it blocks */
|
|
@@ -337,6 +350,8 @@ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'ful
|
|
|
337
350
|
if (o.onlyIds != null && o.onlyIds.length > 0) {
|
|
338
351
|
const keep = closure(o.checks, onlyIds)
|
|
339
352
|
selected = selected.filter((u) => keep.has(u.id))
|
|
353
|
+
// a narrowed run says nothing about the checks it never asked about
|
|
354
|
+
return { selected, off: off.filter((u) => keep.has(u.id)), offElsewhere: off.filter((u) => !keep.has(u.id)), parked, undeclared, errors }
|
|
340
355
|
}
|
|
341
356
|
return { selected, off, parked, undeclared, errors }
|
|
342
357
|
}
|
|
@@ -445,9 +460,9 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
445
460
|
|
|
446
461
|
// OFF, in ONE place: skipped by name, never run, never stored, and the
|
|
447
462
|
// reason printed where the verdict would have been
|
|
448
|
-
for (const u of sel.off) {
|
|
463
|
+
for (const u of [...sel.off, ...(sel.offElsewhere ?? [])]) {
|
|
449
464
|
const line: Line = { kind: 'skip', text: offLine(u.id, u.when) }
|
|
450
|
-
emit(line)
|
|
465
|
+
if (sel.off.includes(u)) emit(line); else (o.detail ?? print)(formatLine(line, color))
|
|
451
466
|
report(u, { id: u.id, status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [line], problems: [], lastLine: line.text, note: 'off', logPath: null })
|
|
452
467
|
}
|
|
453
468
|
// `--only` that leaves nothing to run asked a question nobody answers, and
|
|
@@ -499,10 +514,15 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
499
514
|
* or that a different shipkit measured it. */
|
|
500
515
|
const worldKey = (u: RunnableCheck): string => {
|
|
501
516
|
let code = ''
|
|
502
|
-
try { code = o.ledger?.layer.keyFor(u.id).codeKey ?? '' } catch { code = '' }
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
517
|
+
if (u.ttlMs < FOREVER_MS) try { code = o.ledger?.layer.keyFor(u.id).codeKey ?? '' } catch { code = '' }
|
|
518
|
+
return worldKeyOf({
|
|
519
|
+
ttlMs: u.ttlMs,
|
|
520
|
+
toolchain: u.ttlMs < FOREVER_MS ? o.ledger?.layer.toolchainId ?? '' : '',
|
|
521
|
+
code,
|
|
522
|
+
rotation: u.rotation ?? '',
|
|
523
|
+
// a check that writes answers a different question when frozen
|
|
524
|
+
frozen: u.writes === true ? process.env['SHIPKIT_FROZEN'] === '1' : null,
|
|
525
|
+
})
|
|
506
526
|
}
|
|
507
527
|
|
|
508
528
|
// a non-blocking failure becomes a WARNING here, at the source: printing
|
|
@@ -532,10 +552,18 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
532
552
|
// baked into a remembered line comes back on a replay as if this run
|
|
533
553
|
// had measured it (ledger/ledger.ts rule 5)
|
|
534
554
|
const head = u.lines[0]
|
|
555
|
+
// a suite's count rides on its own line: `test 920 tests (1.4s)`
|
|
556
|
+
const ran = u.status === 'pass' || u.status === 'warn' ? testsRan(u.lastLine) : null
|
|
557
|
+
const counted = ran != null && head != null && head.kind === 'ok' && (head.text === u.id || head.text.startsWith(`${u.id} (`))
|
|
558
|
+
const id = counted ? `${u.id} ${ran} test${ran === 1 ? '' : 's'}` : u.id
|
|
535
559
|
const shown = u.synthetic && !u.replayed && head != null
|
|
536
|
-
? [{ ...head, text: `${
|
|
537
|
-
: u.lines
|
|
538
|
-
|
|
560
|
+
? [{ ...head, text: `${id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
|
|
561
|
+
: counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}` }, ...u.lines.slice(1)] : u.lines
|
|
562
|
+
// a caller printing its own verdict wants one line per green check and
|
|
563
|
+
// only the warnings of a warned one; a failure prints whole
|
|
564
|
+
const quiet = (l: Line, i: number): boolean =>
|
|
565
|
+
o.detail != null && (u.status === 'pass' ? i > 0 : u.status === 'warn' || u.advisory ? l.kind === 'ok' || l.kind === 'note' || l.kind === 'skip' : false)
|
|
566
|
+
for (const [i, l] of shown.entries()) if (quiet(l, i)) o.detail!(formatLine(l, color)); else emit(l)
|
|
539
567
|
if (u.status === 'fail' && !u.advisory && u.logPath != null)
|
|
540
568
|
emit({ kind: 'fix-fail', text: `full log: ${u.logPath}` })
|
|
541
569
|
report(u, { id: u.id, status: u.status, advisory: u.advisory, replayed: u.replayed, ms: u.ms, exitCode: u.exitCode, lines: u.lines, problems: u.problems, lastLine: u.lastLine, note: u.note, logPath: u.logPath }, finishedAt, u.attached)
|
|
@@ -842,7 +870,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
842
870
|
const warned = results.filter((r) => r.status === 'warn' || (r.status === 'fail' && r.advisory))
|
|
843
871
|
const ran = results.filter((r) => r.status !== 'skip').length
|
|
844
872
|
if (blocked.length === 0 && !interrupted && results.length > 0)
|
|
845
|
-
|
|
873
|
+
(o.detail ?? print)(formatLine({ kind: 'ok', text: warned.length === 0 ? `all ${ran} check(s) green` : `all ${ran} check(s) passed, ${warned.length} with a warning: ${warned.map((r) => r.id).join(', ')}` }, color))
|
|
846
874
|
return {
|
|
847
875
|
ok: blocked.length === 0 && !interrupted,
|
|
848
876
|
interrupted,
|