rvlib-shipkit 0.34.0 → 0.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/src/cli/cmd_Ci.ts +3 -0
- package/src/cli/cmd_Keys.ts +10 -4
- package/src/core/check-suite.ts +37 -92
- package/src/core/discover-checks.ts +13 -7
- package/src/core/floor.ts +13 -4
- package/src/core/motherhome.ts +54 -1
- package/src/core/run-check.ts +103 -12
- package/src/core/run-ci.ts +103 -29
- package/src/core/task-runner.ts +7 -0
- package/src/ledger/gate-units.ts +29 -3
- package/src/ledger/git-key.ts +56 -5
- package/src/ledger/git-scan.ts +15 -2
- package/src/ledger/ledger.ts +202 -0
- package/src/ledger/store.ts +122 -0
- package/src/ledger/toolchain-identity.ts +30 -0
- package/src/pb/collection-scaffold.ts +4 -4
- package/src/release/run-release.ts +5 -0
- package/src/release/run-releases.ts +7 -1
- package/src/repo-config.ts +26 -4
- package/src/rules/SK004-gitignore-policy.ts +1 -2
- package/src/rules/SK013-one-way-in-policy.ts +7 -1
package/src/core/run-check.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
// periodic health — contracts + every *.check.ts in the repo. Discovery IS
|
|
2
2
|
// the registration (core/discover-checks.ts); each check runs as a
|
|
3
|
-
// subprocess: wire lines parsed back, exit code is truth.
|
|
4
|
-
//
|
|
5
|
-
// here
|
|
6
|
-
//
|
|
3
|
+
// subprocess: wire lines parsed back, exit code is truth.
|
|
4
|
+
//
|
|
5
|
+
// NOTHING here decides what may be remembered any more. A rule or a check is
|
|
6
|
+
// replayed when it DECLARED how ("inputs" makes it content-keyed and the
|
|
7
|
+
// ledger owns it, "ttl" makes it a world probe and CheckSuite owns it), and
|
|
8
|
+
// otherwise it is measured every run. The blanket 30-minute orchestrator TTL
|
|
9
|
+
// that used to cover every plain check died with that: it cached by default,
|
|
10
|
+
// on top of whatever the check cached for itself, so one question had two
|
|
11
|
+
// staleness windows and neither was declared by anyone. `--fresh`
|
|
12
|
+
// (SHIPKIT_FRESH=1, which travels to children) busts the one that is left.
|
|
7
13
|
import { join, relative } from 'path'
|
|
8
14
|
import { CheckSuite, type CheckOutcome } from './check-suite.ts'
|
|
15
|
+
import { gateUnits } from '../ledger/gate-units.ts'
|
|
16
|
+
import { Ledger, stampReplayed } from '../ledger/ledger.ts'
|
|
17
|
+
import { storeRoot } from '../ledger/store.ts'
|
|
9
18
|
import { CheckCtx } from './check-ctx.ts'
|
|
10
19
|
import { discoverChecks } from './discover-checks.ts'
|
|
11
20
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
@@ -18,9 +27,6 @@ import { writeStatus } from './write-status.ts'
|
|
|
18
27
|
import { SHARE_ENV } from '../release/releases-share.ts'
|
|
19
28
|
import { rmSync } from 'fs'
|
|
20
29
|
|
|
21
|
-
// orchestrator cache for checks WITHOUT their own suite — short on purpose:
|
|
22
|
-
// this layer only absorbs back-to-back runs, real TTLs live in the checks
|
|
23
|
-
const DISCOVERED_TTL = '30m'
|
|
24
30
|
const CHECK_TIMEOUT_MS = 120_000
|
|
25
31
|
|
|
26
32
|
export type CheckOpts = {
|
|
@@ -44,12 +50,26 @@ export type CheckOpts = {
|
|
|
44
50
|
* spends a third-party API call per publish. Undeclared checks are still
|
|
45
51
|
* collected, because an unclassified check stays fatal. */
|
|
46
52
|
onlyBlocking?: boolean
|
|
53
|
+
/** the caller is the GATE (`shipkit ci`), so checks that declared
|
|
54
|
+
* `export const gate = false` are skipped. `shipkit check` leaves it unset
|
|
55
|
+
* and runs everything: the health surface is where a human todo list
|
|
56
|
+
* belongs, and the gate is not. */
|
|
57
|
+
gateOnly?: boolean
|
|
47
58
|
/** the caller narrowed the run to these ids (`ci --only`). Empty or absent
|
|
48
59
|
* means everything. A `--only` that narrowed TASKS while every check still
|
|
49
60
|
* ran made the flag actively harmful: `ci --only cycles`, a 0.03s task,
|
|
50
61
|
* probed every third-party service the repo knows about. One flag, one
|
|
51
62
|
* meaning, across all three surfaces. */
|
|
52
63
|
only?: string[]
|
|
64
|
+
/** the run's ledger, when the caller already opened one (runCi does). A
|
|
65
|
+
* standalone `shipkit check` opens its own. */
|
|
66
|
+
ledger?: Ledger | null
|
|
67
|
+
/** refresh the motherhome registry row. runCi passes false and does it
|
|
68
|
+
* ITSELF, after the ledger has committed: that row is a TRACKED file under
|
|
69
|
+
* `wiki/`, so writing it before the post-run re-hash makes the gate look
|
|
70
|
+
* like an agent editing the tree and throws away every result the run just
|
|
71
|
+
* measured. Two reports per gate run was duplication anyway. */
|
|
72
|
+
reportHome?: boolean
|
|
53
73
|
}
|
|
54
74
|
|
|
55
75
|
/** does this rule or check answer to one of the `--only` ids? Matched against
|
|
@@ -83,7 +103,61 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
83
103
|
const prevFrozen = process.env.SHIPKIT_FROZEN
|
|
84
104
|
if (o.fresh === true) process.env.SHIPKIT_FRESH = '1'
|
|
85
105
|
if (o.frozen === true) process.env.SHIPKIT_FROZEN = '1'
|
|
86
|
-
|
|
106
|
+
// the caller's ledger, or this run's own. `shipkit check` is a full run
|
|
107
|
+
// of every rule and check, so it deserves the same replay the gate gets.
|
|
108
|
+
let ledger: Ledger | null = o.ledger ?? null
|
|
109
|
+
if (ledger === null && o.ledger === undefined) {
|
|
110
|
+
try {
|
|
111
|
+
ledger = Ledger.open(root, gateUnits(root, c), { cliEntry: new URL('../cli.ts', import.meta.url).pathname, fresh: o.fresh })
|
|
112
|
+
} catch { ledger = null }
|
|
113
|
+
}
|
|
114
|
+
const replayed = new Set<string>()
|
|
115
|
+
const unitIdByName = new Map<string, string>()
|
|
116
|
+
/** what a TTL'd answer is remembered under, beside its rotation key. A TTL
|
|
117
|
+
* says how long the WORLD keeps; it cannot say that the probe was
|
|
118
|
+
* rewritten, or that a different shipkit measured it. Without these two a
|
|
119
|
+
* fixed check replayed its old verdict for the rest of its TTL. */
|
|
120
|
+
const worldKey = (unitId: string, rotation?: string): string => {
|
|
121
|
+
let code = ''
|
|
122
|
+
try { code = ledger?.layer.keyFor(unitId).codeKey ?? '' } catch { code = '' }
|
|
123
|
+
return `tc=${ledger?.layer.toolchainId ?? ''}\0code=${code}\0${rotation ?? ''}`
|
|
124
|
+
}
|
|
125
|
+
const suite = new CheckSuite({
|
|
126
|
+
name: c.name,
|
|
127
|
+
storeDir: storeRoot(root),
|
|
128
|
+
onResult: (name, r) => {
|
|
129
|
+
o.onResult?.(name, r)
|
|
130
|
+
// a replay measured nothing, and a red is never stored. A check
|
|
131
|
+
// that cannot block had its FAIL rewritten to a warn before this
|
|
132
|
+
// fires, so the demotion list is what says it failed: a content
|
|
133
|
+
// key has no TTL, so storing that red would pin it until an
|
|
134
|
+
// unrelated edit moved the tree, and "the box is off" is fixed by
|
|
135
|
+
// turning the box on, which changes no file.
|
|
136
|
+
if (ledger == null || r.cached || replayed.has(name) || r.status === 'skip' || r.status === 'fail') return
|
|
137
|
+
if (advisoryFailures.includes(name)) return
|
|
138
|
+
const id = unitIdByName.get(name)
|
|
139
|
+
if (id == null) return
|
|
140
|
+
ledger.record(id, { status: r.status, lines: r.lines, measuredMs: r.ms })
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
/** a stored answer, re-emitted as a runner that does no work. Keeping it in
|
|
144
|
+
* the suite rather than splicing lines afterwards is what preserves
|
|
145
|
+
* declaration order, which the wire contract is frozen on. */
|
|
146
|
+
const addReplay = (unitId: string, name: string, blocking: boolean): boolean => {
|
|
147
|
+
const hit = ledger?.replay(unitId)
|
|
148
|
+
if (hit == null) return false
|
|
149
|
+
replayed.add(name)
|
|
150
|
+
unitIdByName.set(name, unitId)
|
|
151
|
+
suite.addCheck({
|
|
152
|
+
blocking,
|
|
153
|
+
name,
|
|
154
|
+
logic: (t) => {
|
|
155
|
+
for (const l of stampReplayed(hit.lines, hit.measuredMs)) t.lines.push(l)
|
|
156
|
+
if (t.lines.some((l) => l.kind === 'warn' || l.kind === 'fix-warn')) t.warned = true
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
87
161
|
// built-in SK rules (rules/rule-registry.ts) THEN the repo's own
|
|
88
162
|
// (repo.config rules: []), id-tagged, crash-safe, fix-pointer floor
|
|
89
163
|
// (rules/rule.ts runRuleLint owns the semantics). `shipkit ci` runs this
|
|
@@ -104,11 +178,13 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
104
178
|
for (const rule of [...RULES, ...(c.rules ?? [])]) {
|
|
105
179
|
if (!matchesOnly(o.only, rule.id, rule.title)) continue
|
|
106
180
|
if (o.onlyBlocking === true && !rule.blocking) continue
|
|
181
|
+
unitIdByName.set(rule.title, rule.id)
|
|
182
|
+
if (addReplay(rule.id, rule.title, rule.blocking)) continue
|
|
107
183
|
suite.addCheck({
|
|
108
184
|
blocking: rule.blocking,
|
|
109
185
|
name: rule.title,
|
|
110
186
|
remainValidFor: rule.ttl,
|
|
111
|
-
cacheExtra:
|
|
187
|
+
cacheExtra: () => worldKey(rule.id, rule.cacheKey?.({ root, config: c })),
|
|
112
188
|
logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
|
|
113
189
|
})
|
|
114
190
|
}
|
|
@@ -123,22 +199,33 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
123
199
|
const childEnv: Record<string, string | undefined> = {
|
|
124
200
|
...process.env,
|
|
125
201
|
[SHARE_ENV]: sharePath,
|
|
126
|
-
...(o.fresh === true ? { SHIPKIT_FRESH: '1'
|
|
202
|
+
...(o.fresh === true ? { SHIPKIT_FRESH: '1' } : {}),
|
|
127
203
|
}
|
|
128
204
|
for (const d of discoverChecks(root)) {
|
|
129
205
|
const rel = relative(root, d.path)
|
|
130
206
|
// file wins, then the repo default, then nothing: an unclassified
|
|
131
207
|
// check is an error, never a guess
|
|
132
208
|
if (!matchesOnly(o.only, d.name, rel)) continue
|
|
209
|
+
// `ci` answers "is my change good". A check that answers something else,
|
|
210
|
+
// and that can sit red for months with nothing wrong, is not part of
|
|
211
|
+
// that answer at any interval. `shipkit check` still runs it, and
|
|
212
|
+
// naming it in `--only` still runs it, so opting out is never a way to
|
|
213
|
+
// make a check unreachable.
|
|
214
|
+
if (o.gateOnly === true && !d.gate && o.only == null) continue
|
|
133
215
|
const blocking = d.blocking ?? c.checkDefaults?.blocking
|
|
134
216
|
if (blocking == null) undeclared.push(rel)
|
|
135
217
|
if (o.onlyBlocking === true && blocking !== true) continue
|
|
218
|
+
unitIdByName.set(d.name, d.name)
|
|
219
|
+
if (addReplay(d.name, d.name, blocking ?? true)) continue
|
|
136
220
|
suite.addCheck({
|
|
137
221
|
// a check about the WORLD may remember a measured negative; one
|
|
138
222
|
// about this repo never does (check-suite.ts, the rule)
|
|
139
223
|
blocking: blocking ?? true,
|
|
140
224
|
name: d.name,
|
|
141
|
-
|
|
225
|
+
// a WORLD check says how long its answer keeps; everything else is
|
|
226
|
+
// measured every run rather than blanket-cached by this layer
|
|
227
|
+
remainValidFor: d.ttl ?? undefined,
|
|
228
|
+
cacheExtra: () => worldKey(d.name),
|
|
142
229
|
logic: async (t) => {
|
|
143
230
|
const r = await spawnWithTimeout(['bun', d.path], { cwd: root, timeoutMs: CHECK_TIMEOUT_MS, env: childEnv })
|
|
144
231
|
for (const l of parseWireOutput(r.out)) t.lines.push(l)
|
|
@@ -159,7 +246,11 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
159
246
|
if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
|
|
160
247
|
// frozen = a release-shaped run: reporting would re-dirty the tree the
|
|
161
248
|
// caller is about to demand clean (field wart 2026-08-05, second head)
|
|
162
|
-
if (o.frozen !== true) reportToMotherhome(root, c)
|
|
249
|
+
if (o.frozen !== true && o.reportHome !== false) reportToMotherhome(root, c)
|
|
250
|
+
// the caller that OPENED the ledger owns committing it: runCi commits once
|
|
251
|
+
// after its tasks and its checks, so a task and a check in one run share
|
|
252
|
+
// one post-run re-hash
|
|
253
|
+
if (o.ledger === undefined && ledger != null) r.lines.push(...ledger.header(ledger.commit()))
|
|
163
254
|
return { lines: r.lines, blocked: r.exitCode !== 0, advisoryFailures, undeclared }
|
|
164
255
|
}
|
|
165
256
|
|
package/src/core/run-ci.ts
CHANGED
|
@@ -11,15 +11,15 @@ import { reportToMotherhome } from './motherhome.ts'
|
|
|
11
11
|
import { collectChecks } from './run-check.ts'
|
|
12
12
|
import { runFixPass } from './run-fixers.ts'
|
|
13
13
|
import { formatLine } from './wire.ts'
|
|
14
|
-
import { runTasks, type TaskResult } from './task-runner.ts'
|
|
14
|
+
import { runTasks, selectTasks, type TaskResult } from './task-runner.ts'
|
|
15
15
|
import { writeStatus } from './write-status.ts'
|
|
16
16
|
import { rmSync } from 'fs'
|
|
17
17
|
|
|
18
18
|
import { SHARE_ENV } from '../release/releases-share.ts'
|
|
19
19
|
import { collectReports } from '../release/run-releases.ts'
|
|
20
20
|
import { withContractsScope } from '../contracts/contracts-once.ts'
|
|
21
|
-
import { gateUnits } from '../ledger/gate-units.ts'
|
|
22
|
-
import {
|
|
21
|
+
import { envTier, FULL_TESTS_ENV, gateUnits, type TestTier } from '../ledger/gate-units.ts'
|
|
22
|
+
import { Ledger, stampReplayed, type UnitOutcome } from '../ledger/ledger.ts'
|
|
23
23
|
|
|
24
24
|
export type CiOpts = {
|
|
25
25
|
bail?: boolean
|
|
@@ -61,6 +61,14 @@ export type CiOpts = {
|
|
|
61
61
|
* loads of repo.config.ts can disagree, and the release path classifies
|
|
62
62
|
* rule-vs-probe severity from the config it holds. */
|
|
63
63
|
config?: RepoConfig
|
|
64
|
+
/** re-measure everything: no unit replays from the ledger, and what this
|
|
65
|
+
* run measures is stored fresh. Travels to child checks as SHIPKIT_FRESH. */
|
|
66
|
+
fresh?: boolean
|
|
67
|
+
/** read the ledger at all. The release path passes false, unconditionally
|
|
68
|
+
* and not configurably: every remaining hole in the key is then capped at
|
|
69
|
+
* "an agent saw a stale local green", never "a stale green authorised a
|
|
70
|
+
* publish" (ledger/ledger.ts rule 4). */
|
|
71
|
+
ledgerRead?: boolean
|
|
64
72
|
/** which test tier the run asks the repo's suite for. 'quick' is the
|
|
65
73
|
* working default; 'full' sets SHIPKIT_TEST_FULL=1 for every task, which
|
|
66
74
|
* a suite reads to include the slow, real-world tiers it otherwise skips.
|
|
@@ -70,12 +78,8 @@ export type CiOpts = {
|
|
|
70
78
|
tests?: TestTier
|
|
71
79
|
}
|
|
72
80
|
|
|
73
|
-
export type TestTier
|
|
74
|
-
|
|
75
|
-
/** the env var a repo's own suite reads to widen itself. Named here because
|
|
76
|
-
* the gate is what sets it, and a suite that invents its own name is a tier
|
|
77
|
-
* the release path cannot reach. */
|
|
78
|
-
export const FULL_TESTS_ENV = 'SHIPKIT_TEST_FULL'
|
|
81
|
+
export type { TestTier } from '../ledger/gate-units.ts'
|
|
82
|
+
export { FULL_TESTS_ENV } from '../ledger/gate-units.ts'
|
|
79
83
|
|
|
80
84
|
/** what STATUS.md may claim about this run. `--with` ADDS a task, so it stays
|
|
81
85
|
* the full gate; `--only`, `--skip` and skipChecks each remove coverage, and
|
|
@@ -118,7 +122,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
118
122
|
const all = allTasks(c, root)
|
|
119
123
|
const { disabled, unfilled } = all
|
|
120
124
|
const skipIds = new Set(o.skipTaskIds ?? [])
|
|
121
|
-
const tier: TestTier = o.tests ?? (
|
|
125
|
+
const tier: TestTier = o.tests ?? envTier()
|
|
122
126
|
const tasks = all.tasks
|
|
123
127
|
.filter((t) => !skipIds.has(t.id))
|
|
124
128
|
// the whole run carries the flag rather than one task id: a repo names
|
|
@@ -132,27 +136,37 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
132
136
|
: `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
|
|
133
137
|
for (const t of all.tasks) if (skipIds.has(t.id)) sink(`skip: ${t.id} (not part of this release, the unit's own gate covers it)`)
|
|
134
138
|
for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
139
|
+
// THE LEDGER, opened once per run and AFTER the fix pass: the fixers
|
|
140
|
+
// rewrite source, and a scan taken before them would have every unit
|
|
141
|
+
// discarded by its own gate's repair work.
|
|
142
|
+
//
|
|
143
|
+
// Registration is validated on the way in, and it is loud in the gate
|
|
144
|
+
// rather than only in `shipkit keys`, because a unit declaring two
|
|
145
|
+
// contradictory populations is a bug in what binds every repo. The
|
|
146
|
+
// burn-down is one line, never a wall.
|
|
140
147
|
let registrationFailed = false
|
|
148
|
+
let ledger: Ledger | null = null
|
|
141
149
|
try {
|
|
142
|
-
|
|
150
|
+
ledger = Ledger.open(root, gateUnits(root, c, tasks), {
|
|
151
|
+
cliEntry: new URL('../cli.ts', import.meta.url).pathname,
|
|
152
|
+
read: o.ledgerRead,
|
|
153
|
+
fresh: o.fresh,
|
|
154
|
+
})
|
|
155
|
+
const reg = ledger.layer.registration
|
|
143
156
|
for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
|
|
144
157
|
if (registrationFailed) sink(' fix: a unit declares `inputs` (content), or `ttl` (world), or `never: <reason>`, and never two of them')
|
|
145
158
|
// both counts, always. Printing only the burn-down made `never:` the
|
|
146
159
|
// cheap way to shrink the visible number, and that field carries no
|
|
147
160
|
// verification at all: a growing decided population has to be as
|
|
148
|
-
// visible as a shrinking undeclared one.
|
|
149
|
-
// because nothing is cached yet and "so nothing can replay them"
|
|
150
|
-
// implies the rest are being replayed.
|
|
161
|
+
// visible as a shrinking undeclared one.
|
|
151
162
|
const decided = reg.units - reg.burnDown.length - reg.declared
|
|
152
163
|
if (reg.burnDown.length > 0 || decided > 0)
|
|
153
|
-
sink(`skip: cache declarations — ${reg.declared}/${reg.units} declared, ${decided} decided uncacheable, ${reg.burnDown.length} undeclared (shipkit keys --burn-down)`)
|
|
164
|
+
sink(`skip: cache declarations — ${reg.declared}/${reg.units} declared, ${decided} decided uncacheable, ${reg.burnDown.length} undeclared, so they are measured every run (shipkit keys --burn-down)`)
|
|
165
|
+
if (ledger.layer.failure != null)
|
|
166
|
+
sink(`WARN the git scan failed, so NOTHING replays and nothing is stored this run — ${ledger.layer.failure}`)
|
|
154
167
|
} catch (e) {
|
|
155
|
-
|
|
168
|
+
ledger = null
|
|
169
|
+
sink(`WARN ledger unavailable (${String(e).slice(0, 160)}) — every unit is measured this run, so the gate is correct and slower`)
|
|
156
170
|
}
|
|
157
171
|
// a floor slot nothing could fill is VISIBLE — a thin gate must never
|
|
158
172
|
// look like a full one (the fresh-repo floor gap)
|
|
@@ -176,22 +190,77 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
176
190
|
// paying a 4s network survey to answer it is the same bug --only was
|
|
177
191
|
// fixed for
|
|
178
192
|
const willRunChecks = o.skipChecks !== true && (o.onlyIds ?? []).length === 0
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
193
|
+
// REPLAY, before the DAG sees the list. A replayed task is removed from
|
|
194
|
+
// the run entirely, which is also what makes its dependents correct: the
|
|
195
|
+
// runner treats a filtered dep as satisfied, and only a GREEN is ever
|
|
196
|
+
// stored, so a dependent can never start on the back of a red nobody saw.
|
|
197
|
+
const selectable = new Set(selectTasks({ tasks, logDir: '', skipGroups: o.skipGroups ?? [], withIds: o.withIds ?? [], onlyIds: o.onlyIds ?? [] }).selected.map((t) => t.id))
|
|
198
|
+
const replayedTasks = new Map<string, UnitOutcome>()
|
|
199
|
+
const toRun = tasks
|
|
200
|
+
.filter((t) => {
|
|
201
|
+
if (ledger == null || !selectable.has(t.id)) return true
|
|
202
|
+
const hit = ledger.replay(t.id)
|
|
203
|
+
if (hit == null) return true
|
|
204
|
+
replayedTasks.set(t.id, hit)
|
|
205
|
+
for (const l of stampReplayed(hit.lines, hit.measuredMs)) sink(formatLine(l, false))
|
|
206
|
+
return false
|
|
207
|
+
})
|
|
208
|
+
// a dep ON a replayed task is DROPPED, not left dangling. The runner
|
|
209
|
+
// validates every dep against the list it was handed and dies with
|
|
210
|
+
// `unknown dep` otherwise, and dropping is the honest edge here: only a
|
|
211
|
+
// GREEN is ever replayed, so the dependent's precondition is met and
|
|
212
|
+
// this is the same "a filtered dep counts as satisfied" the runner
|
|
213
|
+
// already applies to --skip and opt-in tasks.
|
|
214
|
+
.map((t) => (t.deps?.some((d) => replayedTasks.has(d)) === true ? { ...t, deps: t.deps.filter((d) => !replayedTasks.has(d)) } : t))
|
|
215
|
+
// the pre-warm exists to hide the survey behind the TASKS. When every task
|
|
216
|
+
// replayed there is nothing to hide it behind, and starting it anyway is a
|
|
217
|
+
// 4s network call the run may never need: the five checks that want it
|
|
218
|
+
// fetch it themselves, and when they replay too, nobody pays for it at all.
|
|
219
|
+
const survey = willRunChecks && toRun.length > 0 ? collectReports(root, c).catch(() => null) : null
|
|
220
|
+
// a NAMED task that replayed leaves the list, so its name has to leave the
|
|
221
|
+
// filters with it: the runner validates `--only`/`--with` against the list
|
|
222
|
+
// it was handed and answers `unknown task id`. `bun run typecheck` and
|
|
223
|
+
// `bun run test` route through `--only`, so this is the reflex verb every
|
|
224
|
+
// agent in every wired repo types, going red on a warm store.
|
|
225
|
+
const onlyIds = (o.onlyIds ?? []).filter((id) => !replayedTasks.has(id))
|
|
226
|
+
const withIds = (o.withIds ?? []).filter((id) => !replayedTasks.has(id))
|
|
227
|
+
// narrowed to nothing is NOT the same as "no filter": an empty --only would
|
|
228
|
+
// widen the run to every task
|
|
229
|
+
const nothingToRun = replayedTasks.size > 0 && (toRun.length === 0 || ((o.onlyIds ?? []).length > 0 && onlyIds.length === 0))
|
|
230
|
+
// every asked-for task replayed: the DAG has nothing to schedule, and
|
|
231
|
+
// running it anyway would print `all 0 tasks green`, which reads as a gate
|
|
232
|
+
// that measured nothing rather than one that already knew the answer
|
|
233
|
+
const summary = nothingToRun
|
|
234
|
+
? { ok: true, results: [], interrupted: false }
|
|
235
|
+
: await runTasks({
|
|
236
|
+
tasks: toRun,
|
|
182
237
|
logDir: c.logDir ?? join(root, 'tmp/shipkit-ci'),
|
|
183
238
|
cwd: root,
|
|
184
239
|
bail: o.bail ?? false,
|
|
185
240
|
skipGroups: o.skipGroups ?? [],
|
|
186
|
-
withIds
|
|
187
|
-
onlyIds
|
|
241
|
+
withIds,
|
|
242
|
+
onlyIds,
|
|
188
243
|
print: sink,
|
|
189
244
|
onTaskFinish: o.onTaskFinish,
|
|
190
245
|
})
|
|
246
|
+
if (nothingToRun) sink(`ok: all ${replayedTasks.size} task(s) asked for replayed, none measured`)
|
|
191
247
|
// what the suite ACTUALLY ran, from its own summary line. A flag that was
|
|
192
248
|
// set proves the intent; the count proves the tests
|
|
193
|
-
const
|
|
194
|
-
|
|
249
|
+
for (const r of summary.results) {
|
|
250
|
+
if (ledger == null || r.status !== 'pass') continue
|
|
251
|
+
const surfaced = r.lines.filter((l) => l.kind === 'warn' || l.kind === 'fix-warn' || l.kind === 'skip')
|
|
252
|
+
ledger.record(r.id, {
|
|
253
|
+
status: surfaced.some((l) => l.kind === 'warn' || l.kind === 'fix-warn') ? 'warn' : 'pass',
|
|
254
|
+
// the headline is REBUILT rather than captured: the runner renders
|
|
255
|
+
// the wall time into it, and a duration baked into a stored line
|
|
256
|
+
// would be replayed as if this run had measured it
|
|
257
|
+
lines: [{ kind: 'ok', text: r.id }, ...surfaced],
|
|
258
|
+
lastLine: r.lastLine,
|
|
259
|
+
measuredMs: r.durationMs,
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
const ranTests = [...summary.results.map((r) => r.lastLine ?? ''), ...[...replayedTasks.values()].map((o) => o.lastLine ?? '')]
|
|
263
|
+
.map((l) => /Ran (\d+) tests?/.exec(l)?.[1])
|
|
195
264
|
.find((n) => n != null)
|
|
196
265
|
if (ranTests != null) sink(`ok: test tier ${tier}: ${ranTests} tests ran`)
|
|
197
266
|
// the checks, in the SAME command. there is no second surface: a check
|
|
@@ -202,7 +271,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
202
271
|
if (survey != null) await survey
|
|
203
272
|
const checks = o.skipChecks === true
|
|
204
273
|
? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
|
|
205
|
-
: await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds })
|
|
274
|
+
: await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds, fresh: o.fresh, ledger, reportHome: false, gateOnly: true })
|
|
206
275
|
if (priorShare == null) delete process.env[SHARE_ENV]; else process.env[SHARE_ENV] = priorShare
|
|
207
276
|
for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
|
|
208
277
|
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
@@ -219,6 +288,11 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
219
288
|
sink(' fix: shipkit check')
|
|
220
289
|
} else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
|
|
221
290
|
}
|
|
291
|
+
// the post-run re-hash, then the store. NOTHING was written while the gate
|
|
292
|
+
// ran: a unit whose inputs moved under it answered about a tree that no
|
|
293
|
+
// longer exists, and this is where that result is thrown away instead of
|
|
294
|
+
// stored under a key that describes the file as it was before the edit.
|
|
295
|
+
if (ledger != null) for (const l of ledger.header(ledger.commit())) sink(formatLine(l, false))
|
|
222
296
|
const code = summary.ok && !fixFailed && !registrationFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
|
|
223
297
|
// motherhome row (docsBase wiki on this disk) — never affects the gate,
|
|
224
298
|
// and never fires under frozen: a bump/release gate must not re-dirty
|
package/src/core/task-runner.ts
CHANGED
|
@@ -45,6 +45,13 @@ export type TaskDef = {
|
|
|
45
45
|
never?: string
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** every field a `TaskDef` may carry. Declared as a value so `defineRepo` can
|
|
49
|
+
* refuse a typo inside a `defaults` slot: a slot with no `cmd` DECORATES the
|
|
50
|
+
* resolved task, so a misspelled field is not a type error at the call site
|
|
51
|
+
* and would land as a task that is quietly never cached. Add a field above,
|
|
52
|
+
* add it here. */
|
|
53
|
+
export const TASK_DEF_KEYS = ['id', 'cmd', 'logic', 'deps', 'env', 'cwd', 'group', 'optIn', 'timeoutMs', 'inputs', 'ttl', 'never'] as const
|
|
54
|
+
|
|
48
55
|
export type TaskResult = {
|
|
49
56
|
id: string
|
|
50
57
|
status: 'pass' | 'fail' | 'skipped'
|
package/src/ledger/gate-units.ts
CHANGED
|
@@ -8,14 +8,40 @@
|
|
|
8
8
|
// that drifts the first time somebody adds a check.
|
|
9
9
|
import { relative } from 'path'
|
|
10
10
|
import { allTasks, type RepoConfig } from '../repo-config.ts'
|
|
11
|
+
import type { TaskDef } from '../core/task-runner.ts'
|
|
11
12
|
import { discoverChecks } from '../core/discover-checks.ts'
|
|
12
13
|
import { RULES } from '../rules/rule-registry.ts'
|
|
13
14
|
import type { UnitDecl } from './git-key.ts'
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/** the env var a repo's own suite reads to widen itself. It lives HERE because
|
|
17
|
+
* it rewrites every task's env, which is part of the key: a run that asked for
|
|
18
|
+
* the quick tier and one that asked for the full tier are different questions,
|
|
19
|
+
* and a surface that renders keys without applying it renders a tier nobody
|
|
20
|
+
* ran. */
|
|
21
|
+
export const FULL_TESTS_ENV = 'SHIPKIT_TEST_FULL'
|
|
22
|
+
|
|
23
|
+
export type TestTier = 'quick' | 'full'
|
|
24
|
+
|
|
25
|
+
/** the tier this process is asking for, absent an explicit choice */
|
|
26
|
+
export function envTier(): TestTier {
|
|
27
|
+
return process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** the repo's tasks as the gate will actually run them at `tier`. */
|
|
31
|
+
export function tieredTasks(c: RepoConfig, root: string, tier: TestTier): TaskDef[] {
|
|
32
|
+
const tasks = allTasks(c, root).tasks
|
|
33
|
+
return tier === 'full' ? tasks.map((t) => ({ ...t, env: { ...t.env, [FULL_TESTS_ENV]: '1' } })) : tasks
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** every task, rule and discovered check, in the order the gate meets them.
|
|
37
|
+
*
|
|
38
|
+
* `tasks` overrides what the repo resolves to, and the gate MUST pass what it
|
|
39
|
+
* is actually about to run: `--full` rewrites every task's env, and a key
|
|
40
|
+
* built from the unmodified list would let a quick run replay into a full one
|
|
41
|
+
* under one identity. */
|
|
42
|
+
export function gateUnits(root: string, c: RepoConfig, tasks?: TaskDef[]): UnitDecl[] {
|
|
17
43
|
const out: UnitDecl[] = []
|
|
18
|
-
for (const t of allTasks(c, root).tasks) {
|
|
44
|
+
for (const t of tasks ?? allTasks(c, root).tasks) {
|
|
19
45
|
out.push({
|
|
20
46
|
id: t.id,
|
|
21
47
|
kind: 'task',
|
package/src/ledger/git-key.ts
CHANGED
|
@@ -28,14 +28,15 @@
|
|
|
28
28
|
// differ from main by two top-level packages. That is what makes a shared
|
|
29
29
|
// ledger safe rather than a stale green in a checkout nobody looked at.
|
|
30
30
|
import { createHash } from 'crypto'
|
|
31
|
+
import { join } from 'path'
|
|
31
32
|
import { loadConfig, type RvtConfig } from '../ts/config.ts'
|
|
32
33
|
import { importClosure } from '../ts/source-graph.ts'
|
|
33
|
-
import { GitScanError, hashFiles, normalizeInput, scanPaths, type ScanResult } from './git-scan.ts'
|
|
34
|
-
import { toolchainIdentity } from './toolchain-identity.ts'
|
|
34
|
+
import { GitScanError, hashFiles, normalizeInput, scanPaths, WHOLE_TREE, type ScanResult } from './git-scan.ts'
|
|
35
|
+
import { installIdentity, toolchainIdentity } from './toolchain-identity.ts'
|
|
35
36
|
|
|
36
37
|
/** the key format. Bumped when a component is added or its rendering changes,
|
|
37
38
|
* so an old ledger can never answer a new question. */
|
|
38
|
-
export const KEY_VERSION =
|
|
39
|
+
export const KEY_VERSION = 2
|
|
39
40
|
|
|
40
41
|
export type Population = 'content' | 'world' | 'none'
|
|
41
42
|
|
|
@@ -176,6 +177,16 @@ export class KeyLayer {
|
|
|
176
177
|
readonly openMs: number,
|
|
177
178
|
) {}
|
|
178
179
|
|
|
180
|
+
private readonly installIds = new Map<string, string>()
|
|
181
|
+
|
|
182
|
+
/** the identity of the program this run is driving: bun version, the gate
|
|
183
|
+
* binary's whole source, and every installed package. A WORLD unit needs
|
|
184
|
+
* it too, because a TTL'd answer measured by a different shipkit is a
|
|
185
|
+
* different answer, and the TTL alone cannot see that. */
|
|
186
|
+
get toolchainId(): string {
|
|
187
|
+
return this.toolchain.id
|
|
188
|
+
}
|
|
189
|
+
|
|
179
190
|
/** wall time the GIT half alone cost, ms — `openMs` is what a run pays */
|
|
180
191
|
get scanMs(): number {
|
|
181
192
|
return this.scan?.ms ?? 0
|
|
@@ -276,6 +287,10 @@ export class KeyLayer {
|
|
|
276
287
|
`v=${KEY_VERSION}`,
|
|
277
288
|
`unit=${d.kind}:${d.id}`,
|
|
278
289
|
`bun=${Bun.version}`,
|
|
290
|
+
// report-only mode changes what a self-maintaining rule DOES, and
|
|
291
|
+
// a TTL cannot see a mode. A frozen answer replayed into a normal
|
|
292
|
+
// run, or the reverse, is an answer to the other question.
|
|
293
|
+
`frozen=${process.env['SHIPKIT_FROZEN'] === '1'}`,
|
|
279
294
|
`toolchain=${this.toolchain.id}`,
|
|
280
295
|
`argv=${JSON.stringify(d.argv ?? [])}`,
|
|
281
296
|
...(d.env ?? []).slice().sort().map((n) => `env:${n}=${process.env[n] ?? '<unset>'}`),
|
|
@@ -284,17 +299,53 @@ export class KeyLayer {
|
|
|
284
299
|
...(d.extra != null ? [`extra=${d.extra}`] : []),
|
|
285
300
|
// ignored content nobody keyed on is part of the key's MEANING:
|
|
286
301
|
// two runs that excluded different things answered different
|
|
287
|
-
// questions, and a silently narrowed key is the false green
|
|
288
|
-
|
|
302
|
+
// questions, and a silently narrowed key is the false green.
|
|
303
|
+
//
|
|
304
|
+
// An excluded INSTALL carries its contents rather than only its
|
|
305
|
+
// name. `test:libs` declares `wiki/build`, every sub-package's
|
|
306
|
+
// `node_modules` sits inside it and is ignored, and the toolchain
|
|
307
|
+
// component describes the ROOT install only: without this, a lib
|
|
308
|
+
// whose own dependencies moved replayed a green measured against
|
|
309
|
+
// the previous ones, and no path in the key could say so.
|
|
310
|
+
...hidden.sort().map((p) => (p.split('/').at(-1) === 'node_modules' ? `excluded=${p}#${this.installOf(p)}` : `excluded=${p}`)),
|
|
289
311
|
]
|
|
290
312
|
const key = createHash('sha256').update(components.join('\n')).digest('hex').slice(0, 32)
|
|
291
313
|
return { ...base, key, components, inputOids }
|
|
292
314
|
}
|
|
293
315
|
|
|
316
|
+
/** an excluded install's identity, once per layer: several units can name
|
|
317
|
+
* the same one, and hashing a `node_modules` twice in a run is pure cost. */
|
|
318
|
+
private installOf(rel: string): string {
|
|
319
|
+
let id = this.installIds.get(rel)
|
|
320
|
+
if (id == null) { id = installIdentity(join(this.root, rel)); this.installIds.set(rel, id) }
|
|
321
|
+
return id
|
|
322
|
+
}
|
|
323
|
+
|
|
294
324
|
keys(): UnitKey[] {
|
|
295
325
|
return this.decls.map((d) => this.keyFor(d.id))
|
|
296
326
|
}
|
|
297
327
|
|
|
328
|
+
/** which units READ one of these paths. Answers the only question a
|
|
329
|
+
* post-run re-hash has: whose result now describes a tree that no longer
|
|
330
|
+
* exists. A unit declaring the whole work tree reads everything, so any
|
|
331
|
+
* drift at all touches it. */
|
|
332
|
+
unitsTouchedBy(drifted: string[]): Set<string> {
|
|
333
|
+
const hit = new Set<string>()
|
|
334
|
+
if (drifted.length === 0) return hit
|
|
335
|
+
for (const d of this.decls) {
|
|
336
|
+
// the unit's own source counts exactly like a declared input: an
|
|
337
|
+
// agent fixing a check WHILE it runs must not store the old
|
|
338
|
+
// verdict under the new code's key
|
|
339
|
+
if ((this.closureByUnit.get(d.id) ?? []).some((f) => drifted.includes(f))) { hit.add(d.id); continue }
|
|
340
|
+
for (const raw of d.inputs ?? []) {
|
|
341
|
+
let p: string
|
|
342
|
+
try { p = normalizeInput(raw) } catch { hit.add(d.id); break }
|
|
343
|
+
if (p === WHOLE_TREE || drifted.some((x) => x === p || x.startsWith(`${p}/`))) { hit.add(d.id); break }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return hit
|
|
347
|
+
}
|
|
348
|
+
|
|
298
349
|
/**
|
|
299
350
|
* POST RUN RE HASH. Re-scan exactly what this layer keyed on and report
|
|
300
351
|
* every path whose object id moved while the units were running.
|
package/src/ledger/git-scan.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// `git add` on one index file, 9 fail with `Unable to create …lock`. Three
|
|
30
30
|
// agents in one checkout is the normal case here, so lock contention is
|
|
31
31
|
// retried and only a NON lock failure is fatal.
|
|
32
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
32
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'fs'
|
|
33
33
|
import { isAbsolute, join, normalize } from 'path'
|
|
34
34
|
|
|
35
35
|
/** what a scan measured. Every OID is a git object id of the CURRENT disk
|
|
@@ -135,12 +135,25 @@ export function indexPaths(root: string): { scratch: string; ignored: string; re
|
|
|
135
135
|
if (/^scan-\d+\.idx(\.tmp)?$/.test(f) && statSync(join(dir, f)).mtimeMs < cutoff) rmSync(join(dir, f), { force: true })
|
|
136
136
|
} catch {}
|
|
137
137
|
// tmp + rename: a half-copied index is a corrupt index, and git's error
|
|
138
|
-
// for that is not the one the retry loop is meant to absorb
|
|
138
|
+
// for that is not the one the retry loop is meant to absorb.
|
|
139
|
+
//
|
|
140
|
+
// THE COPY KEEPS THE ORIGINAL'S MTIME, and that one line is the whole of a
|
|
141
|
+
// reproduced false green. Git decides an entry is "racily clean" by
|
|
142
|
+
// comparing the entry's cached mtime against the INDEX FILE's own mtime,
|
|
143
|
+
// and re-reads content whenever it cannot tell them apart. macOS git
|
|
144
|
+
// records mtimes at one-second granularity, so a file rewritten to the
|
|
145
|
+
// same SIZE inside the second it was staged is invisible to the stat cache
|
|
146
|
+
// and only that racy rule catches it. A fresh copy has mtime NOW, which
|
|
147
|
+
// makes every entry look comfortably older than the index and switches the
|
|
148
|
+
// rule off. Measured: 1 miss in 30 on a file edited between two scans, and
|
|
149
|
+
// a miss here is a replay of an answer about content nobody has any more.
|
|
139
150
|
try {
|
|
140
151
|
rmSync(scratch, { force: true })
|
|
141
152
|
if (existsSync(real)) {
|
|
153
|
+
const st = statSync(real)
|
|
142
154
|
const tmp = `${scratch}.tmp`
|
|
143
155
|
copyFileSync(real, tmp)
|
|
156
|
+
utimesSync(tmp, st.atime, st.mtime)
|
|
144
157
|
renameSync(tmp, scratch)
|
|
145
158
|
}
|
|
146
159
|
} catch {
|