rvlib-shipkit 0.38.0 → 1.0.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 +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
// THE RUNNER — one DAG over every check this repo has, whatever declared it
|
|
2
|
+
// and whichever body it carries. There is no second engine: `shipkit ci` and
|
|
3
|
+
// `shipkit check` differ by which checks they ASK for, never by how one runs.
|
|
4
|
+
//
|
|
5
|
+
// What it does, in the order a reader meets it:
|
|
6
|
+
// - a check with no `deps` is a root, one with `deps` waits for them to PASS.
|
|
7
|
+
// A filtered out dep counts as satisfied, a failed or skipped dep skips its
|
|
8
|
+
// dependents transitively with a note saying why. Deps double as the race
|
|
9
|
+
// guard between two checks that touch one file.
|
|
10
|
+
// - `off` is handled ONCE, by name, before anything is scheduled: the check
|
|
11
|
+
// never runs, it prints its skip line with the reason, and its row reports
|
|
12
|
+
// `skip` so no surface reads it as a green nobody measured.
|
|
13
|
+
// - a REPLAY comes first, from the ledger (content keyed) or from the world
|
|
14
|
+
// cache (TTL). A replayed check runs nothing and says so on its first line.
|
|
15
|
+
// - a `cmd` body is a subprocess: its whole output is streamed to a log file,
|
|
16
|
+
// its wire lines are parsed back, the exit code is truth, and it is killed
|
|
17
|
+
// with its whole process TREE at its ceiling (120s by default, whatever a
|
|
18
|
+
// build step's floor resolution gives it).
|
|
19
|
+
// - a `run` body runs IN PROCESS through check.ts runCheckBody, which tags its
|
|
20
|
+
// lines with the check id and catches a throw as that check's failure. It
|
|
21
|
+
// cannot be killed, so a hanging one hangs the gate: that is the price of
|
|
22
|
+
// in-process, and `cmd` is the answer for anything that can hang. Its one
|
|
23
|
+
// parameter hangs off ONE pass per call (core/check-pass.ts), which is where
|
|
24
|
+
// a shared scan is memoised, and what it read goes to the ledger with it.
|
|
25
|
+
// - `blocking: false` is demoted at the SOURCE: the FAIL lines are rewritten
|
|
26
|
+
// to warnings before anything reads them, and the real outcome rides beside
|
|
27
|
+
// them on the result, so the exit code means exactly "something that blocks
|
|
28
|
+
// you failed" and a page can still show the red.
|
|
29
|
+
// - ONE tap fires per check, with what the wire lines cannot say: the real
|
|
30
|
+
// status, whether it replayed, whether it can block, what it cost.
|
|
31
|
+
import { closeSync, mkdirSync, openSync, readdirSync, rmSync, writeSync } from 'fs'
|
|
32
|
+
import { basename, join } from 'path'
|
|
33
|
+
import { shareRun } from './attach.ts'
|
|
34
|
+
import { CheckCtx } from './check-ctx.ts'
|
|
35
|
+
import { CheckPass } from './check-pass.ts'
|
|
36
|
+
import type { ReadSet } from './read-set.ts'
|
|
37
|
+
import { RepoView } from '../ledger/repo-view.ts'
|
|
38
|
+
import { parallelCap } from './parallel.ts'
|
|
39
|
+
import { machinePool, SLOT_HELD_ENV, takeSlot, type SlotPool } from './slots.ts'
|
|
40
|
+
import { killTree, spawnWithTimeout } from './spawn-timeout.ts'
|
|
41
|
+
import { formatLine, parseWireOutput, type Line } from './wire.ts'
|
|
42
|
+
import { offLine, type WhenDecl } from './when.ts'
|
|
43
|
+
import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
|
|
44
|
+
import { Ledger, stampCached } from '../ledger/ledger.ts'
|
|
45
|
+
import { attachedStamp } from './outcome-words.ts'
|
|
46
|
+
import type { RunLog, RunStatus } from '../ledger/run-log.ts'
|
|
47
|
+
import { liveStateOf } from '../ledger/live-runs.ts'
|
|
48
|
+
import { CHECK_ID_ENV } from '../ledger/suite-log.ts'
|
|
49
|
+
import { rememberWorld, replayWorld, type WorldCache } from '../ledger/world-cache.ts'
|
|
50
|
+
import { extractProblems } from '../problems/extract.ts'
|
|
51
|
+
import { feedsProblemsPane, type Population, type Problem } from '../problems/problem-line.ts'
|
|
52
|
+
|
|
53
|
+
/** the ceiling a check takes when it names none. A build step's floor
|
|
54
|
+
* resolution hands it a longer one: a whole tool chain is not one question
|
|
55
|
+
* about the world. */
|
|
56
|
+
export const CHECK_TIMEOUT_MS = 120_000
|
|
57
|
+
|
|
58
|
+
/** epoch ms that never goes backwards inside one process, whole numbers so a
|
|
59
|
+
* row's `at - ms` is its start exactly: the wall clock can step back (an NTP
|
|
60
|
+
* correction), and a check stamped before an earlier one ended makes the
|
|
61
|
+
* record show more checks at once than ever ran */
|
|
62
|
+
const clock = (): number => Math.floor(performance.timeOrigin + performance.now())
|
|
63
|
+
|
|
64
|
+
export type CheckBody = Pick<CheckDef, 'run' | 'cmd' | 'env' | 'cwd'>
|
|
65
|
+
|
|
66
|
+
/** one check, resolved: everything the runner needs and nothing it does not.
|
|
67
|
+
* ledger/gate-checks.ts builds these from the one enumeration, so a surface
|
|
68
|
+
* can never list a different set than the one that runs. */
|
|
69
|
+
export type RunnableCheck = {
|
|
70
|
+
id: string
|
|
71
|
+
title: string
|
|
72
|
+
/** the fix pointer floor, appended after `docsBase` when a failing body
|
|
73
|
+
* attached no precise pointer of its own */
|
|
74
|
+
owner: string
|
|
75
|
+
/** null = it never said, which the runner treats as fatal and the caller
|
|
76
|
+
* reports as a defect */
|
|
77
|
+
blocking: boolean | null
|
|
78
|
+
/** a build step: the gate runs it, the health pass does not */
|
|
79
|
+
build: boolean
|
|
80
|
+
when: WhenDecl
|
|
81
|
+
deps: string[]
|
|
82
|
+
group: string | null
|
|
83
|
+
timeoutMs: number
|
|
84
|
+
/** the declared world TTL in ms, 0 when this check is not a world probe */
|
|
85
|
+
ttlMs: number
|
|
86
|
+
/** the rotation half of a world key (a secret hash, a config switch) */
|
|
87
|
+
rotation?: string
|
|
88
|
+
/** it rewrites the repo unless frozen, so frozen is part of its key */
|
|
89
|
+
writes?: boolean
|
|
90
|
+
body: CheckBody
|
|
91
|
+
/** an idempotent repair exists, so a fix pointer advertises it */
|
|
92
|
+
fix?: unknown
|
|
93
|
+
/** what a failing subprocess points the reader at */
|
|
94
|
+
pointer: string | null
|
|
95
|
+
/** how it is remembered (problems/problem-line.ts populationOf). Absent
|
|
96
|
+
* reads a declared TTL as world and anything else as not */
|
|
97
|
+
population?: Population
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type CheckResult = {
|
|
101
|
+
id: string
|
|
102
|
+
/** the REAL outcome. A failure that cannot block still says fail here */
|
|
103
|
+
status: RunStatus
|
|
104
|
+
/** it failed and cannot block: red on its own page, never on the gate */
|
|
105
|
+
advisory: boolean
|
|
106
|
+
replayed: boolean
|
|
107
|
+
ms: number
|
|
108
|
+
exitCode: number | null
|
|
109
|
+
/** the check's own wire lines, demoted already when it cannot block */
|
|
110
|
+
lines: Line[]
|
|
111
|
+
/** its file anchored findings, for the problems pane. Empty for a world
|
|
112
|
+
* check, whatever it printed (problems/problem-line.ts owns the rule) */
|
|
113
|
+
problems: Problem[]
|
|
114
|
+
/** the headline a failure summary reads */
|
|
115
|
+
lastLine: string
|
|
116
|
+
/** why it did not run (a dead dep, a bail, an interrupt) */
|
|
117
|
+
note: string
|
|
118
|
+
logPath: string | null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** one check finishing, with the facts the wire lines drop. ONE tap: the TUI,
|
|
122
|
+
* the hub stream and the run record all read this. */
|
|
123
|
+
export type CheckEvent = {
|
|
124
|
+
id: string
|
|
125
|
+
/** the name the wire and the TUI know it by (its title) */
|
|
126
|
+
name: string
|
|
127
|
+
status: RunStatus
|
|
128
|
+
ms: number
|
|
129
|
+
replayed: boolean
|
|
130
|
+
blocking: boolean | null
|
|
131
|
+
advisory: boolean
|
|
132
|
+
lines: Line[]
|
|
133
|
+
/** file anchored findings of this verdict, measured or replayed. The
|
|
134
|
+
* runner always sets it, absent reads as none */
|
|
135
|
+
problems?: Problem[]
|
|
136
|
+
logPath?: string | null
|
|
137
|
+
lastLine?: string
|
|
138
|
+
note?: string
|
|
139
|
+
/** it measured nothing here: another run measuring the same key answered it (core/attach.ts) */
|
|
140
|
+
attached?: boolean
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** which question is being asked. `gate` is `shipkit ci` (is my change
|
|
144
|
+
* good), `health` is `shipkit check` (is this repo healthy), and the only
|
|
145
|
+
* thing it changes is WHICH checks are asked. */
|
|
146
|
+
export type Audience = 'gate' | 'health'
|
|
147
|
+
|
|
148
|
+
export type RunChecksOpts = {
|
|
149
|
+
checks: RunnableCheck[]
|
|
150
|
+
root: string
|
|
151
|
+
config: CheckEnv['config']
|
|
152
|
+
audience: Audience
|
|
153
|
+
/** `release` checks are in at the full tier and out at the quick one */
|
|
154
|
+
fullTier: boolean
|
|
155
|
+
/** where a `cmd` check's whole output is streamed */
|
|
156
|
+
logDir: string
|
|
157
|
+
/** the world answers' store, null = nothing is remembered by TTL */
|
|
158
|
+
cache: WorldCache | null
|
|
159
|
+
/** the run's ledger, null = every check is measured */
|
|
160
|
+
ledger: Ledger | null
|
|
161
|
+
/** the run record, written per check, never read */
|
|
162
|
+
log: RunLog | null
|
|
163
|
+
/** extra environment for every `cmd` check of this run */
|
|
164
|
+
childEnv?: Record<string, string | undefined>
|
|
165
|
+
bail?: boolean
|
|
166
|
+
maxParallel?: number
|
|
167
|
+
/** the machine pool every measured check takes a slot from (core/slots.ts).
|
|
168
|
+
* Omitted: this machine's pool. null: no pool, the in process cap alone */
|
|
169
|
+
pool?: SlotPool | null
|
|
170
|
+
skipGroups?: string[]
|
|
171
|
+
onlyIds?: string[]
|
|
172
|
+
withIds?: string[]
|
|
173
|
+
/** run only the checks that can BLOCK. A check declaring blocking:false
|
|
174
|
+
* cannot change the outcome, so a release does not spend a third-party
|
|
175
|
+
* API call to learn that a DNS provider is reachable. */
|
|
176
|
+
onlyBlocking?: boolean
|
|
177
|
+
/** the BUILD steps alone. The TUI's tasks panel owns this: it renders the
|
|
178
|
+
* rest in its own panel, and running them twice per refresh proves the
|
|
179
|
+
* same thing twice. */
|
|
180
|
+
buildOnly?: boolean
|
|
181
|
+
/** the gate asks the PROACTIVE checks too (`release.requireHealthy`): a
|
|
182
|
+
* repo that wants a missing cred to stop its publish asks for exactly
|
|
183
|
+
* those probes, and the everyday opt-out must not drop them silently. */
|
|
184
|
+
includeProactive?: boolean
|
|
185
|
+
/** ids this caller does not need. A sub-unit release skips the steps that
|
|
186
|
+
* gate the whole repo, since the check's own gate already ran. */
|
|
187
|
+
exclude?: string[]
|
|
188
|
+
print?: (line: string) => void
|
|
189
|
+
color?: boolean
|
|
190
|
+
onCheck?: (e: CheckEvent) => void
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export type RunChecksSummary = {
|
|
194
|
+
/** nothing that can block you failed */
|
|
195
|
+
ok: boolean
|
|
196
|
+
interrupted: boolean
|
|
197
|
+
/** every check that was asked for, in declaration order */
|
|
198
|
+
results: CheckResult[]
|
|
199
|
+
/** checks this run did not ask about, with the reason */
|
|
200
|
+
parked: string[]
|
|
201
|
+
/** checks that really failed and cannot block */
|
|
202
|
+
advisoryFailures: string[]
|
|
203
|
+
/** checks that never declared whether they block */
|
|
204
|
+
undeclared: string[]
|
|
205
|
+
/** a registration nobody can act on: the run printed these and stopped */
|
|
206
|
+
errors: string[]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '').replace(/\r/g, '')
|
|
210
|
+
|
|
211
|
+
// only-mode keeps the transitive dep closure — running `--only typecheck`
|
|
212
|
+
// with a lint race-guard dep must still run lint first
|
|
213
|
+
function closure(checks: RunnableCheck[], ids: string[]): Set<string> {
|
|
214
|
+
const byId = new Map(checks.map((u) => [u.id, u]))
|
|
215
|
+
const keep = new Set<string>()
|
|
216
|
+
const visit = (id: string): void => {
|
|
217
|
+
if (keep.has(id)) return
|
|
218
|
+
keep.add(id)
|
|
219
|
+
for (const d of byId.get(id)?.deps ?? []) visit(d)
|
|
220
|
+
}
|
|
221
|
+
for (const id of ids) visit(id)
|
|
222
|
+
return keep
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export type Selection = {
|
|
226
|
+
/** what this run measures */
|
|
227
|
+
selected: RunnableCheck[]
|
|
228
|
+
/** turned off by name, with the reason: never run, always reported */
|
|
229
|
+
off: RunnableCheck[]
|
|
230
|
+
/** not asked about by this run, and why */
|
|
231
|
+
parked: { id: string; text: string }[]
|
|
232
|
+
/** asked for, and it never said whether it blocks */
|
|
233
|
+
undeclared: string[]
|
|
234
|
+
errors: string[]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** what this run asks about. The audience decides the shape of the question,
|
|
238
|
+
* `--only` and `--with` name checks past any filter, and OFF is the one word
|
|
239
|
+
* no flag lifts. */
|
|
240
|
+
export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'fullTier' | 'skipGroups' | 'onlyIds' | 'withIds' | 'onlyBlocking' | 'buildOnly' | 'exclude' | 'includeProactive'>): Selection {
|
|
241
|
+
const errors: string[] = []
|
|
242
|
+
const ids = new Set(o.checks.map((u) => u.id))
|
|
243
|
+
if (ids.size !== o.checks.length) errors.push('duplicate check ids')
|
|
244
|
+
for (const u of o.checks) {
|
|
245
|
+
if ((u.body.cmd == null) === (u.body.run == null)) errors.push(`check ${u.id}: exactly one body required, \`run\` (in process) or \`cmd\` (argv)`)
|
|
246
|
+
for (const d of u.deps) if (!ids.has(d)) errors.push(`check ${u.id}: unknown dep '${d}'`)
|
|
247
|
+
}
|
|
248
|
+
// `--only` and `--with` address a check by the name an operator last saw
|
|
249
|
+
// it under: its id, its title, or the file a probe lives in
|
|
250
|
+
const byName = new Map<string, string>()
|
|
251
|
+
for (const u of o.checks) {
|
|
252
|
+
byName.set(u.id.toLowerCase(), u.id)
|
|
253
|
+
byName.set(u.title.toLowerCase(), u.id)
|
|
254
|
+
if (u.owner !== '') byName.set(u.owner.toLowerCase(), u.id)
|
|
255
|
+
}
|
|
256
|
+
// `--only` also takes a GROUP, every check declaring that `group`, so a
|
|
257
|
+
// family of checks runs without a hand kept list. A check name wins over
|
|
258
|
+
// a group of the same spelling
|
|
259
|
+
const byGroup = new Map<string, string[]>()
|
|
260
|
+
for (const u of o.checks) if (u.group != null) byGroup.set(u.group.toLowerCase(), [...(byGroup.get(u.group.toLowerCase()) ?? []), u.id])
|
|
261
|
+
const resolve = (raw: string, groups: boolean): string[] | null => {
|
|
262
|
+
const id = byName.get(raw.toLowerCase())
|
|
263
|
+
if (id != null) return [id]
|
|
264
|
+
return groups ? byGroup.get(raw.toLowerCase()) ?? null : null
|
|
265
|
+
}
|
|
266
|
+
const named = new Set<string>()
|
|
267
|
+
const onlyIds: string[] = []
|
|
268
|
+
for (const [raw, groups] of [...(o.onlyIds ?? []).map((r) => [r, true] as const), ...(o.withIds ?? []).map((r) => [r, false] as const)]) {
|
|
269
|
+
const ids = resolve(raw, groups)
|
|
270
|
+
if (ids == null) errors.push(`unknown check id${groups ? ' or group' : ''} '${raw}'`)
|
|
271
|
+
else for (const id of ids) { named.add(id); if (groups) onlyIds.push(id) }
|
|
272
|
+
}
|
|
273
|
+
// a dep cycle must die loud at select time, not hang the loop
|
|
274
|
+
const state = new Map<string, 0 | 1>()
|
|
275
|
+
const byId = new Map(o.checks.map((u) => [u.id, u]))
|
|
276
|
+
const walk = (id: string, trail: string[]): void => {
|
|
277
|
+
if (state.get(id) === 1) return
|
|
278
|
+
if (state.get(id) === 0) { errors.push(`dep cycle: ${[...trail, id].join(' → ')}`); return }
|
|
279
|
+
state.set(id, 0)
|
|
280
|
+
for (const d of byId.get(id)?.deps ?? []) walk(d, [...trail, id])
|
|
281
|
+
state.set(id, 1)
|
|
282
|
+
}
|
|
283
|
+
for (const u of o.checks) walk(u.id, [])
|
|
284
|
+
if (errors.length > 0) return { selected: [], off: [], parked: [], undeclared: [], errors }
|
|
285
|
+
|
|
286
|
+
const skipGroups = new Set(o.skipGroups ?? [])
|
|
287
|
+
const excluded = new Set(o.exclude ?? [])
|
|
288
|
+
const off: RunnableCheck[] = []
|
|
289
|
+
const parked: { id: string; text: string }[] = []
|
|
290
|
+
const undeclared: string[] = []
|
|
291
|
+
let selected: RunnableCheck[] = []
|
|
292
|
+
const nonBlocking = new Map<string, RunnableCheck>()
|
|
293
|
+
const take = (u: RunnableCheck): void => {
|
|
294
|
+
// a check that never said whether it blocks is a defect, not a
|
|
295
|
+
// setting, and it is named whether or not this run measures it
|
|
296
|
+
if (u.blocking == null) undeclared.push(u.id)
|
|
297
|
+
selected.push(u)
|
|
298
|
+
}
|
|
299
|
+
for (const u of o.checks) {
|
|
300
|
+
// IS IT THIS RUN'S BUSINESS AT ALL? The health pass never builds (a
|
|
301
|
+
// repo's test suite and its type checker answer "is my change good",
|
|
302
|
+
// which is the gate's question), the TUI's build panel asks for
|
|
303
|
+
// nothing else, and a sub-unit release skips what its own gate ran.
|
|
304
|
+
// A check out of scope here is not even reported as off: it is not part
|
|
305
|
+
// of this question.
|
|
306
|
+
if (!named.has(u.id)) {
|
|
307
|
+
if (o.audience === 'health' && u.build) continue
|
|
308
|
+
if (o.buildOnly === true && !u.build) continue
|
|
309
|
+
if (excluded.has(u.id)) { parked.push({ id: u.id, text: `${u.id} (not part of this release, its own gate covers it)` }); continue }
|
|
310
|
+
}
|
|
311
|
+
// OFF is the one word that means never, past every other flag: a
|
|
312
|
+
// switch a filter can lift is a suggestion
|
|
313
|
+
if (u.when.at === 'off') { off.push(u); continue }
|
|
314
|
+
if (named.has(u.id)) { take(u); continue }
|
|
315
|
+
if (u.group != null && skipGroups.has(u.group)) { parked.push({ id: u.id, text: `${u.id} (--skip ${u.group})` }); continue }
|
|
316
|
+
if (o.audience === 'gate') {
|
|
317
|
+
if (u.when.at === 'health' && o.includeProactive !== true) { parked.push({ id: u.id, text: `${u.id} (health)` }); continue }
|
|
318
|
+
if (u.when.at === 'release' && !o.fullTier) { parked.push({ id: u.id, text: `${u.id} (release)` }); continue }
|
|
319
|
+
}
|
|
320
|
+
if (o.onlyBlocking === true && u.blocking !== true) { if (u.blocking == null) undeclared.push(u.id); nonBlocking.set(u.id, u); continue }
|
|
321
|
+
take(u)
|
|
322
|
+
}
|
|
323
|
+
// a non blocking check cannot change the outcome on its own, but one a
|
|
324
|
+
// selected check waits on decides whether that check may run at all:
|
|
325
|
+
// dropping it read as "dep satisfied", so a suite whose prerequisite only
|
|
326
|
+
// warns ran without it and went red
|
|
327
|
+
for (let i = 0; i < selected.length; i++)
|
|
328
|
+
for (const d of selected[i]!.deps) {
|
|
329
|
+
const dep = nonBlocking.get(d)
|
|
330
|
+
if (dep != null) { nonBlocking.delete(d); selected.push(dep) }
|
|
331
|
+
}
|
|
332
|
+
if (o.onlyIds != null && o.onlyIds.length > 0) {
|
|
333
|
+
const keep = closure(o.checks, onlyIds)
|
|
334
|
+
selected = selected.filter((u) => keep.has(u.id))
|
|
335
|
+
}
|
|
336
|
+
return { selected, off, parked, undeclared, errors }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** THIS run's log dir. It used to be `logDir` itself, wiped at the top of
|
|
340
|
+
* every run: a second agent starting a gate deleted the log files the first
|
|
341
|
+
* was still streaming into, so the surviving FAIL line pointed at a path
|
|
342
|
+
* that no longer existed. Now each run owns `run-<pid>-<started>/`.
|
|
343
|
+
*
|
|
344
|
+
* Old run dirs are swept on the way in, and only ones nothing can be using:
|
|
345
|
+
* the pid must not be running AND the dir must be older than the grace
|
|
346
|
+
* window, so a recycled pid cannot cost a live run its logs. */
|
|
347
|
+
function openRunDir(base: string): string {
|
|
348
|
+
// the pid plus the millisecond separates two PROCESSES; the counter
|
|
349
|
+
// separates two runs inside one (the TUI can start a second before the
|
|
350
|
+
// clock ticks), so no run ever writes into another's dir
|
|
351
|
+
const dir = join(base, `run-${process.pid}-${Date.now()}-${runSeq++}`)
|
|
352
|
+
mkdirSync(dir, { recursive: true })
|
|
353
|
+
const cutoff = Date.now() - RUN_DIR_KEEP_MS
|
|
354
|
+
try {
|
|
355
|
+
for (const name of readdirSync(base)) {
|
|
356
|
+
// run dirs AND the per-run release survey files, which a killed
|
|
357
|
+
// run leaves behind: both carry the pid and the start time, so one
|
|
358
|
+
// sweep covers both rather than one path leaking forever
|
|
359
|
+
const m = /^(?:run|releases)-(\d+)-(\d+)(?:-\d+)?(?:\.json(?:\.lock)?)?$/.exec(name)
|
|
360
|
+
if (m == null || name === basename(dir)) continue
|
|
361
|
+
if (Number(m[2]) > cutoff) continue
|
|
362
|
+
try { process.kill(Number(m[1]), 0); continue } catch (e) { if ((e as { code?: string }).code === 'EPERM') continue }
|
|
363
|
+
rmSync(join(base, name), { recursive: true, force: true })
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// an unreadable log base is a housekeeping miss, never a gate failure
|
|
367
|
+
}
|
|
368
|
+
return dir
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** how long a finished run's logs stay readable, and the pid-reuse guard */
|
|
372
|
+
const RUN_DIR_KEEP_MS = 6 * 60 * 60 * 1000
|
|
373
|
+
let runSeq = 0
|
|
374
|
+
|
|
375
|
+
type Live = RunnableCheck & CheckResult & {
|
|
376
|
+
startedAt: number
|
|
377
|
+
/** when its body stopped, stamped BEFORE its slot goes back: a row must
|
|
378
|
+
* never outlast the slot, or another process starting on it overlaps */
|
|
379
|
+
endedAt: number
|
|
380
|
+
/** the body never reached its subject, so there is no answer to remember */
|
|
381
|
+
unmeasured: boolean
|
|
382
|
+
/** its answer came from another run measuring the same key */
|
|
383
|
+
attached: boolean
|
|
384
|
+
/** what an in process body read, null when it reached the world or ran
|
|
385
|
+
* as a subprocess */
|
|
386
|
+
reads: ReadSet | null
|
|
387
|
+
/** it printed nothing of its own, so its one line is the headline below */
|
|
388
|
+
synthetic: boolean
|
|
389
|
+
/** what the headline carries after the id: a failure's last line, or the
|
|
390
|
+
* reason it never ran */
|
|
391
|
+
tail: string
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
395
|
+
const print = o.print ?? ((l: string) => console.log(l))
|
|
396
|
+
const color = o.color ?? (process.stdout.isTTY && process.env.NO_COLOR == null)
|
|
397
|
+
const emit = (l: Line): void => print(formatLine(l, color))
|
|
398
|
+
const sel = selectChecks(o)
|
|
399
|
+
const advisoryFailures: string[] = []
|
|
400
|
+
if (sel.errors.length > 0) {
|
|
401
|
+
for (const e of sel.errors) emit({ kind: 'fail', text: e })
|
|
402
|
+
return { ok: false, interrupted: false, results: [], parked: [], advisoryFailures, undeclared: [], errors: sel.errors }
|
|
403
|
+
}
|
|
404
|
+
// ONE pass for every body of this call: its scans run once, and its file
|
|
405
|
+
// list is the ledger's, so a listing and its validation read one snapshot
|
|
406
|
+
const pass = new CheckPass(o.root, o.config, () => o.ledger?.view() ?? RepoView.open(o.root))
|
|
407
|
+
const results: CheckResult[] = []
|
|
408
|
+
const report = (u: RunnableCheck, r: CheckResult, at?: number, attached = false): void => {
|
|
409
|
+
results.push(r)
|
|
410
|
+
o.onCheck?.({
|
|
411
|
+
id: u.id, name: u.title, status: r.status, ms: r.ms, replayed: r.replayed,
|
|
412
|
+
blocking: u.blocking, advisory: r.advisory, lines: r.lines, problems: r.problems, logPath: r.logPath, lastLine: r.lastLine, note: r.note, attached,
|
|
413
|
+
})
|
|
414
|
+
// an attached check is filed as a replay: it measured nothing, and a
|
|
415
|
+
// row carrying its wait would read as a check running in this process
|
|
416
|
+
o.log?.add({ id: u.id, status: r.status, ms: r.ms, replayed: r.replayed || attached, lines: r.lines, at })
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// OFF, in ONE place: skipped by name, never run, never stored, and the
|
|
420
|
+
// reason printed where the verdict would have been
|
|
421
|
+
for (const u of sel.off) {
|
|
422
|
+
const line: Line = { kind: 'skip', text: offLine(u.id, u.when) }
|
|
423
|
+
emit(line)
|
|
424
|
+
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 })
|
|
425
|
+
}
|
|
426
|
+
// `--only` that leaves nothing to run asked a question nobody answers, and
|
|
427
|
+
// a green over zero checks reads exactly like a clean repo
|
|
428
|
+
if ((o.onlyIds ?? []).length > 0 && sel.selected.length === 0) {
|
|
429
|
+
const text = `--only ${o.onlyIds!.join(', ')} leaves nothing to run${sel.off.length > 0 ? `: ${sel.off.map((u) => u.id).join(', ')} is off` : ''}`
|
|
430
|
+
emit({ kind: 'fail', text })
|
|
431
|
+
return { ok: false, interrupted: false, results, parked: [], advisoryFailures, undeclared: [], errors: [text] }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const runDir = openRunDir(o.logDir)
|
|
435
|
+
const checks: Live[] = sel.selected.map((u) => ({
|
|
436
|
+
...u,
|
|
437
|
+
status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, synthetic: false, tail: '',
|
|
438
|
+
logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
|
|
439
|
+
}))
|
|
440
|
+
const selectedIds = new Set(checks.map((u) => u.id))
|
|
441
|
+
const byId = new Map(checks.map((u) => [u.id, u]))
|
|
442
|
+
const queued = new Set(checks.map((u) => u.id))
|
|
443
|
+
const done = new Map<string, Live>()
|
|
444
|
+
const running = new Map<string, Promise<void>>()
|
|
445
|
+
const cap = o.maxParallel ?? parallelCap()
|
|
446
|
+
const pool = o.pool === undefined ? machinePool() : o.pool
|
|
447
|
+
let slotWaitSaid = false
|
|
448
|
+
let interrupted = false
|
|
449
|
+
let bailed = false
|
|
450
|
+
const procs = new Map<string, ReturnType<typeof Bun.spawn>>()
|
|
451
|
+
const killed = new Map<string, string>()
|
|
452
|
+
const killAll = (reason: string): void => {
|
|
453
|
+
for (const [id, proc] of procs) {
|
|
454
|
+
killed.set(id, reason)
|
|
455
|
+
const tree = killTree(proc, 'SIGTERM')
|
|
456
|
+
setTimeout(() => { killTree(proc, 'SIGKILL', tree) }, 2000)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const depState = (u: Live): 'ready' | 'blocked' | 'dead' => {
|
|
461
|
+
for (const d of u.deps) {
|
|
462
|
+
if (!selectedIds.has(d)) continue // filtered dep = satisfied
|
|
463
|
+
const dep = done.get(d)
|
|
464
|
+
if (dep == null) return 'blocked'
|
|
465
|
+
if (dep.status !== 'pass') { u.note = `dep ${d} ${dep.status}`; return 'dead' }
|
|
466
|
+
}
|
|
467
|
+
return 'ready'
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** what a check is remembered under while nothing on disk describes it: a
|
|
471
|
+
* TTL says how long the WORLD keeps, never that the probe was rewritten
|
|
472
|
+
* or that a different shipkit measured it. */
|
|
473
|
+
const worldKey = (u: RunnableCheck): string => {
|
|
474
|
+
let code = ''
|
|
475
|
+
try { code = o.ledger?.layer.keyFor(u.id).codeKey ?? '' } catch { code = '' }
|
|
476
|
+
// a check that writes answers a different question when frozen
|
|
477
|
+
const frozen = u.writes === true ? `\0frozen=${process.env['SHIPKIT_FROZEN'] === '1'}` : ''
|
|
478
|
+
return `tc=${o.ledger?.layer.toolchainId ?? ''}\0code=${code}\0${u.rotation ?? ''}${frozen}`
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// a non-blocking failure becomes a WARNING here, at the source: printing
|
|
482
|
+
// FAIL and exiting 0 would be the tool lying about its own severity
|
|
483
|
+
const demote = (u: Live): void => {
|
|
484
|
+
if (u.blocking !== false || u.status !== 'fail') return
|
|
485
|
+
u.advisory = true
|
|
486
|
+
if (!advisoryFailures.includes(u.id)) advisoryFailures.push(u.id)
|
|
487
|
+
u.lines = u.lines.map((l) => (l.kind === 'fail' ? { ...l, kind: 'warn' as const } : l.kind === 'fix-fail' ? { ...l, kind: 'fix-warn' as const } : l))
|
|
488
|
+
u.problems = u.problems.map((p) => ({ ...p, severity: 'warning' as const }))
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** what this check contributes to the problems pane, from what it printed */
|
|
492
|
+
const feeds = (u: Live): boolean => feedsProblemsPane(u.population ?? (u.ttlMs > 0 ? 'world' : 'neither'))
|
|
493
|
+
const findings = (u: Live, lines: Line[], output?: string): Problem[] => {
|
|
494
|
+
if (!feeds(u)) return []
|
|
495
|
+
return extractProblems({ lines, output }, { root: o.root, cwd: u.body.cwd ?? o.root, check: u.id })
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const finish = (u: Live): void => {
|
|
499
|
+
const finishedAt = u.endedAt > 0 ? u.endedAt : clock()
|
|
500
|
+
u.ms = u.replayed || u.startedAt === 0 ? 0 : finishedAt - u.startedAt
|
|
501
|
+
done.set(u.id, u)
|
|
502
|
+
if (u.lines.length === 0) headlineOf(u)
|
|
503
|
+
demote(u)
|
|
504
|
+
// the duration is rendered at PRINT time and never stored: a wall time
|
|
505
|
+
// baked into a remembered line comes back on a replay as if this run
|
|
506
|
+
// had measured it (ledger/ledger.ts rule 5)
|
|
507
|
+
const head = u.lines[0]
|
|
508
|
+
const shown = u.synthetic && !u.replayed && head != null
|
|
509
|
+
? [{ ...head, text: `${u.id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
|
|
510
|
+
: u.lines
|
|
511
|
+
for (const l of shown) emit(l)
|
|
512
|
+
if (u.status === 'fail' && !u.advisory && u.logPath != null)
|
|
513
|
+
emit({ kind: 'fix-fail', text: `full log: ${u.logPath}` })
|
|
514
|
+
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)
|
|
515
|
+
if (u.status === 'fail' && !u.advisory && o.bail === true && !bailed) { bailed = true; killAll('bail') }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** a stored answer, re-emitted without running anything */
|
|
519
|
+
const replay = (u: Live): boolean => {
|
|
520
|
+
const hit = o.ledger?.replay(u.id)
|
|
521
|
+
if (hit != null) {
|
|
522
|
+
u.replayed = true
|
|
523
|
+
u.lines = stampCached(hit.lines, hit.measuredMs)
|
|
524
|
+
u.lastLine = hit.lastLine ?? ''
|
|
525
|
+
u.status = hit.status
|
|
526
|
+
// a replay stands for the verdict it replays, findings included:
|
|
527
|
+
// the inputs have not moved, so what was wrong is still wrong
|
|
528
|
+
u.problems = !feeds(u) ? [] : hit.problems ?? findings(u, hit.lines)
|
|
529
|
+
return true
|
|
530
|
+
}
|
|
531
|
+
if (o.cache == null || u.ttlMs <= 0) return false
|
|
532
|
+
const stored = replayWorld(o.cache, u.id, worldKey(u), u.ttlMs)
|
|
533
|
+
if (stored == null) return false
|
|
534
|
+
u.replayed = true
|
|
535
|
+
u.lines = stored
|
|
536
|
+
u.problems = findings(u, stored)
|
|
537
|
+
// a replayed WORLD negative keeps its severity: it is still the
|
|
538
|
+
// answer, and demoting it to a pass would hide a real finding
|
|
539
|
+
u.status = stored.some((l) => l.kind === 'warn' || l.kind === 'fail') ? 'warn' : 'pass'
|
|
540
|
+
return true
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const measure = async (u: Live): Promise<void> => {
|
|
544
|
+
const t = new CheckCtx(pass)
|
|
545
|
+
if (u.body.run != null) {
|
|
546
|
+
// check.ts owns the semantics: a throwing body still fails with its
|
|
547
|
+
// id, every line is tagged, and a failure carries a fix pointer
|
|
548
|
+
await runCheckBody({ id: u.id, title: u.title, owner: u.owner, run: u.body.run, fix: u.fix }, t)
|
|
549
|
+
u.reads = t.untracked == null ? t.reads.snapshot() : null
|
|
550
|
+
u.lines = t.lines
|
|
551
|
+
u.problems = findings(u, t.lines)
|
|
552
|
+
u.unmeasured = t.unmeasured
|
|
553
|
+
const last = t.lines.filter((l) => l.kind === 'fail').at(-1) ?? t.lines.at(-1)
|
|
554
|
+
u.lastLine = last?.text.slice(0, 200) ?? ''
|
|
555
|
+
u.status = t.failed ? 'fail' : t.warned ? 'warn' : 'pass'
|
|
556
|
+
u.exitCode = t.failed ? 1 : 0
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
const cmd = u.body.cmd!
|
|
560
|
+
// the log opens only once the child actually spawned (a missing binary
|
|
561
|
+
// must not leave a dangling open on the log dir), and every write is
|
|
562
|
+
// synchronous: when measure returns the whole log is on disk and no
|
|
563
|
+
// open is still pending against a dir the caller may remove next
|
|
564
|
+
let fd: number | null = null
|
|
565
|
+
let logProblem: string | null = null
|
|
566
|
+
const logWrite = (text: string): void => {
|
|
567
|
+
if (fd == null) return
|
|
568
|
+
try { writeSync(fd, text) } catch (e) { logProblem ??= (e as Error).message; try { closeSync(fd) } catch { /* already gone */ } fd = null }
|
|
569
|
+
}
|
|
570
|
+
const r = await spawnWithTimeout(cmd, {
|
|
571
|
+
timeoutMs: u.timeoutMs,
|
|
572
|
+
// a child runs on this check's slot and takes none of its own
|
|
573
|
+
env: { ...process.env, ...o.childEnv, ...u.body.env, [SLOT_HELD_ENV]: '1', [CHECK_ID_ENV]: u.id },
|
|
574
|
+
cwd: u.body.cwd ?? o.root,
|
|
575
|
+
onSpawn: (proc) => {
|
|
576
|
+
try { fd = openSync(u.logPath!, 'a') } catch (e) { logProblem = (e as Error).message }
|
|
577
|
+
procs.set(u.id, proc)
|
|
578
|
+
},
|
|
579
|
+
onChunk: (text) => {
|
|
580
|
+
logWrite(text)
|
|
581
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
582
|
+
const s = stripAnsi(raw).trim()
|
|
583
|
+
if (s !== '') u.lastLine = s.slice(0, 200)
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
})
|
|
587
|
+
procs.delete(u.id)
|
|
588
|
+
// TS narrows `fd` to its initializer across the await (the assignment
|
|
589
|
+
// happens in the onSpawn closure), so widen explicitly
|
|
590
|
+
const open = fd as number | null
|
|
591
|
+
if (open != null) try { closeSync(open) } catch { /* the write path already reported it */ }
|
|
592
|
+
if (r.spawnFailed) { u.lastLine = r.out; u.status = 'fail'; u.exitCode = -1; return }
|
|
593
|
+
const killReason = killed.get(u.id)
|
|
594
|
+
if (killReason != null) { u.note = `killed (${killReason})`; u.status = 'skip'; u.exitCode = r.code; return }
|
|
595
|
+
// a check REPORTS, in wire lines. A build step runs a TOOL, whose
|
|
596
|
+
// output is a log rather than a report, so its verdict is the one
|
|
597
|
+
// line the headline draws and the rest is in the file
|
|
598
|
+
u.lines = u.build ? [] : parseWireOutput(r.out)
|
|
599
|
+
if (logProblem != null) u.lines.push({ kind: 'warn', text: `${u.id}: its log ${u.logPath} was not written: ${logProblem}` })
|
|
600
|
+
u.exitCode = r.code
|
|
601
|
+
const failed = u.lines.some((l) => l.kind === 'fail')
|
|
602
|
+
if (r.timedOut) {
|
|
603
|
+
const tail = u.lastLine !== '' ? ` — ${u.lastLine}` : ''
|
|
604
|
+
u.lastLine = `timeout: ${u.id} killed at its ${(u.timeoutMs / 1000).toFixed(1)}s ceiling, raise its timeoutMs${tail}`
|
|
605
|
+
}
|
|
606
|
+
// exit code is truth: a non-zero exit with no FAIL line (a crash, non
|
|
607
|
+
// wire output, a timeout) must still fail loud. A BUILD step says it
|
|
608
|
+
// in its headline instead, which is the line that carries the wall
|
|
609
|
+
// time and the log path
|
|
610
|
+
if (r.code !== 0 && !failed && !u.build) {
|
|
611
|
+
u.lines.push({ kind: 'fail', text: `${u.id} exited ${r.code}${r.timedOut ? ` (timeout after ${u.timeoutMs}ms)` : ''}${u.lastLine === '' ? '' : ` — ${u.lastLine.slice(0, 160)}`}` })
|
|
612
|
+
if (u.pointer != null) u.lines.push({ kind: 'fix-fail', text: u.pointer })
|
|
613
|
+
}
|
|
614
|
+
u.status = r.code !== 0 || failed ? 'fail' : u.lines.some((l) => l.kind === 'warn' || l.kind === 'fix-warn') ? 'warn' : 'pass'
|
|
615
|
+
u.problems = findings(u, u.lines, r.out)
|
|
616
|
+
const lastFail = u.lines.filter((l) => l.kind === 'fail').at(-1)
|
|
617
|
+
if (lastFail != null) u.lastLine = lastFail.text.slice(0, 200)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** a check that printed nothing of its own still has to report that it
|
|
621
|
+
* ran: the verdict, its name, and (at print time) what it cost */
|
|
622
|
+
const headlineOf = (u: Live): void => {
|
|
623
|
+
u.synthetic = true
|
|
624
|
+
u.tail = u.status === 'fail' ? (u.lastLine === '' ? '' : ` — ${u.lastLine}`) : u.status === 'skip' ? (u.note === '' ? '' : ` (${u.note})`) : ''
|
|
625
|
+
u.lines = [{ kind: u.status === 'fail' ? 'fail' : u.status === 'skip' ? 'skip' : 'ok', text: `${u.id}${u.tail}` }]
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** what an attached asker adopts: the verdict and the lines BEFORE this
|
|
629
|
+
* run's own demotion, which the asker applies from its own declaration */
|
|
630
|
+
type Answer = Pick<Live, 'status' | 'lines' | 'problems' | 'lastLine' | 'exitCode' | 'logPath' | 'synthetic' | 'tail'> & { measuredMs: number }
|
|
631
|
+
const answerOf = (u: Live): Answer => ({
|
|
632
|
+
status: u.status, lines: u.lines, problems: u.problems, lastLine: u.lastLine, exitCode: u.exitCode, logPath: u.logPath,
|
|
633
|
+
synthetic: u.synthetic, tail: u.tail, measuredMs: (u.endedAt > 0 ? u.endedAt : clock()) - u.startedAt,
|
|
634
|
+
})
|
|
635
|
+
// only a GREEN is ever stored. A pass is one whatever the check blocks. A
|
|
636
|
+
// WARN from a check that cannot block is not: it may be a red about the
|
|
637
|
+
// world, and a content key has no TTL, so storing it would pin it until an
|
|
638
|
+
// unrelated edit moved the tree
|
|
639
|
+
const storable = (u: Live): u is Live & { status: 'pass' | 'warn' } => u.status === 'pass' || (u.status === 'warn' && u.blocking !== false)
|
|
640
|
+
|
|
641
|
+
const adopt = (u: Live, a: Answer, pid: number): void => {
|
|
642
|
+
const stamp = ` ${attachedStamp(pid, a.measuredMs)}`
|
|
643
|
+
u.status = a.status
|
|
644
|
+
u.lastLine = a.lastLine
|
|
645
|
+
u.exitCode = a.exitCode
|
|
646
|
+
// the full log lives in the run that measured
|
|
647
|
+
u.logPath = a.logPath
|
|
648
|
+
u.attached = true
|
|
649
|
+
const head = a.lines[0]
|
|
650
|
+
u.lines = head == null ? [] : [{ ...head, text: `${head.text}${stamp}` }, ...a.lines.slice(1)]
|
|
651
|
+
u.problems = feeds(u) ? a.problems ?? [] : []
|
|
652
|
+
if (o.ledger != null && storable(u))
|
|
653
|
+
o.ledger.record(u.id, { status: u.status, lines: a.lines, lastLine: a.lastLine, problems: u.problems, measuredMs: a.measuredMs })
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** ONE measurement per in flight question: an asker whose exact key
|
|
657
|
+
* another run is measuring right now waits for that answer instead of
|
|
658
|
+
* running the tool a second time (core/attach.ts) */
|
|
659
|
+
const measureShared = async (u: Live): Promise<void> => {
|
|
660
|
+
const slot = o.ledger?.attachSlot(u.id) ?? null
|
|
661
|
+
if (slot == null) return await measureAndKeep(u)
|
|
662
|
+
const r = await shareRun<Answer>(slot, {
|
|
663
|
+
askedAt: Date.now(),
|
|
664
|
+
label: u.id,
|
|
665
|
+
print,
|
|
666
|
+
// past the holder's own ceiling, so a live holder always answers first
|
|
667
|
+
maxWaitMs: u.timeoutMs + 30_000,
|
|
668
|
+
abort: () => interrupted || bailed,
|
|
669
|
+
}, async () => {
|
|
670
|
+
await measureAndKeep(u)
|
|
671
|
+
return { value: answerOf(u), share: !killed.has(u.id) && !u.unmeasured }
|
|
672
|
+
})
|
|
673
|
+
if (r.kind === 'attached') { o.ledger?.noteAttached(); adopt(u, r.value, r.pid) }
|
|
674
|
+
else if (r.kind === 'aborted') { u.status = 'skip'; u.note = bailed ? 'bailed' : 'interrupted'; u.lines = [] }
|
|
675
|
+
else if (r.kind === 'overlapped') emit({ kind: 'warn', text: `${u.id} ran again beside another run: ${r.warn}` })
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const measureAndKeep = async (u: Live): Promise<void> => {
|
|
679
|
+
// a machine slot for the body, and only the body: a replay or an
|
|
680
|
+
// attached wait runs nothing, so it holds none
|
|
681
|
+
const slot = pool == null ? null : await takeSlot(pool, { repo: o.config.name, check: u.id }, {
|
|
682
|
+
abort: () => interrupted || bailed,
|
|
683
|
+
onWait: (held) => {
|
|
684
|
+
if (slotWaitSaid) return
|
|
685
|
+
slotWaitSaid = true
|
|
686
|
+
const who = held.slice(0, 3).map((h) => `${h.repo}/${h.check} pid ${h.pid}`).join(', ')
|
|
687
|
+
emit({ kind: 'ok', text: `waiting for a machine slot: ${held.length} of ${pool.size} held${who === '' ? '' : ` (${who}${held.length > 3 ? ', …' : ''})`}` })
|
|
688
|
+
},
|
|
689
|
+
})
|
|
690
|
+
if (pool != null && slot == null) { u.status = 'skip'; u.note = bailed ? 'bailed' : 'interrupted'; u.lines = []; u.unmeasured = true; return }
|
|
691
|
+
// its time starts when it runs, so a row never counts the queue
|
|
692
|
+
u.startedAt = clock()
|
|
693
|
+
try { await measure(u) } finally { u.endedAt = clock(); slot?.release() }
|
|
694
|
+
// the headline is built BEFORE anything is remembered: a check
|
|
695
|
+
// whose result is stored empty replays as a line nobody can tell
|
|
696
|
+
// from a measurement
|
|
697
|
+
if (u.lines.length === 0) headlineOf(u)
|
|
698
|
+
// what may be remembered, and for how long, is ONE policy
|
|
699
|
+
// (ledger/world-cache.ts): a clean answer keeps for the declared
|
|
700
|
+
// TTL, a measured negative only long enough to stop a re-ask storm
|
|
701
|
+
if (o.cache != null && u.ttlMs > 0) {
|
|
702
|
+
const clean = u.status === 'pass'
|
|
703
|
+
u.lines = rememberWorld(o.cache, u.id, worldKey(u), u.ttlMs, {
|
|
704
|
+
lines: u.lines,
|
|
705
|
+
clean,
|
|
706
|
+
unmeasured: u.unmeasured || u.lines.some((l) => l.kind === 'skip'),
|
|
707
|
+
keepNegative: u.blocking === false,
|
|
708
|
+
})
|
|
709
|
+
}
|
|
710
|
+
if (o.ledger != null && storable(u))
|
|
711
|
+
o.ledger.record(u.id, { status: u.status, lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const start = (u: Live): void => {
|
|
715
|
+
queued.delete(u.id)
|
|
716
|
+
u.startedAt = clock()
|
|
717
|
+
const p = (async (): Promise<void> => {
|
|
718
|
+
if (replay(u)) return finish(u)
|
|
719
|
+
await measureShared(u)
|
|
720
|
+
finish(u)
|
|
721
|
+
})().finally(() => running.delete(u.id))
|
|
722
|
+
running.set(u.id, p)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// what is running, what finished and what waits on what, for the surfaces
|
|
726
|
+
// that show a run in flight. Written once per turn of the loop, after the
|
|
727
|
+
// starts, and once more marked ended when the runner returns
|
|
728
|
+
const snapshot = (ended: number | null) => liveStateOf({
|
|
729
|
+
cap,
|
|
730
|
+
total: checks.length,
|
|
731
|
+
finished: done,
|
|
732
|
+
running: running.keys(),
|
|
733
|
+
startedAt: (id) => byId.get(id)?.startedAt ?? 0,
|
|
734
|
+
queued,
|
|
735
|
+
depsOf: (id) => byId.get(id)?.deps ?? [],
|
|
736
|
+
selected: selectedIds,
|
|
737
|
+
ended,
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
const onSigint = (): void => { interrupted = true; killAll('interrupt') }
|
|
741
|
+
process.on('SIGINT', onSigint)
|
|
742
|
+
try {
|
|
743
|
+
while (queued.size > 0 || running.size > 0) {
|
|
744
|
+
if (interrupted || bailed) {
|
|
745
|
+
for (const id of [...queued]) {
|
|
746
|
+
const u = byId.get(id)!
|
|
747
|
+
u.note = bailed ? 'bailed' : 'interrupted'
|
|
748
|
+
queued.delete(id)
|
|
749
|
+
done.set(id, u)
|
|
750
|
+
}
|
|
751
|
+
await Promise.all(running.values())
|
|
752
|
+
break
|
|
753
|
+
}
|
|
754
|
+
let started = false
|
|
755
|
+
for (const id of [...queued]) {
|
|
756
|
+
if (running.size >= cap) break
|
|
757
|
+
const u = byId.get(id)!
|
|
758
|
+
const st = depState(u)
|
|
759
|
+
if (st === 'dead') { queued.delete(id); finish(u); started = true }
|
|
760
|
+
else if (st === 'ready') { start(u); started = true }
|
|
761
|
+
}
|
|
762
|
+
o.log?.live(snapshot(null))
|
|
763
|
+
if (running.size > 0) await Promise.race(running.values())
|
|
764
|
+
else if (!started && queued.size > 0) {
|
|
765
|
+
// nothing running, nothing startable: impossible unless a bug
|
|
766
|
+
for (const id of [...queued]) emit({ kind: 'fail', text: `${id}: scheduler stuck (unsatisfiable deps?)` })
|
|
767
|
+
break
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
} finally {
|
|
771
|
+
process.off('SIGINT', onSigint)
|
|
772
|
+
// the runner is the one scope that knows the run is over, a throw
|
|
773
|
+
// included: the row says so, and the flush deletes it
|
|
774
|
+
o.log?.live(snapshot(Date.now()))
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// bail and interrupt leftovers never finished: they are visible, never a
|
|
778
|
+
// fake pass
|
|
779
|
+
for (const u of checks) if (!results.some((r) => r.id === u.id)) report(u, { id: u.id, status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: u.lastLine, note: u.note, logPath: u.logPath })
|
|
780
|
+
const order = new Map(o.checks.map((u, i) => [u.id, i]))
|
|
781
|
+
results.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0))
|
|
782
|
+
const blocked = results.filter((r) => r.status === 'fail' && !r.advisory)
|
|
783
|
+
const skipped = results.filter((r) => r.status === 'skip' && r.note !== '' && r.note !== 'off')
|
|
784
|
+
if (blocked.length > 0) emit({ kind: 'fail', text: `${blocked.length}/${results.length} failed: ${blocked.map((r) => r.id).join(', ')}` })
|
|
785
|
+
if (skipped.length > 0) emit({ kind: 'note', text: `skip: ${skipped.map((r) => `${r.id} (${r.note})`).join(', ')}` })
|
|
786
|
+
if (blocked.length === 0 && !interrupted && results.length > 0) emit({ kind: 'ok', text: `all ${results.filter((r) => r.status !== 'skip').length} check(s) green` })
|
|
787
|
+
return {
|
|
788
|
+
ok: blocked.length === 0 && !interrupted,
|
|
789
|
+
interrupted,
|
|
790
|
+
results,
|
|
791
|
+
parked: sel.parked.map((p) => p.text),
|
|
792
|
+
advisoryFailures,
|
|
793
|
+
undeclared: sel.undeclared,
|
|
794
|
+
errors: [],
|
|
795
|
+
}
|
|
796
|
+
}
|