rvlib-shipkit 1.0.0 → 1.1.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 +65 -0
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +30 -4
- package/src/assets/gen/derived.ts +1 -1
- package/src/check.ts +6 -0
- package/src/checks/SK004-gitignore-policy.check.ts +4 -4
- package/src/checks/SK005-rvlib-versions.check.ts +25 -7
- package/src/checks/SK021-github-login.check.ts +123 -0
- package/src/cli/cmd_Asks.ts +1 -1
- package/src/cli/cmd_Check.ts +1 -1
- package/src/cli/cmd_Ci.ts +1 -1
- package/src/cli/cmd_Fleet.ts +4 -47
- package/src/cli.ts +2 -3
- package/src/collections/checks.ts +12 -3
- package/src/collections/collection-types.ts +34 -18
- package/src/collections/comfy.ts +1 -0
- package/src/collections/posts.ts +1 -0
- package/src/collections/providers.ts +71 -43
- package/src/collections/reflections.ts +1 -0
- package/src/collections/state-rules.ts +29 -13
- package/src/collections/tests.ts +1 -0
- package/src/collections/wikis.ts +1 -0
- package/src/core/budget.ts +60 -0
- package/src/core/check-ctx.ts +2 -1
- package/src/core/check-runner.ts +43 -6
- package/src/core/fd-table.ts +37 -0
- package/src/core/floor-resolve.ts +2 -2
- package/src/core/git-root.ts +26 -2
- package/src/core/lib-version.ts +16 -0
- package/src/core/migrate-layout.ts +69 -9
- package/src/core/motherhome.ts +2 -4
- package/src/core/package-manager.ts +20 -16
- package/src/core/raw-access.ts +11 -3
- package/src/core/reexec.ts +26 -0
- package/src/core/run-check.ts +36 -16
- package/src/core/run-ci.ts +31 -27
- package/src/core/run-fix.ts +4 -1
- package/src/core/run-fixers.ts +19 -11
- package/src/core/self-update.ts +63 -26
- package/src/core/update-policy.ts +25 -0
- package/src/fleet/scan-fleet.ts +20 -15
- package/src/fleet/update-fleet.ts +85 -0
- package/src/hooks/leak-guard.ts +10 -1
- package/src/hooks/run-hooks.ts +3 -3
- package/src/ledger/gate-checks.ts +19 -5
- package/src/ledger/git-key.ts +10 -5
- package/src/ledger/git-scan.ts +29 -17
- package/src/ledger/ledger.ts +14 -6
- package/src/ledger/repo-view.ts +8 -6
- package/src/ledger/run-log.ts +10 -4
- package/src/ledger/run-timeline.ts +21 -9
- package/src/problems/problem-board.ts +13 -2
- package/src/problems/problem-line.ts +9 -0
- package/src/repo-config.ts +24 -4
- package/src/serve/hub-app/check-view.tsx +12 -11
- package/src/serve/hub-app/cols-svc.ts +16 -9
- package/src/serve/hub-app/dur.ts +10 -1
- package/src/serve/hub-app/header-bar.tsx +11 -0
- package/src/serve/hub-app/help.tsx +1 -0
- package/src/serve/hub-app/hub-st.ts +3 -4
- package/src/serve/hub-app/hub-types.ts +5 -0
- package/src/serve/hub-app/hub.css +3 -0
- package/src/serve/hub-app/timeline-view.tsx +4 -4
- package/src/serve/hub-app/tree-build.ts +1 -2
- package/src/serve/hub-problems.ts +35 -0
- package/src/serve/run-serve.ts +51 -12
- package/src/serve/serve-hub.ts +10 -3
- package/src/serve/stale-restart.ts +77 -0
- package/src/smoke/run-smoke.ts +6 -2
- package/src/suite/suite-cli.ts +14 -10
- package/src/ts/source-graph.ts +3 -2
- package/src/wiki/scan-md.ts +1 -1
|
@@ -99,7 +99,7 @@ const SUITE_CLI = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/suite/sui
|
|
|
99
99
|
* bunfig, its preload and its `units[].tests` budget apply, after the checks
|
|
100
100
|
* its `deps` name. Its inputs are its dir and the dirs of the
|
|
101
101
|
* sibling units its package.json names, derived rather than declared */
|
|
102
|
-
function unitTestTasks(
|
|
102
|
+
function unitTestTasks(root: string, io: FloorReader, subs: TestUnitDecl[], siblings: Map<string, string[]>, notes: string[], unfilled: { id: string; reason: string }[]): TaskDef[] {
|
|
103
103
|
const read = io.text
|
|
104
104
|
const out: TaskDef[] = []
|
|
105
105
|
for (const u of subs) {
|
|
@@ -258,7 +258,7 @@ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): Floo
|
|
|
258
258
|
else { tasks.push(decorate(b.task)); sources[id] = b.source }
|
|
259
259
|
}
|
|
260
260
|
const siblings = siblingUnits(subs, io.text)
|
|
261
|
-
for (const t of unitTestTasks(
|
|
261
|
+
for (const t of unitTestTasks(root, io, subs, siblings, notes, unfilled)) { tasks.push(t); sources[t.id] = 'unit' }
|
|
262
262
|
for (const t of unitTypecheckTasks(root, io, subs, siblings, unfilled)) { tasks.push(t); sources[t.id] = 'unit' }
|
|
263
263
|
return { tasks, disabled, unfilled, sources, notes }
|
|
264
264
|
}
|
package/src/core/git-root.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// root and its prefix inside it. One rev-parse answers all four, and the answer
|
|
3
3
|
// is remembered per directory, because one gate asks it from a dozen places
|
|
4
4
|
// (store, run record, scan, nesting, three rules) and each ask is a process
|
|
5
|
-
// spawn. A directory with no `.git` in reach is answered without asking
|
|
5
|
+
// spawn. A directory with no `.git` in reach is answered without asking, and
|
|
6
|
+
// so is a plain repository root (plainRoot). Every remembered answer is re-validated with a few stats on each hit,
|
|
6
7
|
// so a `git init` or a deleted repo is seen at once in a long-lived process.
|
|
7
|
-
import { existsSync, realpathSync, statSync } from 'fs'
|
|
8
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'fs'
|
|
8
9
|
import { dirname, join, resolve } from 'path'
|
|
9
10
|
import { gitBin } from './git-bin.ts'
|
|
10
11
|
|
|
@@ -78,8 +79,31 @@ function nothingInReach(dir: string): boolean {
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
/** the common case answered from the disk: `.git` is a plain directory right
|
|
83
|
+
* at `dir`, which git describes as exactly that. Anything unusual (a locating
|
|
84
|
+
* variable, a gitfile or a symlinked `.git`, a shared common dir, a config
|
|
85
|
+
* that relocates the work tree, marks it bare or includes another file, a
|
|
86
|
+
* `.git` owned by someone else) is left to git */
|
|
87
|
+
function plainRoot(dir: string): GitRoot | null {
|
|
88
|
+
if (LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')) return null
|
|
89
|
+
try {
|
|
90
|
+
const top = realpathSync(dir)
|
|
91
|
+
const gitDir = join(top, '.git')
|
|
92
|
+
const st = lstatSync(gitDir)
|
|
93
|
+
if (!st.isDirectory() || (process.getuid != null && st.uid !== process.getuid())) return null
|
|
94
|
+
if (!existsSync(join(gitDir, 'HEAD')) || existsSync(join(gitDir, 'commondir'))) return null
|
|
95
|
+
const cfg = readFileSync(join(gitDir, 'config'), 'utf8')
|
|
96
|
+
if (/^\s*(worktree\s*=|bare\s*=\s*true|\[include)/im.test(cfg)) return null
|
|
97
|
+
return { gitDir, commonDir: gitDir, top, prefix: '' }
|
|
98
|
+
} catch {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
81
103
|
function ask(dir: string): Probe {
|
|
82
104
|
if (nothingInReach(dir)) return { root: null, error: 'fatal: not a git repository (or any of the parent directories): .git' }
|
|
105
|
+
const plain = plainRoot(dir)
|
|
106
|
+
if (plain != null) return { root: plain, error: '' }
|
|
83
107
|
let p: ReturnType<typeof Bun.spawnSync>
|
|
84
108
|
try {
|
|
85
109
|
p = Bun.spawnSync([gitBin(), 'rev-parse', '--path-format=absolute', '--absolute-git-dir', '--git-common-dir', '--show-toplevel', '--show-prefix'], {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// the version of this lib: what the running process booted with, and what
|
|
2
|
+
// the package.json on disk says now. The boot version is a MODULE import on
|
|
3
|
+
// purpose: `bun --watch` follows the module graph only, so a version bump
|
|
4
|
+
// (which touches nothing but package.json) restarts a watched hub. Read with
|
|
5
|
+
// fs, the file sits outside the graph and a watched hub never learns of it.
|
|
6
|
+
import { readFileSync } from 'fs'
|
|
7
|
+
import pkg from '../../package.json' with { type: 'json' }
|
|
8
|
+
|
|
9
|
+
/** the version this process booted with */
|
|
10
|
+
export const LIB_VERSION: string = pkg.version
|
|
11
|
+
|
|
12
|
+
/** what package.json says at this moment, `?` when it is unreadable. Only a
|
|
13
|
+
* process NOT under a watch can see it differ from LIB_VERSION */
|
|
14
|
+
export const diskLibVersion = (): string => {
|
|
15
|
+
try { return (JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
|
|
16
|
+
}
|
|
@@ -34,7 +34,20 @@ import { nestedIn } from './nesting.ts'
|
|
|
34
34
|
/** the prefix every contract folder carried before `.shipkit/` */
|
|
35
35
|
export const LEGACY_CONTRACT_PREFIX = '.rv-'
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const CONFIG_FILE = 'repo.config.ts'
|
|
38
|
+
|
|
39
|
+
/** what one pass did, in words: the folders it moved and the root files it
|
|
40
|
+
* dropped. Empty when it did neither, so no caller prints a sentence with a
|
|
41
|
+
* hole where the list should be */
|
|
42
|
+
export function layoutSummary(r: { moved: LayoutMove[]; removed: string[] }): string {
|
|
43
|
+
const list = (xs: string[]): string => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`)
|
|
44
|
+
const folders = r.moved.filter((m) => m.kind !== 'cache').map((m) => `${m.from}/`)
|
|
45
|
+
const parts = [
|
|
46
|
+
...(folders.length > 0 ? [`move ${list(folders)} under ${SHIPKIT_DIR}/`] : []),
|
|
47
|
+
...(r.removed.length > 0 ? [`drop the root ${list(r.removed)}, which shipkit writes under ${SHIPKIT_DIR}/`] : []),
|
|
48
|
+
]
|
|
49
|
+
return parts.join(', and ')
|
|
50
|
+
}
|
|
38
51
|
|
|
39
52
|
/** the contract names a typo is matched against */
|
|
40
53
|
const KNOWN = ['identity', 'assets', 'comfy', 'private', 'reflections', 'social', 'journal', 'domain', 'itch', 'steam', 'discord'] as const
|
|
@@ -200,6 +213,39 @@ export function rebaseRelative(text: string, at: { from: string; to: string }, m
|
|
|
200
213
|
.replace(/(\]\()(\.\.?\/[^)\s#]+)/g, (_m, lead: string, spec: string) => `${lead}${rebase(spec)}`)
|
|
201
214
|
}
|
|
202
215
|
|
|
216
|
+
/** one repo.config.ts value that names a moved folder, as a path: the folder
|
|
217
|
+
* then a slash, behind an optional `!`, `/` or `./`. A word, a bare folder
|
|
218
|
+
* name and a longer name that merely starts the same stay as they are */
|
|
219
|
+
function rebasePathValue(v: string, moves: LayoutMove[]): string {
|
|
220
|
+
const m = /^(!?(?:\/|\.\/)?)(.*)$/s.exec(v)!
|
|
221
|
+
const rest = m[2]!
|
|
222
|
+
const hit = moves.find((x) => rest.startsWith(`${x.from}/`))
|
|
223
|
+
return hit == null ? v : `${m[1]}${hit.to}${rest.slice(hit.from.length)}`
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** PURE: repo.config.ts source with every quoted path that names a moved
|
|
227
|
+
* folder rewritten (`leakAllow` paths, `blockedPaths`, `gitignore` lines, a
|
|
228
|
+
* prose include, any other value): each of them is a repo path by contract */
|
|
229
|
+
export function sweepConfigSource(text: string, moves: LayoutMove[]): string {
|
|
230
|
+
if (moves.length === 0) return text
|
|
231
|
+
return text.replace(/(['"`])((?:\\.|(?!\1)[^\\\n])*)\1/g, (lit, q: string, body: string) => {
|
|
232
|
+
if (q === '`' && body.includes('${')) return lit
|
|
233
|
+
const next = rebasePathValue(body, moves)
|
|
234
|
+
return next === body ? lit : `${q}${next}${q}`
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** PURE: the loaded config with the same rewrite, so what this pass renders
|
|
239
|
+
* from it (the ignore block) reads the values the file now holds. Only plain
|
|
240
|
+
* objects and arrays are copied; functions and class instances are kept */
|
|
241
|
+
export function rebaseConfigPaths<T>(v: T, moves: LayoutMove[]): T {
|
|
242
|
+
if (typeof v === 'string') return rebasePathValue(v, moves) as T
|
|
243
|
+
if (Array.isArray(v)) return v.map((x) => rebaseConfigPaths(x, moves)) as T
|
|
244
|
+
if (v != null && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype)
|
|
245
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, rebaseConfigPaths(x, moves)])) as T
|
|
246
|
+
return v
|
|
247
|
+
}
|
|
248
|
+
|
|
203
249
|
/** PURE: the .gitignore after the move. Old names turn into new ones, except
|
|
204
250
|
* the folders still on disk (`keep`), whose lines keep guarding them. A
|
|
205
251
|
* negation of one FILE inside a folder (`!.shipkit/social/README.md`) is dead
|
|
@@ -327,9 +373,10 @@ export function legacyHooksOurs(root: string): boolean {
|
|
|
327
373
|
* still has core.hooksPath on the vanished `hooks/`, and git runs no hook
|
|
328
374
|
* from a folder that is not there: the commit gates go dark in silence */
|
|
329
375
|
function repointStaleHooks(root: string): void {
|
|
376
|
+
// the disk decides first: git is asked only when the move has happened
|
|
377
|
+
if (existsSync(join(root, 'hooks')) || !existsSync(join(root, LAYOUT.hooks, 'pre-commit'))) return
|
|
330
378
|
const v = git(root, ['config', 'core.hooksPath']).out.trim().replace(/\/+$/, '')
|
|
331
379
|
if (v !== 'hooks' && v !== join(root, 'hooks')) return
|
|
332
|
-
if (existsSync(join(root, 'hooks')) || !existsSync(join(root, LAYOUT.hooks, 'pre-commit'))) return
|
|
333
380
|
git(root, ['config', 'core.hooksPath', LAYOUT.hooks])
|
|
334
381
|
}
|
|
335
382
|
|
|
@@ -400,8 +447,10 @@ function inProgress(root: string): string | null {
|
|
|
400
447
|
export type MigrateOpts = {
|
|
401
448
|
config?: RepoConfig
|
|
402
449
|
/** re-render the SK004 block into this .gitignore text (SK004 owns the
|
|
403
|
-
* block). `legacy`: an old folder is still on disk, so its name stays
|
|
404
|
-
|
|
450
|
+
* block). `legacy`: an old folder is still on disk, so its name stays
|
|
451
|
+
* ignored. `config`: the repo config with its paths already moved, which
|
|
452
|
+
* is what the block must be rendered from */
|
|
453
|
+
renderGitignore?: (text: string, legacy: boolean, config?: RepoConfig) => string
|
|
405
454
|
}
|
|
406
455
|
|
|
407
456
|
/** tracked symlinks, with where each points */
|
|
@@ -442,8 +491,13 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
|
|
|
442
491
|
const targets = Object.fromEntries(plan.moves.filter((m) => m.kind === 'contract').map((m) => [m.from, m.to]))
|
|
443
492
|
const sources = plan.moves.filter((m) => m.kind !== 'cache').map((m) => m.from)
|
|
444
493
|
const tracked = inGit ? lsFiles(root, [...sources, ...legacyStatus]) : []
|
|
445
|
-
// a moved file is rebased whether or not it names an old folder
|
|
446
|
-
|
|
494
|
+
// a moved file is rebased whether or not it names an old folder, and
|
|
495
|
+
// repo.config.ts whenever one of its values names one
|
|
496
|
+
const configNames = inGit && lsFiles(root, [CONFIG_FILE]).length > 0 && sweepConfigSource(readFileSync(join(root, CONFIG_FILE), 'utf8'), plan.moves) !== readFileSync(join(root, CONFIG_FILE), 'utf8') ? [CONFIG_FILE] : []
|
|
497
|
+
// a legacy generated file is dropped, never rewritten: a rewrite would
|
|
498
|
+
// write it back after the drop, and the commit would keep it
|
|
499
|
+
const dropped = new Set<string>(legacyStatus)
|
|
500
|
+
const swept = inGit ? [...new Set([...sweepTargets(root, contractNames), ...tracked.filter((t) => /\.(m?[jt]sx?|json|md)$/.test(t)), ...configNames])].filter((f) => !dropped.has(f)) : []
|
|
447
501
|
const links = inGit ? trackedLinks(root) : []
|
|
448
502
|
// commit the ignore file when git tracks it, or when this pass creates it:
|
|
449
503
|
// it is what keeps the moved private folders out. An existing untracked
|
|
@@ -464,7 +518,8 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
|
|
|
464
518
|
const text = readFileSync(join(root, rel), 'utf8')
|
|
465
519
|
if (text.includes(SWEEP_SKIP)) continue
|
|
466
520
|
const now = movedTo(moves, rel)
|
|
467
|
-
const
|
|
521
|
+
const paths = rel === CONFIG_FILE ? sweepConfigSource(text, moves) : text
|
|
522
|
+
const next = sweepText(rebaseRelative(paths, { from: rel, to: now }, moves), targets, { from: rel, to: now })
|
|
468
523
|
if (next !== text) writes.push({ now, text: next })
|
|
469
524
|
}
|
|
470
525
|
const relinks: { now: string; target: string }[] = []
|
|
@@ -486,7 +541,8 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
|
|
|
486
541
|
|
|
487
542
|
const gi = join(root, '.gitignore')
|
|
488
543
|
const giOriginal = existsSync(gi) ? readFileSync(gi, 'utf8') : ''
|
|
489
|
-
const
|
|
544
|
+
const config = o.config == null ? undefined : rebaseConfigPaths(o.config, moves)
|
|
545
|
+
const render = (text: string, legacy: boolean): string => (o.renderGitignore != null ? o.renderGitignore(text, legacy, config) : text)
|
|
490
546
|
const stillOld = (): string[] => dirsIn(root).filter((d) => d.startsWith(LEGACY_CONTRACT_PREFIX))
|
|
491
547
|
try {
|
|
492
548
|
// 1. both layouts ignored while any folder is in flight
|
|
@@ -550,7 +606,11 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
|
|
|
550
606
|
const giChanged = giNew || (giTracked && readFileSync(join(root, '.gitignore'), 'utf8') !== giOriginal)
|
|
551
607
|
const touched = [...new Set([...tracked, ...added, ...sweptTracked, ...(giChanged ? ['.gitignore'] : [])])]
|
|
552
608
|
if (touched.length === 0) return out
|
|
553
|
-
|
|
609
|
+
// the title names what this commit carries; a pass that did neither has
|
|
610
|
+
// nothing tracked to land and returned above
|
|
611
|
+
const what = layoutSummary(out)
|
|
612
|
+
if (what === '') return out
|
|
613
|
+
const landed = machineCommit({ anchor: root, message: `chore(machine): ${what}`, paths: touched.map((p) => join(root, p)) })
|
|
554
614
|
if (!landed) return { ...out, uncommitted: true, pending: touched }
|
|
555
615
|
out.commit = git(root, ['rev-parse', '--short', 'HEAD']).out.trim()
|
|
556
616
|
return out
|
package/src/core/motherhome.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { RepoConfig } from '../repo-config.ts'
|
|
|
17
17
|
import { atomicWrite, withFileLock } from './locked-write.ts'
|
|
18
18
|
import { isLinkedWorktree } from './git-root.ts'
|
|
19
19
|
import { machineCommit } from './machine-commit.ts'
|
|
20
|
+
import { LIB_VERSION } from './lib-version.ts'
|
|
20
21
|
|
|
21
22
|
const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
|
|
22
23
|
const END = '<!-- <<< shipkit local-projects -->'
|
|
@@ -53,9 +54,6 @@ const parseRow = (line: string): Row | null => {
|
|
|
53
54
|
return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function libVersion(): string {
|
|
57
|
-
try { return (JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
|
|
58
|
-
}
|
|
59
57
|
|
|
60
58
|
// how this repo consumes the lib — `?` on an unreadable package.json (a
|
|
61
59
|
// read failure must never be rendered as the fact 'path')
|
|
@@ -91,7 +89,7 @@ export function reportToMotherhome(root: string, config: RepoConfig, today: stri
|
|
|
91
89
|
const mode = consumptionMode(root)
|
|
92
90
|
// source-consumed code is not the published release its version
|
|
93
91
|
// string names — disclose it
|
|
94
|
-
const version = mode === 'npm' ?
|
|
92
|
+
const version = mode === 'npm' ? LIB_VERSION : `${LIB_VERSION}+src`
|
|
95
93
|
const me: Row = { name: config.name, root: resolve(root), version, mode, seen: today }
|
|
96
94
|
|
|
97
95
|
const r = withFileLock(page, (): MotherhomeOutcome => {
|
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
// TRACKED locks decide, because an untracked bun.lock is exactly what a wrong
|
|
8
8
|
// update leaves behind, and reading it as the repo's choice would make that
|
|
9
9
|
// first mistake permanent. Nothing tracked (a fresh tree, no git) falls back
|
|
10
|
-
// to what sits on disk.
|
|
11
|
-
//
|
|
12
|
-
// house default.
|
|
10
|
+
// to what sits on disk. No lock at all, or locks of two managers, is a
|
|
11
|
+
// REFUSAL that names what it saw: a guessed manager is the failure above.
|
|
13
12
|
import { existsSync } from 'fs'
|
|
14
13
|
import { join } from 'path'
|
|
15
14
|
import { gitBin } from './git-bin.ts'
|
|
@@ -24,10 +23,11 @@ export type PackageManager = {
|
|
|
24
23
|
install: string[]
|
|
25
24
|
/** move these deps to their latest version, manifest AND lock in one call */
|
|
26
25
|
update: (names: string[]) => string[]
|
|
27
|
-
/** set when several managers own a lock here, so the pick is a guess */
|
|
28
|
-
warn?: string
|
|
29
26
|
}
|
|
30
27
|
|
|
28
|
+
/** the manager, or why the lock files do not name exactly one */
|
|
29
|
+
export type PackageManagerPick = PackageManager | { refused: string }
|
|
30
|
+
|
|
31
31
|
const BUN_LOCKS = ['bun.lock', 'bun.lockb']
|
|
32
32
|
export const LOCK_FILES = [...BUN_LOCKS, 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json']
|
|
33
33
|
|
|
@@ -70,17 +70,21 @@ export function trackedLocks(root: string): string[] {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/** the manager this repo installs with, read off its lock files */
|
|
73
|
-
export function detectPackageManager(root: string, tracked
|
|
73
|
+
export function detectPackageManager(root: string, tracked?: string[]): PackageManagerPick {
|
|
74
74
|
const present = LOCK_FILES.filter((f) => existsSync(join(root, f)))
|
|
75
|
-
|
|
75
|
+
// git is asked which locks it tracks only when there is a lock to ask about
|
|
76
|
+
const known = present.length === 0 ? [] : (tracked ?? trackedLocks(root))
|
|
77
|
+
const owned = present.filter((f) => known.includes(f))
|
|
76
78
|
const locks = owned.length > 0 ? owned : present
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
return {
|
|
79
|
+
const managers = [
|
|
80
|
+
...(locks.some((f) => BUN_LOCKS.includes(f)) ? [bun] : []),
|
|
81
|
+
...(locks.includes('pnpm-lock.yaml') ? [pnpm] : []),
|
|
82
|
+
...(locks.includes('yarn.lock') ? [yarn(existsSync(join(root, '.yarnrc.yml')))] : []),
|
|
83
|
+
...(locks.includes('package-lock.json') ? [npm] : []),
|
|
84
|
+
]
|
|
85
|
+
if (managers.length === 1) return managers[0]!
|
|
86
|
+
if (managers.length === 0) return { refused: `no lock file at the root (looked for ${LOCK_FILES.join(', ')}), so which package manager installs this repo is unknown: install once with the right one and commit its lock` }
|
|
87
|
+
return { refused: `lock files of ${managers.length} package managers ${owned.length > 0 ? 'are tracked' : 'sit at the root'} (${locks.join(', ')}), so which one installs this repo is unknown: delete the lock of the one it does not install with` }
|
|
86
88
|
}
|
|
89
|
+
|
|
90
|
+
export const isRefusal = (p: PackageManagerPick): p is { refused: string } => 'refused' in p
|
package/src/core/raw-access.ts
CHANGED
|
@@ -72,17 +72,25 @@ function scanSource(src: string, o: ClosureKind): { line: number; what: string }
|
|
|
72
72
|
* scanned, and neither is `skip`, the toolkit's own tree when it sits inside
|
|
73
73
|
* the repo: the toolchain id covers it exactly as it covers an install.
|
|
74
74
|
* Throws when the closure cannot be walked, which the caller counts as a hit */
|
|
75
|
-
export function rawAccessInClosure(root: string, file: string, o: { skip?: string; configs?: Map<string, RvtConfig> } & ClosureKind = {}): RawAccess | null {
|
|
75
|
+
export function rawAccessInClosure(root: string, file: string, o: { skip?: string; configs?: Map<string, RvtConfig>; texts?: Map<string, string> } & ClosureKind = {}): RawAccess | null {
|
|
76
76
|
// one pass over many checks under one root hands in `configs`, so the
|
|
77
77
|
// root's config (a walk of its source tree) is read once for all of them
|
|
78
78
|
let loaded = o.configs?.get(root)
|
|
79
79
|
if (loaded == null) { loaded = loadConfig(root); o.configs?.set(root, loaded) }
|
|
80
80
|
const cfg = { ...loaded, root }
|
|
81
81
|
const skipped = (f: string): boolean => f.split('/').includes('node_modules') || (o.skip != null && join(root, f).startsWith(`${o.skip}/`))
|
|
82
|
-
|
|
82
|
+
// one pass over many checks hands in `texts` too: their closures share
|
|
83
|
+
// most of their files, and each is read once for the whole pass
|
|
84
|
+
const read = (rel: string): string => {
|
|
85
|
+
const p = join(root, rel)
|
|
86
|
+
let text = o.texts?.get(p)
|
|
87
|
+
if (text == null) { text = readFileSync(p, 'utf8'); o.texts?.set(p, text) }
|
|
88
|
+
return text
|
|
89
|
+
}
|
|
90
|
+
const files = importClosure(cfg, [relative(root, file).replace(/\\/g, '/')], { ...o, read }).filter((f) => !skipped(f))
|
|
83
91
|
for (const f of files) {
|
|
84
92
|
let src: string
|
|
85
|
-
try { src =
|
|
93
|
+
try { src = read(f) } catch { continue }
|
|
86
94
|
const hit = rawAccessInSource(src, o)
|
|
87
95
|
if (hit != null) return { file: f, ...hit }
|
|
88
96
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// run this exact invocation again, on whatever code is on disk now. execve
|
|
2
|
+
// replaces the image in place: same pid, same terminal, same argv (so the
|
|
3
|
+
// same flags and the same port), and a `--watch` flag stays in execArgv. A
|
|
4
|
+
// runtime without execve runs the invocation as a child and exits with its
|
|
5
|
+
// code, which keeps the terminal but leaves this process waiting under it.
|
|
6
|
+
import { existsSync } from 'fs'
|
|
7
|
+
|
|
8
|
+
const argvOf = (): string[] => [process.execPath, ...process.execArgv, ...process.argv.slice(1)]
|
|
9
|
+
|
|
10
|
+
/** why this invocation cannot run again, null when it can. Asked BEFORE a
|
|
11
|
+
* caller tears anything down, so a refusal leaves the process serving */
|
|
12
|
+
export function reexecBlocker(): string | null {
|
|
13
|
+
const entry = process.argv[1]
|
|
14
|
+
if (entry != null && entry !== '' && !entry.startsWith('-') && /\.[cm]?[jt]sx?$/.test(entry) && !existsSync(entry)) return `its entry file ${entry} is gone`
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** never returns. `env` defaults to this process's own */
|
|
19
|
+
export function reexecSelf(env: Record<string, string | undefined> = process.env): never {
|
|
20
|
+
const argv = argvOf()
|
|
21
|
+
const clean = Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] != null))
|
|
22
|
+
const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
|
|
23
|
+
if (typeof execve === 'function') execve.call(process, process.execPath, argv, clean)
|
|
24
|
+
const child = Bun.spawnSync(argv, { cwd: process.cwd(), stdout: 'inherit', stderr: 'inherit', stdin: 'inherit', env: clean })
|
|
25
|
+
process.exit(child.exitCode ?? 1)
|
|
26
|
+
}
|
package/src/core/run-check.ts
CHANGED
|
@@ -14,10 +14,11 @@ import { Ledger } from '../ledger/ledger.ts'
|
|
|
14
14
|
import { RunLog, type InvokedBy } from '../ledger/run-log.ts'
|
|
15
15
|
import { storeRoot } from '../ledger/store.ts'
|
|
16
16
|
import { gateBinary } from '../ledger/toolchain-identity.ts'
|
|
17
|
-
import { runChecks, type Audience, type CheckEvent, type CheckResult } from './check-runner.ts'
|
|
17
|
+
import { runChecks, selectChecks, type Audience, type CheckEvent, type CheckResult, type RunnableCheck } from './check-runner.ts'
|
|
18
18
|
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
19
19
|
import { reportToMotherhome } from './motherhome.ts'
|
|
20
20
|
import { unknownWhenIds } from './when.ts'
|
|
21
|
+
import { unknownBudgetIds } from './budget.ts'
|
|
21
22
|
import { formatLine, type Line } from './wire.ts'
|
|
22
23
|
import { writeStatus } from './write-status.ts'
|
|
23
24
|
import { CHECK_TABLE_FILE, writeCheckTable } from '../ledger/check-table.ts'
|
|
@@ -106,12 +107,37 @@ export type CheckRun = {
|
|
|
106
107
|
/** `when` entries naming no check of this repo — a toggle that does
|
|
107
108
|
* nothing is worse than none, so it is fatal too */
|
|
108
109
|
unknownWhen: string[]
|
|
110
|
+
/** `budgets` entries naming no check of this repo, fatal for the same reason */
|
|
111
|
+
unknownBudgets: string[]
|
|
109
112
|
/** what every asked-for check did, in declaration order */
|
|
110
113
|
results: CheckResult[]
|
|
111
114
|
/** the run was interrupted or bailed out */
|
|
112
115
|
interrupted: boolean
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
/** what the runner selects from, built from the pass options in ONE place: the
|
|
119
|
+
* ledger is scoped on the same selection the runner then makes */
|
|
120
|
+
function selectionOf(checks: RunnableCheck[], o: CheckOpts, tier: TestTier) {
|
|
121
|
+
return {
|
|
122
|
+
checks,
|
|
123
|
+
audience: o.audience ?? 'health',
|
|
124
|
+
fullTier: tier === 'full',
|
|
125
|
+
skipGroups: o.skipGroups,
|
|
126
|
+
onlyIds: o.only,
|
|
127
|
+
withIds: o.withIds,
|
|
128
|
+
onlyBlocking: o.onlyBlocking,
|
|
129
|
+
buildOnly: o.buildOnly,
|
|
130
|
+
includeProactive: o.includeProactive,
|
|
131
|
+
exclude: o.exclude,
|
|
132
|
+
} satisfies Parameters<typeof selectChecks>[0]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** the ids a pass with these options executes: the scope its ledger scans for */
|
|
136
|
+
export function passScope(composed: GateCheck[], o: CheckOpts): Set<string> {
|
|
137
|
+
const tier: TestTier = o.tier ?? envTier()
|
|
138
|
+
return new Set(selectChecks(selectionOf(composed.map((g) => g.runnable), o, tier)).selected.map((u) => u.id))
|
|
139
|
+
}
|
|
140
|
+
|
|
115
141
|
/** THE run body: compose this repo's checks, run the set the audience asks
|
|
116
142
|
* for, and classify the outcome by what each check declared. `shipkit check`
|
|
117
143
|
* and `shipkit ci` both come through here, and nothing else decides
|
|
@@ -135,7 +161,7 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
|
|
|
135
161
|
let ledger: Ledger | null = o.ledger ?? null
|
|
136
162
|
if (ledger === null && o.ledger === undefined) {
|
|
137
163
|
try {
|
|
138
|
-
ledger = Ledger.open(root, composed.map((g) => g.row.decl), { cliEntry: o.cliEntry ?? gateBinary(), fresh: o.fresh })
|
|
164
|
+
ledger = Ledger.open(root, composed.map((g) => g.row.decl), { cliEntry: o.cliEntry ?? gateBinary(), fresh: o.fresh, scope: passScope(composed, o) })
|
|
139
165
|
} catch { ledger = null }
|
|
140
166
|
}
|
|
141
167
|
// the caller's log, or this run's own: `shipkit check` measures checks too,
|
|
@@ -157,24 +183,15 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
|
|
|
157
183
|
const collected: Line[] = []
|
|
158
184
|
const sink = (l: string): void => print(l)
|
|
159
185
|
const summary = await runChecks({
|
|
160
|
-
checks,
|
|
186
|
+
...selectionOf(checks, o, tier),
|
|
161
187
|
root,
|
|
162
188
|
config: c,
|
|
163
|
-
audience,
|
|
164
|
-
fullTier: tier === 'full',
|
|
165
189
|
logDir: c.logDir ?? join(root, CI_LOG_DIR),
|
|
166
190
|
cache: { storeDir: storeRoot(root), ns: c.name, now: Date.now },
|
|
167
191
|
ledger,
|
|
168
192
|
log,
|
|
169
193
|
childEnv: { [SHARE_ENV]: sharePath, ...(o.fresh === true ? { SHIPKIT_FRESH: '1' } : {}) },
|
|
170
194
|
bail: o.bail,
|
|
171
|
-
skipGroups: o.skipGroups,
|
|
172
|
-
onlyIds: o.only,
|
|
173
|
-
withIds: o.withIds,
|
|
174
|
-
onlyBlocking: o.onlyBlocking,
|
|
175
|
-
buildOnly: o.buildOnly,
|
|
176
|
-
includeProactive: o.includeProactive,
|
|
177
|
-
exclude: o.exclude,
|
|
178
195
|
print: sink,
|
|
179
196
|
onCheck: (e) => { collected.push(...e.lines); o.onCheck?.(e) },
|
|
180
197
|
})
|
|
@@ -205,13 +222,16 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
|
|
|
205
222
|
if (o.runLog === undefined && log != null) for (const l of log.flush()) say(l)
|
|
206
223
|
// a `when` id naming nothing is a switch wired to no check: the id is read
|
|
207
224
|
// against the WHOLE inventory this run composed, never its narrowed list
|
|
208
|
-
const
|
|
225
|
+
const rows = composed.map((g) => g.row)
|
|
226
|
+
const unknownWhen = unknownWhenIds(rows, c)
|
|
227
|
+
const unknownBudgets = unknownBudgetIds(rows.map((r) => r.decl.id), c.budgets)
|
|
209
228
|
return {
|
|
210
229
|
lines: [...collected, ...lines],
|
|
211
230
|
blocked: !summary.ok,
|
|
212
231
|
advisoryFailures: summary.advisoryFailures,
|
|
213
232
|
undeclared: summary.undeclared,
|
|
214
233
|
unknownWhen,
|
|
234
|
+
unknownBudgets,
|
|
215
235
|
results: summary.results,
|
|
216
236
|
interrupted: summary.interrupted,
|
|
217
237
|
}
|
|
@@ -222,14 +242,14 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
|
|
|
222
242
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
223
243
|
const c = o.config ?? (await loadRepoConfig(root))
|
|
224
244
|
const run = await runCheckPass(root, { ...o, config: c })
|
|
225
|
-
const code = run.blocked || run.undeclared.length > 0 || run.unknownWhen.length > 0 ? 1 : 0
|
|
245
|
+
const code = run.blocked || run.undeclared.length > 0 || run.unknownWhen.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
|
|
226
246
|
if (o.json === true) { print(JSON.stringify(run)); return code }
|
|
227
247
|
for (const u of run.undeclared) {
|
|
228
248
|
print(`FAIL: ${u} does not declare whether it blocks`)
|
|
229
249
|
print(` fix: add \`blocking: true\` (about this repo) or \`false\` (about the world) to that check`)
|
|
230
250
|
}
|
|
231
|
-
for (const
|
|
232
|
-
print(`FAIL:
|
|
251
|
+
for (const key of [...run.unknownWhen.map((id) => `when.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
|
|
252
|
+
print(`FAIL: ${key} names no check of this repo`)
|
|
233
253
|
print(` fix: \`shipkit checks\` lists every id; drop the entry or correct it`)
|
|
234
254
|
}
|
|
235
255
|
if (o.status !== false) {
|
package/src/core/run-ci.ts
CHANGED
|
@@ -6,15 +6,14 @@
|
|
|
6
6
|
// (write-status.ts — atomic + lock) unless status:false, and the fix pass plus
|
|
7
7
|
// some checks WRITE, so a run is not read-only unless frozen is set and
|
|
8
8
|
// --no-fix is passed.
|
|
9
|
-
import { join } from 'path'
|
|
10
9
|
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
11
10
|
import { reportToMotherhome } from './motherhome.ts'
|
|
12
|
-
import { runCheckPass, type CheckOpts } from './run-check.ts'
|
|
11
|
+
import { passScope, runCheckPass, type CheckOpts } from './run-check.ts'
|
|
13
12
|
import { poolLines } from './slots.ts'
|
|
14
13
|
import { runFixPass } from './run-fixers.ts'
|
|
15
|
-
import { formatLine
|
|
14
|
+
import { formatLine } from './wire.ts'
|
|
16
15
|
import { writeStatus } from './write-status.ts'
|
|
17
|
-
import { allTasks, composeChecks, envTier, FULL_TESTS_ENV,
|
|
16
|
+
import { allTasks, composeChecks, envTier, FULL_TESTS_ENV, type TestTier } from '../ledger/gate-checks.ts'
|
|
18
17
|
import { CHECK_TABLE_FILE, writeCheckTable } from '../ledger/check-table.ts'
|
|
19
18
|
import { Ledger } from '../ledger/ledger.ts'
|
|
20
19
|
import { RunLog, type InvokedBy } from '../ledger/run-log.ts'
|
|
@@ -170,11 +169,34 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
170
169
|
// ONE walk for the whole gate: the ledger is keyed on it and the run
|
|
171
170
|
// measures it, and two walks can disagree about what exists
|
|
172
171
|
const composed = await composeChecks(root, c, { tier })
|
|
172
|
+
// `gate = false` opts a check out of the everyday gate, and failOnAdvisory
|
|
173
|
+
// (release.requireHealthy) re-arms the world probes, because a repo asking
|
|
174
|
+
// for exactly those must not have them silently dropped by the opt-out
|
|
175
|
+
const passOpts: CheckOpts = {
|
|
176
|
+
status: false,
|
|
177
|
+
config: c,
|
|
178
|
+
composed,
|
|
179
|
+
audience: 'gate',
|
|
180
|
+
includeProactive: o.failOnAdvisory === true,
|
|
181
|
+
frozen: o.frozen,
|
|
182
|
+
onlyBlocking: o.onlyBlockingChecks,
|
|
183
|
+
only: o.onlyIds,
|
|
184
|
+
withIds: o.withIds,
|
|
185
|
+
skipGroups: o.skipGroups,
|
|
186
|
+
exclude: o.skipTaskIds,
|
|
187
|
+
buildOnly: o.buildOnly,
|
|
188
|
+
bail: o.bail,
|
|
189
|
+
fresh: o.fresh,
|
|
190
|
+
tier,
|
|
191
|
+
}
|
|
173
192
|
try {
|
|
174
193
|
ledger = Ledger.open(root, composed.map((g) => g.row.decl), {
|
|
175
194
|
cliEntry: o.cliEntry ?? gateBinary(),
|
|
176
195
|
read: o.ledgerRead,
|
|
177
196
|
fresh: o.fresh,
|
|
197
|
+
// only what this run executes is scanned: an `--only` run pays for
|
|
198
|
+
// its own checks, never for the whole inventory
|
|
199
|
+
scope: passScope(composed, passOpts),
|
|
178
200
|
})
|
|
179
201
|
const reg = ledger.layer.registration
|
|
180
202
|
for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
|
|
@@ -192,27 +214,9 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
192
214
|
ledger = null
|
|
193
215
|
sink(`WARN cache unavailable (${String(e).slice(0, 160)}) — every check runs this time, so the gate is correct and slower`)
|
|
194
216
|
}
|
|
195
|
-
// THE RUN. One engine, one DAG, one pass over everything this gate asks
|
|
196
|
-
// for: `gate = false` opts a check out of the everyday gate, and
|
|
197
|
-
// failOnAdvisory (release.requireHealthy) re-arms the world probes,
|
|
198
|
-
// because a repo asking for exactly those must not have them silently
|
|
199
|
-
// dropped by the opt-out.
|
|
217
|
+
// THE RUN. One engine, one DAG, one pass over everything this gate asks for
|
|
200
218
|
const run = await runCheckPass(root, {
|
|
201
|
-
|
|
202
|
-
config: c,
|
|
203
|
-
composed,
|
|
204
|
-
audience: 'gate',
|
|
205
|
-
includeProactive: o.failOnAdvisory === true,
|
|
206
|
-
frozen: o.frozen,
|
|
207
|
-
onlyBlocking: o.onlyBlockingChecks,
|
|
208
|
-
only: o.onlyIds,
|
|
209
|
-
withIds: o.withIds,
|
|
210
|
-
skipGroups: o.skipGroups,
|
|
211
|
-
exclude: o.skipTaskIds,
|
|
212
|
-
buildOnly: o.buildOnly,
|
|
213
|
-
bail: o.bail,
|
|
214
|
-
fresh: o.fresh,
|
|
215
|
-
tier,
|
|
219
|
+
...passOpts,
|
|
216
220
|
ledger,
|
|
217
221
|
runLog,
|
|
218
222
|
onCheck: o.onCheck,
|
|
@@ -231,8 +235,8 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
231
235
|
}
|
|
232
236
|
// a switch wired to no check does nothing, silently, which is the one thing
|
|
233
237
|
// a toggle must never do
|
|
234
|
-
for (const
|
|
235
|
-
sink(`FAIL:
|
|
238
|
+
for (const key of [...run.unknownWhen.map((id) => `when.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
|
|
239
|
+
sink(`FAIL: ${key} names no check of this repo`)
|
|
236
240
|
sink(` fix: \`shipkit checks\` lists every id; drop the entry or correct it`)
|
|
237
241
|
}
|
|
238
242
|
let advisoryFatal = false
|
|
@@ -250,7 +254,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
250
254
|
if (ledger != null) for (const l of ledger.header(ledger.commit())) sink(formatLine(l, false))
|
|
251
255
|
// one write for the whole gate, after everything has finished
|
|
252
256
|
for (const l of runLog.flush()) sink(formatLine(l, false))
|
|
253
|
-
const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownWhen.length > 0 ? 1 : 0
|
|
257
|
+
const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownWhen.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
|
|
254
258
|
// motherhome row (docsBase wiki on this disk) — never affects the gate,
|
|
255
259
|
// and never fires under frozen: a bump/release gate must not re-dirty the
|
|
256
260
|
// tree it just proved clean
|
package/src/core/run-fix.ts
CHANGED
|
@@ -17,11 +17,14 @@ export type FixOpts = {
|
|
|
17
17
|
* not wire-shaped, which the frozen output contract forbids */
|
|
18
18
|
quiet?: boolean
|
|
19
19
|
print?: (line: string) => void
|
|
20
|
+
/** the config as it stands on disk now, when the caller holds one: a
|
|
21
|
+
* second load of a rewritten repo.config.ts can hit the module cache */
|
|
22
|
+
config?: RepoConfig
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
23
26
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
24
|
-
const config = await loadRepoConfig(root)
|
|
27
|
+
const config = o.config ?? (await loadRepoConfig(root))
|
|
25
28
|
const env: CheckEnv = { root, config }
|
|
26
29
|
// a fresh context per body: a fixer may rewrite the config, and the body
|
|
27
30
|
// after it must read the one on disk now
|