rvlib-shipkit 1.8.0 → 2.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 +66 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +31 -14
- package/src/assets/gen/generate-roll.ts +20 -7
- package/src/assets/kit-config.ts +9 -0
- package/src/assets/kits/identity-kit.ts +7 -1
- package/src/assets/run-assets.ts +3 -3
- package/src/assets/server/studio-server.ts +3 -3
- package/src/check.ts +5 -2
- package/src/checks/SK001-package-json-shape.check.ts +47 -12
- package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
- package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
- package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
- package/src/checks/SK029-explicit-check-files.check.ts +25 -8
- package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
- package/src/cli/cmd_Ci.ts +6 -5
- package/src/cli/cmd_Commands.ts +21 -5
- package/src/cli/cmd_Hooks.ts +6 -2
- package/src/cli/cmd_Init.ts +1 -1
- package/src/cli/cmd_Serve.ts +1 -2
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli.ts +2 -4
- package/src/collections/providers.ts +1 -1
- package/src/core/budget.ts +6 -8
- package/src/core/bun-builtins.ts +36 -0
- package/src/core/bun-tool-cli.ts +23 -0
- package/src/core/canonical-scripts.ts +6 -4
- package/src/core/check-ctx.ts +104 -10
- package/src/core/check-file-names.ts +2 -3
- package/src/core/check-pass.ts +17 -1
- package/src/core/check-runner.ts +32 -10
- package/src/core/config-rewrite.ts +27 -6
- package/src/core/discover-checks.ts +64 -15
- package/src/core/fix-scope.ts +5 -0
- package/src/core/floor-resolve.ts +18 -8
- package/src/core/git-root.ts +22 -12
- package/src/core/job-run.ts +2 -2
- package/src/core/layout.ts +4 -1
- package/src/core/legacy-shapes.ts +40 -0
- package/src/core/one-lib.ts +5 -3
- package/src/core/one-way-in-policy.ts +106 -13
- package/src/core/pre-commit-discovery.ts +106 -0
- package/src/core/retired-checks.ts +2 -0
- package/src/core/run-ci.ts +23 -16
- package/src/core/run-fix.ts +9 -0
- package/src/core/run-fixers.ts +91 -35
- package/src/core/runs-matrix.ts +7 -0
- package/src/core/slots.ts +36 -6
- package/src/core/vendored-read.ts +39 -0
- package/src/core/vendored.ts +82 -0
- package/src/core/waivers.ts +125 -0
- package/src/core/when-block.ts +25 -185
- package/src/core/when.ts +30 -13
- package/src/core/wire.ts +17 -1
- package/src/git-bootstrap.ts +1 -1
- package/src/home/attach.ts +3 -5
- package/src/home/gui-app/ShipkitGui.swift +96 -0
- package/src/home/insights.ts +6 -1
- package/src/home/pm-config.ts +2 -2
- package/src/home/resources.ts +3 -0
- package/src/home/run-daemon.ts +2 -2
- package/src/home/run-unit-action.ts +2 -2
- package/src/hooks/commit-message.ts +2 -14
- package/src/hooks/hook-gates.ts +38 -0
- package/src/hooks/leak-guard.ts +8 -3
- package/src/hooks/one-way-in-guard.ts +8 -2
- package/src/hooks/root-identity.ts +9 -6
- package/src/hooks/run-hooks.ts +72 -41
- package/src/hooks/staged.ts +49 -0
- package/src/hooks/tree-shrink.ts +41 -0
- package/src/init-repo.ts +0 -42
- package/src/ledger/check-table.ts +1 -1
- package/src/ledger/gate-checks.ts +45 -9
- package/src/ledger/ledger.ts +6 -1
- package/src/ledger/repo-view.ts +14 -4
- package/src/ledger/toolchain-identity.ts +4 -2
- package/src/mv/mv-md.ts +6 -1
- package/src/plan/planner.ts +21 -4
- package/src/plan/repo-source.ts +7 -3
- package/src/problems/extract.ts +27 -5
- package/src/problems/problem-line.ts +2 -3
- package/src/release/run-release.ts +30 -16
- package/src/release/unit-gate.ts +12 -5
- package/src/repo-config.ts +51 -27
- package/src/scaffold/files/bunfig.toml +11 -0
- package/src/scaffold/files/vscode-settings.json +1 -2
- package/src/scaffold/jsonc-spans.ts +16 -0
- package/src/scaffold/project-scaffold.ts +43 -77
- package/src/serve/hub-app/check-view.tsx +4 -4
- package/src/serve/hub-app/check-words.ts +1 -0
- package/src/serve/hub-app/checks-svc.ts +1 -2
- package/src/serve/hub-app/home.html +0 -1
- package/src/serve/hub-app/hub-types.ts +0 -2
- package/src/serve/hub-app/index.html +0 -1
- package/src/serve/hub-app/project-list.css +1 -2
- package/src/serve/hub-app/tree-build.ts +3 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-port.ts +3 -3
- package/src/serve/hub-runtime.ts +3 -5
- package/src/serve/project-gutter.ts +5 -2
- package/src/serve/run-lanes.ts +7 -3
- package/src/serve/serve-hub.ts +1 -16
- package/src/serve/tracked-watch.ts +1 -1
- package/src/smoke/run-smoke.ts +13 -6
- package/src/start-repo.ts +15 -3
- package/src/suite/judge-suites.ts +81 -32
- package/src/suite/select.ts +2 -0
- package/src/suite/suite-cli.ts +27 -16
- package/src/suite/suite-cpu-preload.ts +7 -11
- package/src/suite/test-units.ts +7 -4
- package/src/ts/config.ts +12 -5
- package/src/ts/imports.ts +3 -1
- package/src/ts/typecheck-cache.ts +11 -0
- package/src/ts/typecheck-cli.ts +2 -1
- package/src/tui/build-steps-panel.ts +1 -1
- package/src/wiki/scan-md.ts +16 -9
- package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
- package/src/checks/SK014-prose-ste.check.ts +0 -78
- package/src/cli/cmd_Prose.ts +0 -74
- package/src/prose/prose-view.ts +0 -90
- package/src/prose/run-prose.ts +0 -187
- package/src/prose/ste.ts +0 -552
- package/src/scaffold/files/vscode-tasks.json +0 -24
- package/src/serve/hub-app/prose-hits-st.ts +0 -125
- package/src/serve/hub-app/prose-hits-view.tsx +0 -176
- package/src/serve/hub-app/prose-hits.css +0 -21
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// discovery for the pre-commit gate: import ONLY the check files that run a
|
|
2
|
+
// body at commit. Full discovery imports every check file to read one field,
|
|
3
|
+
// and a repo file that imports heavy modules at its top level then costs every
|
|
4
|
+
// commit, seconds once its sources moved since the last transpile.
|
|
5
|
+
//
|
|
6
|
+
// A table beside the per worktree git dir remembers, per file, whether it
|
|
7
|
+
// declares `onPreCommit`, keyed on the file's mtime and size. A file whose
|
|
8
|
+
// stamp still matches and that runs nothing at commit is never imported. A
|
|
9
|
+
// file that moved is imported again, and every import is timed, so a slow
|
|
10
|
+
// one is named on the commit that pays for it.
|
|
11
|
+
//
|
|
12
|
+
// The stamp is the file's own: a check that takes its `runs` from another
|
|
13
|
+
// module is re-read when its own file moves, or when the table is dropped.
|
|
14
|
+
import { mkdirSync, readFileSync, statSync } from 'fs'
|
|
15
|
+
import { dirname, join } from 'path'
|
|
16
|
+
import { gitRoot } from './git-root.ts'
|
|
17
|
+
import { atomicWrite } from './locked-write.ts'
|
|
18
|
+
import { BUILTIN_CHECK_DIR, discoverChecks, type DiscoveredCheck } from './discover-checks.ts'
|
|
19
|
+
|
|
20
|
+
/** a repo check file whose import takes longer than this, warm, is named */
|
|
21
|
+
export const SLOW_IMPORT_MS = 30
|
|
22
|
+
|
|
23
|
+
const TABLE_VERSION = 1
|
|
24
|
+
|
|
25
|
+
type Row = { stamp: string; preCommit: boolean }
|
|
26
|
+
type Table = { v: number; files: Record<string, Row> }
|
|
27
|
+
|
|
28
|
+
export const declarationsTable = (root: string): string | null => {
|
|
29
|
+
const dir = gitRoot(root)?.gitDir
|
|
30
|
+
return dir == null ? null : join(dir, 'shipkit', 'check-declarations.json')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readTable(path: string | null): Table {
|
|
34
|
+
if (path == null) return { v: TABLE_VERSION, files: {} }
|
|
35
|
+
try {
|
|
36
|
+
const t = JSON.parse(readFileSync(path, 'utf8')) as Table
|
|
37
|
+
if (t.v === TABLE_VERSION && t.files != null && typeof t.files === 'object') return t
|
|
38
|
+
} catch {}
|
|
39
|
+
return { v: TABLE_VERSION, files: {} }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const stampOf = (path: string): string | null => {
|
|
43
|
+
try {
|
|
44
|
+
const s = statSync(path)
|
|
45
|
+
return `${s.mtimeMs}:${s.size}`
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type PreCommitDiscovery = {
|
|
52
|
+
/** what discovery returns, minus the files the table skipped */
|
|
53
|
+
checks: DiscoveredCheck[]
|
|
54
|
+
/** repo check files whose import took longer than SLOW_IMPORT_MS */
|
|
55
|
+
slow: { path: string; ms: number }[]
|
|
56
|
+
/** how many files were imported, and how many the table skipped */
|
|
57
|
+
imported: number
|
|
58
|
+
skipped: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function discoverPreCommit(root: string): Promise<PreCommitDiscovery> {
|
|
62
|
+
const path = declarationsTable(root)
|
|
63
|
+
const table = readTable(path)
|
|
64
|
+
const stamps = new Map<string, string | null>()
|
|
65
|
+
const stamp = (f: string): string | null => {
|
|
66
|
+
if (!stamps.has(f)) stamps.set(f, stampOf(f))
|
|
67
|
+
return stamps.get(f) ?? null
|
|
68
|
+
}
|
|
69
|
+
let changed = false
|
|
70
|
+
let skipped = 0
|
|
71
|
+
let imported = 0
|
|
72
|
+
const slow: { path: string; ms: number }[] = []
|
|
73
|
+
const checks = await discoverChecks(root, {
|
|
74
|
+
skip: (f) => {
|
|
75
|
+
const row = table.files[f]
|
|
76
|
+
const hit = row != null && !row.preCommit && row.stamp === stamp(f)
|
|
77
|
+
if (hit) skipped++
|
|
78
|
+
return hit
|
|
79
|
+
},
|
|
80
|
+
imported: (f, ms, def, loaded) => {
|
|
81
|
+
imported++
|
|
82
|
+
if (!f.startsWith(`${BUILTIN_CHECK_DIR}/`) && ms > SLOW_IMPORT_MS) slow.push({ path: f, ms })
|
|
83
|
+
const s = stamp(f)
|
|
84
|
+
// a file that did not load is asked again next time: its error is
|
|
85
|
+
// the gate's to show, never the table's to remember
|
|
86
|
+
if (!loaded || s == null) return
|
|
87
|
+
const row: Row = { stamp: s, preCommit: def != null && def.runs.onPreCommit !== false }
|
|
88
|
+
const prev = table.files[f]
|
|
89
|
+
if (prev?.stamp === row.stamp && prev.preCommit === row.preCommit) return
|
|
90
|
+
table.files[f] = row
|
|
91
|
+
changed = true
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
if (changed && path != null) {
|
|
95
|
+
// a row whose file is gone is dropped, so the table never outgrows the repo
|
|
96
|
+
for (const f of Object.keys(table.files)) if (stamp(f) == null) delete table.files[f]
|
|
97
|
+
try {
|
|
98
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
99
|
+
atomicWrite(path, JSON.stringify(table))
|
|
100
|
+
} catch {
|
|
101
|
+
// a table that cannot be written costs the next commit the full
|
|
102
|
+
// import, never its answer
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { checks, slow, imported, skipped }
|
|
106
|
+
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// id is never reused, so a doc or a changelog may still name one, and
|
|
3
3
|
// discovery refuses a built-in that takes one again.
|
|
4
4
|
export const RETIRED_CHECK_IDS: Readonly<Record<string, string>> = {
|
|
5
|
+
SK009: 'the AI attribution guard is gone: no rule polices commit trailers',
|
|
6
|
+
SK014: 'the STE prose rule is gone: no rule polices prose style',
|
|
5
7
|
SK021: "the gh login is a machine fact: the house repo's github-login check runs src/core/gh-login.ts",
|
|
6
8
|
SK023: "the store caps are the machine daemon's health: the house repo's stores-under-cap check",
|
|
7
9
|
}
|
package/src/core/run-ci.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
|
10
10
|
import { reportToMotherhome } from './motherhome.ts'
|
|
11
11
|
import { passScope, runCheckPass, type CheckOpts } from './run-check.ts'
|
|
12
12
|
import { poolLines } from './slots.ts'
|
|
13
|
+
import { fixPassRuns } from './fix-scope.ts'
|
|
13
14
|
import { runFixPass } from './run-fixers.ts'
|
|
14
15
|
import { formatLine } from './wire.ts'
|
|
15
16
|
import { outcomeCounts } from './outcome-words.ts'
|
|
@@ -29,8 +30,9 @@ export type CiOpts = {
|
|
|
29
30
|
onlyIds?: string[]
|
|
30
31
|
/** refresh STATUS.md after the run (default true — O4: every repo gets it) */
|
|
31
32
|
status?: boolean
|
|
32
|
-
/** repair the mechanical slots before gating
|
|
33
|
-
*
|
|
33
|
+
/** repair the mechanical slots before gating, verify mode under CI.
|
|
34
|
+
* Unset: on for a whole gate, off for a narrowed one (core/fix-scope.ts).
|
|
35
|
+
* core/run-fixers.ts owns the scope. */
|
|
34
36
|
fix?: boolean
|
|
35
37
|
/** also apply the repairs of the checks declaring `fix: 'ci'` during the
|
|
36
38
|
* fix pass */
|
|
@@ -135,19 +137,22 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
135
137
|
// what the reader never acts on: kept for STATUS.md and --verbose, never
|
|
136
138
|
// printed on a default run, where every line costs a reader tokens
|
|
137
139
|
const detail = (l: string): void => { collected.push(l); if (o.verbose === true) print(l) }
|
|
140
|
+
// a check that answered green says nothing a reader acts on: the verdict
|
|
141
|
+
// counts it. What needs a reader (a WARN, a FAIL and its fix, a wait for a
|
|
142
|
+
// machine slot) always prints
|
|
143
|
+
const checkLine = (l: string): void => (/^(ok|skip): /.test(l) && !/^ok: waiting\b/.test(l) ? detail : sink)(l)
|
|
138
144
|
let c = o.config ?? (await loadRepoConfig(root))
|
|
139
145
|
// mechanical failures are not information, so they never cost a round trip
|
|
140
146
|
let fixFailed = false
|
|
141
|
-
// a NARROWED run repairs
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// back as a test failure.
|
|
147
|
+
// a NARROWED run repairs nothing unless --fix asks, and even then only
|
|
148
|
+
// what the named checks own: their own ci repair and the fixer slot that
|
|
149
|
+
// governs one of them. `bun run test` routes here and must answer the one
|
|
150
|
+
// question its name promises without leaving files in the agent's diff
|
|
151
|
+
// that it never wrote: in a shared tree a tree wide fixer rewrites every
|
|
152
|
+
// other agent's files too. Arming rewrites hooks/: the full gate's price alone
|
|
148
153
|
const narrowed = (o.onlyIds ?? []).length > 0
|
|
149
|
-
if (o
|
|
150
|
-
const pass = await runFixPass(root, c, { rules:
|
|
154
|
+
if (fixPassRuns(o)) {
|
|
155
|
+
const pass = await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink, detail, ...(narrowed ? { only: o.onlyIds } : {}) })
|
|
151
156
|
fixFailed = pass.failed
|
|
152
157
|
// a fixer that rewrote the config or moved a check file leaves the
|
|
153
158
|
// config above describing a repo that is gone, so the gate reads the
|
|
@@ -158,7 +163,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
158
163
|
return 1
|
|
159
164
|
}
|
|
160
165
|
}
|
|
161
|
-
} else detail('skip: fix pass (--no-fix)')
|
|
166
|
+
} else detail(o.fix === false ? 'skip: fix pass (--no-fix)' : 'skip: fix pass (a --only run leaves the tree alone, --fix repairs first)')
|
|
162
167
|
const tier: TestTier = o.tests ?? envTier()
|
|
163
168
|
// announced BEFORE the run (under --verbose, and in every release log),
|
|
164
169
|
// so a log that died mid-way still says which tier it was asking for
|
|
@@ -178,7 +183,9 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
178
183
|
// the harness refused it the raw runner, so the line says what to add
|
|
179
184
|
// rather than leaving it with `unknown check id` and no next move
|
|
180
185
|
if ((o.onlyIds ?? []).includes(u.id))
|
|
181
|
-
sink(
|
|
186
|
+
sink(u.id === 'lint'
|
|
187
|
+
? ` fix: add a [lint] section to bunfig.toml, then \`shipkit ci --only lint\` runs \`bun lint\` (build/typescript.md)`
|
|
188
|
+
: ` fix: add a '${u.id}:raw' script holding the tool, then \`shipkit ci --only ${u.id}\` runs it (meta/rv-conventions/package-json.md)`)
|
|
182
189
|
}
|
|
183
190
|
// the run record: one run id for every check of this gate, so a breakdown
|
|
184
191
|
// of the wall time is reconstructable. It is never read here, only written
|
|
@@ -251,7 +258,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
251
258
|
runLog,
|
|
252
259
|
onCheck: o.onCheck,
|
|
253
260
|
reportHome: false,
|
|
254
|
-
print:
|
|
261
|
+
print: checkLine,
|
|
255
262
|
detail,
|
|
256
263
|
})
|
|
257
264
|
// what the suite ACTUALLY ran, from its own headline. A flag that was set
|
|
@@ -288,10 +295,10 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
288
295
|
for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
|
|
289
296
|
// why every check that did not replay ran, grouped: a re-run nobody
|
|
290
297
|
// can explain costs a reader more than the line does
|
|
291
|
-
for (const { line
|
|
298
|
+
for (const { line } of ledger.explain()) detail(formatLine(line, false))
|
|
292
299
|
}
|
|
293
300
|
// one write for the whole gate, after everything has finished
|
|
294
|
-
for (const l of runLog.flush())
|
|
301
|
+
for (const l of runLog.flush()) checkLine(formatLine(l, false))
|
|
295
302
|
const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownOff.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
|
|
296
303
|
// THE VERDICT, one line and only on a pass: a red run already said so in
|
|
297
304
|
// its FAIL lines. The whole gate adds the tier and the cache counts, which
|
package/src/core/run-fix.ts
CHANGED
|
@@ -15,6 +15,10 @@ import type { RepoConfig } from '../repo-config.ts'
|
|
|
15
15
|
|
|
16
16
|
export type FixOpts = {
|
|
17
17
|
ids?: string[]
|
|
18
|
+
/** the gate's `--only` names: of the ci repairs, only the ones these
|
|
19
|
+
* name, by check id or by group, and silently none for a name that has
|
|
20
|
+
* no repair */
|
|
21
|
+
scope?: string[]
|
|
18
22
|
/** drop the closing advisory: inside the ci fix pass it is noise, and it is
|
|
19
23
|
* not wire-shaped, which the frozen output contract forbids */
|
|
20
24
|
quiet?: boolean
|
|
@@ -55,6 +59,11 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
|
55
59
|
|
|
56
60
|
// no ids: the repairs declared for the gate. A manual one runs by name
|
|
57
61
|
let targets = all.filter((r) => r.fix != null && r.when === 'ci')
|
|
62
|
+
if (o.scope != null) {
|
|
63
|
+
const named = new Set(o.scope)
|
|
64
|
+
const groups = new Map((await gateInventory(root, config)).map((r) => [r.decl.id, r.group]))
|
|
65
|
+
targets = targets.filter((r) => named.has(r.id) || named.has(groups.get(r.id) ?? ''))
|
|
66
|
+
}
|
|
58
67
|
if (o.ids != null && o.ids.length > 0) {
|
|
59
68
|
for (const id of o.ids) {
|
|
60
69
|
const r = all.find((x) => x.id === id)
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
// what `shipkit ci` repairs before it gates.
|
|
2
2
|
//
|
|
3
|
-
// mechanical slots only (
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// mechanical slots only (the import canonicaliser): it rewrites the files the
|
|
4
|
+
// agent is already editing and its output is total. structural SK rule fixers
|
|
5
|
+
// rewrite repo config, so they stay behind --fix-rules. no formatter runs
|
|
6
|
+
// here: formatting is a manual `bun fmt`, never a gate step.
|
|
6
7
|
//
|
|
7
8
|
// under CI the same fixers run in verify mode instead: a gate that silently
|
|
8
9
|
// rewrites the tree it checks can turn red green with no commit. one
|
|
9
|
-
// mechanism, two modes, rather than a
|
|
10
|
-
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
10
|
+
// mechanism, two modes, rather than a fixer plus a check that drift apart.
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
12
12
|
import { dirname, isAbsolute, join } from 'path'
|
|
13
13
|
import { fileURLToPath } from 'url'
|
|
14
14
|
import { armHooks } from '../hooks/run-hooks.ts'
|
|
15
15
|
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
16
16
|
import { runFix } from './run-fix.ts'
|
|
17
|
-
import {
|
|
17
|
+
import { repoOff } from './when.ts'
|
|
18
18
|
import { discoverLegacyChecks } from './discover-legacy-checks.ts'
|
|
19
19
|
import { repoCheckFiles } from './discover-checks.ts'
|
|
20
20
|
import { mutateLease, withMutateLease } from './locked-write.ts'
|
|
@@ -24,6 +24,8 @@ import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
|
24
24
|
import { gitBin } from './git-bin.ts'
|
|
25
25
|
import { LAYOUT } from './layout.ts'
|
|
26
26
|
import { gateBinary, toolchainIdentity } from '../ledger/toolchain-identity.ts'
|
|
27
|
+
import { isVendored } from './vendored.ts'
|
|
28
|
+
import { readVendored } from './vendored-read.ts'
|
|
27
29
|
|
|
28
30
|
const FIXER_TIMEOUT_MS = 120_000
|
|
29
31
|
|
|
@@ -35,8 +37,6 @@ export type FixerSlot = {
|
|
|
35
37
|
|
|
36
38
|
export type FixerResolution = {
|
|
37
39
|
slots: FixerSlot[]
|
|
38
|
-
/** slots nothing could fill, printed, never silent */
|
|
39
|
-
unfilled: { id: string; reason: string }[]
|
|
40
40
|
/** slots the repo turned off, printed with the same vocabulary the task
|
|
41
41
|
* side uses, so a disable is never invisible in the output */
|
|
42
42
|
disabled: { id: string; reason: string }[]
|
|
@@ -47,10 +47,6 @@ export type FixerResolution = {
|
|
|
47
47
|
// anyway is the config saying one thing and the tool doing another.
|
|
48
48
|
const GOVERNED_BY: Record<string, string> = { 'imports:fix': 'imports:check' }
|
|
49
49
|
|
|
50
|
-
// one fixer per mechanical concern. `fmt` accepts the two names the house
|
|
51
|
-
// vocabulary allows so a repo that calls it `format` is not silently unfixed.
|
|
52
|
-
const FMT_SCRIPTS = ['fmt', 'format'] as const
|
|
53
|
-
|
|
54
50
|
export function resolveFixers(root: string, config?: RepoConfig): FixerResolution {
|
|
55
51
|
const pkgPath = join(root, 'package.json')
|
|
56
52
|
let scripts: Record<string, string> = {}
|
|
@@ -59,31 +55,23 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
|
|
|
59
55
|
catch (e) { throw new Error(`package.json unparseable — fixers cannot register: ${String(e).slice(0, 120)}`) }
|
|
60
56
|
}
|
|
61
57
|
const slots: FixerSlot[] = []
|
|
62
|
-
const unfilled: { id: string; reason: string }[] = []
|
|
63
58
|
const disabled: { id: string; reason: string }[] = []
|
|
64
59
|
const offByConfig = (id: string): boolean => {
|
|
65
60
|
const governing = GOVERNED_BY[id]
|
|
66
61
|
if (governing == null) return false
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
const off = config == null ? null : repoOff(config, governing)
|
|
63
|
+
if (off == null) return false
|
|
64
|
+
disabled.push({ id, reason: `skipped-by-config (${governing} is off: ${off.reason})` })
|
|
69
65
|
return true
|
|
70
66
|
}
|
|
71
67
|
|
|
72
|
-
// `fmt` is the one slot the fix pass invokes BY SCRIPT, so it is the one
|
|
73
|
-
// name ONE WAY IN deliberately leaves raw: a routed body here would make
|
|
74
|
-
// the fixer spawn the gate that spawns the fixer
|
|
75
|
-
const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null && !routesToShipkit(scripts[n]!))
|
|
76
|
-
if (fmtName != null) slots.push({ id: 'fmt', cmd: ['bun', 'run', fmtName], source: 'package.json script' })
|
|
77
|
-
else if (FMT_SCRIPTS.some((n) => scripts[n] != null)) unfilled.push({ id: 'fmt', reason: `the 'fmt' script routes back to shipkit — the fix pass runs it, so it holds the formatter itself` })
|
|
78
|
-
else unfilled.push({ id: 'fmt', reason: `no 'fmt' or 'format' script — formatting stays a manual step` })
|
|
79
|
-
|
|
80
68
|
if (offByConfig('imports:fix')) { /* the repo said no, in the one place that governs both directions */ }
|
|
81
69
|
else if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
|
|
82
70
|
else {
|
|
83
71
|
const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
|
|
84
72
|
slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (shipkit)' })
|
|
85
73
|
}
|
|
86
|
-
return { slots,
|
|
74
|
+
return { slots, disabled }
|
|
87
75
|
}
|
|
88
76
|
|
|
89
77
|
/** worktree fingerprint: every dirty path mapped to its CONTENT hash, or null
|
|
@@ -101,10 +89,9 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
|
|
|
101
89
|
* bytes attributes only what a fixer can actually have done.
|
|
102
90
|
*
|
|
103
91
|
* It watches the WHOLE tree on purpose. A narrower porcelain was tried and
|
|
104
|
-
* deleted: the
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* scope. Narrowing to the formatter's paths therefore hid real rewrites from
|
|
92
|
+
* deleted: the same pass also runs the rule fixers, which rewrite
|
|
93
|
+
* package.json, .gitignore, CLAUDE.md and .claude/, none of them inside a
|
|
94
|
+
* source fixer's scope. Narrowing to the fixers' paths therefore hid real rewrites from
|
|
108
95
|
* the verify mode, which is a silent green on a tree a fixer changed. What
|
|
109
96
|
* actually stops another agent's work being reported here is the lease plus
|
|
110
97
|
* the byte comparison, not a path list. */
|
|
@@ -125,6 +112,50 @@ function treeState(root: string): Map<string, string> | null {
|
|
|
125
112
|
return m
|
|
126
113
|
}
|
|
127
114
|
|
|
115
|
+
/** the vendored files whose work tree differs from the index, each with its
|
|
116
|
+
* bytes (null when deleted). One pathspec limited diff, paths relative to
|
|
117
|
+
* `root` even in a nested repo, so a repo with no vendored dir costs nothing */
|
|
118
|
+
function vendoredState(root: string, dirs: readonly string[]): Map<string, Buffer | null> {
|
|
119
|
+
const m = new Map<string, Buffer | null>()
|
|
120
|
+
if (dirs.length === 0) return m
|
|
121
|
+
const p = Bun.spawnSync([gitBin(), 'diff', '--name-only', '--relative', '-z', '--', ...dirs], { cwd: root, stdout: 'pipe', stderr: 'ignore' })
|
|
122
|
+
if (p.exitCode !== 0) return m
|
|
123
|
+
for (const path of p.stdout.toString().split('\0')) {
|
|
124
|
+
if (path === '' || !isVendored(path, dirs)) continue
|
|
125
|
+
try { m.set(path, readFileSync(join(root, path))) } catch { m.set(path, null) }
|
|
126
|
+
}
|
|
127
|
+
return m
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** put back every vendored file a fixer changed since `before`: its bytes from
|
|
131
|
+
* before when it differed from the index then, the index's otherwise */
|
|
132
|
+
function restoreVendored(root: string, dirs: readonly string[], before: Map<string, Buffer | null>): { restored: string[]; failed: string[] } {
|
|
133
|
+
const restored: string[] = []
|
|
134
|
+
const failed: string[] = []
|
|
135
|
+
const fromIndex: string[] = []
|
|
136
|
+
const now = vendoredState(root, dirs)
|
|
137
|
+
const putBack = (path: string, was: Buffer | null): void => {
|
|
138
|
+
try {
|
|
139
|
+
if (was === null) rmSync(join(root, path), { force: true })
|
|
140
|
+
else writeFileSync(join(root, path), was)
|
|
141
|
+
restored.push(path)
|
|
142
|
+
} catch { failed.push(path) }
|
|
143
|
+
}
|
|
144
|
+
for (const [path, bytes] of now) {
|
|
145
|
+
const was = before.get(path)
|
|
146
|
+
if (was === undefined) fromIndex.push(path)
|
|
147
|
+
else if (was === null ? bytes !== null : bytes === null || !was.equals(bytes)) putBack(path, was)
|
|
148
|
+
}
|
|
149
|
+
// differed from the index before and matches it now: a fixer undid an edit
|
|
150
|
+
for (const [path, was] of before) if (!now.has(path)) putBack(path, was)
|
|
151
|
+
if (fromIndex.length > 0) {
|
|
152
|
+
const p = Bun.spawnSync([gitBin(), 'checkout', '--', ...fromIndex], { cwd: root, stdout: 'ignore', stderr: 'ignore' })
|
|
153
|
+
if (p.exitCode === 0) restored.push(...fromIndex)
|
|
154
|
+
else failed.push(...fromIndex)
|
|
155
|
+
}
|
|
156
|
+
return { restored: restored.sort(), failed: failed.sort() }
|
|
157
|
+
}
|
|
158
|
+
|
|
128
159
|
/** where the key of the last clean fix pass is kept, per checkout */
|
|
129
160
|
const memoPath = (root: string): string => join(root, LAYOUT.tmp, 'fix-pass.key')
|
|
130
161
|
|
|
@@ -173,7 +204,11 @@ export type FixPassOpts = {
|
|
|
173
204
|
* mutate the tree they are about to ship) */
|
|
174
205
|
armHooks?: boolean
|
|
175
206
|
print: (line: string) => void
|
|
176
|
-
/**
|
|
207
|
+
/** a narrowed pass (`sk ci --only <ids> --fix`): only the repairs these
|
|
208
|
+
* ids own, their own ci repair and the fixer slot that governs one of
|
|
209
|
+
* them */
|
|
210
|
+
only?: string[]
|
|
211
|
+
/** where the lines nobody acts on go (a clean pass, a disabled slot).
|
|
177
212
|
* Default: print. `shipkit ci` shows them only under --verbose */
|
|
178
213
|
detail?: (line: string) => void
|
|
179
214
|
}
|
|
@@ -194,7 +229,7 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
|
|
|
194
229
|
// not clean, and the gate says so instead of quietly repairing it
|
|
195
230
|
const verify = process.env.CI != null && process.env.CI !== ''
|
|
196
231
|
// EVERY write this function makes sits inside one lease. Three agents in
|
|
197
|
-
// one checkout is the normal case, and two
|
|
232
|
+
// one checkout is the normal case, and two fixers rewriting the same
|
|
198
233
|
// file at once, or two runs arming hooks and rewriting package.json
|
|
199
234
|
// together, is the one hazard a snapshot diff cannot even detect. The
|
|
200
235
|
// loser waits rather than skipping, because a skipped repair never
|
|
@@ -260,17 +295,34 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
|
|
|
260
295
|
else if (armed.state === 'failed') o.print(`WARN hooks could not arm: ${armed.detail}`)
|
|
261
296
|
}
|
|
262
297
|
const detail = o.detail ?? o.print
|
|
263
|
-
const
|
|
264
|
-
|
|
298
|
+
const resolution = resolveFixers(root, config)
|
|
299
|
+
const { disabled } = resolution
|
|
300
|
+
const named = o.only == null ? null : new Set(o.only)
|
|
301
|
+
const slots = named == null ? resolution.slots : resolution.slots.filter((s) => named.has(GOVERNED_BY[s.id] ?? ''))
|
|
302
|
+
if (named != null) for (const s of resolution.slots) if (!slots.includes(s)) detail(`skip: ${s.id} (--only ${o.only!.join(', ')} names no check it repairs)`)
|
|
265
303
|
for (const d of disabled) detail(`skip: ${d.id} (${d.reason})`)
|
|
266
304
|
const before = treeState(root)
|
|
267
305
|
// the fixers already ran clean over exactly this tree (HEAD, every dirty
|
|
268
306
|
// path's bytes, the toolchain, the commands): running them again rewrites
|
|
269
|
-
// nothing and costs a whole
|
|
307
|
+
// nothing and costs a whole fixer pass. CI never skips: there the
|
|
270
308
|
// pass IS the check
|
|
271
309
|
const memo = verify || before == null ? null : fixMemo(root, before, slots.map((s) => s.cmd))
|
|
272
310
|
const skip = memo != null && readMemo(root) === memo
|
|
273
311
|
let fixersOk = true
|
|
312
|
+
// third party code stays byte identical: whatever a fixer wrote under a
|
|
313
|
+
// vendored dir goes back at once, and the fixer is named so the repo
|
|
314
|
+
// scopes it (it walked those files for nothing)
|
|
315
|
+
let vendored: string[] = []
|
|
316
|
+
try { vendored = readVendored(root) } catch (e) { o.print(`WARN vendored dirs unreadable, nothing guards them this pass: ${String((e as Error)?.message ?? e).slice(0, 160)}`) }
|
|
317
|
+
let guard = vendoredState(root, vendored)
|
|
318
|
+
const putBack = (who: string): void => {
|
|
319
|
+
if (vendored.length === 0) return
|
|
320
|
+
const { restored, failed } = restoreVendored(root, vendored, guard)
|
|
321
|
+
if (restored.length > 0) o.print(`WARN ${who} rewrote ${restored.length} vendored file(s), put back byte for byte: ${restored.slice(0, 4).join(' ')}${restored.length > 4 ? ` … +${restored.length - 4}` : ''}`)
|
|
322
|
+
if (restored.length > 0) o.print(` fix: scope ${who} to leave ${vendored.join(', ')} out (repo.config.ts \`vendored\`)`)
|
|
323
|
+
if (failed.length > 0) o.print(`WARN ${who} rewrote vendored file(s) that could not be put back: ${failed.slice(0, 4).join(' ')} — restore them with git checkout`)
|
|
324
|
+
guard = vendoredState(root, vendored)
|
|
325
|
+
}
|
|
274
326
|
if (skip) detail(`ok: fixers skipped, the tree is unchanged since they last ran clean (${slots.length} fixer(s))`)
|
|
275
327
|
else for (const s of slots) {
|
|
276
328
|
const r = await spawnWithTimeout(s.cmd, { cwd: root, timeoutMs: FIXER_TIMEOUT_MS })
|
|
@@ -280,9 +332,13 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
|
|
|
280
332
|
fixersOk = false
|
|
281
333
|
o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
|
|
282
334
|
}
|
|
335
|
+
putBack(`fix ${s.id} (${s.cmd.join(' ').slice(0, 60)})`)
|
|
283
336
|
}
|
|
284
337
|
// structural repo-config repair is never implicit, and never in verify mode
|
|
285
|
-
if (o.rules === true && !verify)
|
|
338
|
+
if (o.rules === true && !verify) {
|
|
339
|
+
await runFix(root, { quiet: true, print: o.print, detail: o.detail, config, ...(o.only == null ? {} : { scope: o.only }) })
|
|
340
|
+
putBack('the rule repairs')
|
|
341
|
+
}
|
|
286
342
|
|
|
287
343
|
const after = treeState(root)
|
|
288
344
|
const reshaped = shapeStamp(root) !== shapeBefore
|
package/src/core/runs-matrix.ts
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
import type { CheckRow } from '../ledger/gate-checks.ts'
|
|
6
6
|
import { cellText, RUNS_KEYS, type RunsAxis, type RunsSource } from './runs.ts'
|
|
7
7
|
|
|
8
|
+
/** the commit column's words for a commit gate (hooks/hook-gates.ts HookName) */
|
|
9
|
+
const HOOK_WORDS: readonly string[] = ['pre-commit', 'commit-msg', 'both']
|
|
10
|
+
|
|
8
11
|
export type MatrixAxis = RunsAxis | 'fix'
|
|
9
12
|
|
|
10
13
|
export const MATRIX_AXES: readonly MatrixAxis[] = [...RUNS_KEYS, 'fix']
|
|
@@ -103,6 +106,9 @@ export function runsCellsOf(row: CheckRow): RunsCells {
|
|
|
103
106
|
const r = row.runs
|
|
104
107
|
const cells = Object.fromEntries(MATRIX_AXES.map((k) => {
|
|
105
108
|
const v = k === 'fix' ? r.fix : r[k]
|
|
109
|
+
// a commit gate runs in a git hook rather than as an onPreCommit body,
|
|
110
|
+
// and the commit column names which hook
|
|
111
|
+
if (k === 'onPreCommit' && row.hook != null) return [k, { text: row.hook, on: r.off == null, source: r.source[k] }]
|
|
106
112
|
const on = r.off == null && v !== false && v !== 'none'
|
|
107
113
|
return [k, { text: cellText(r, k), on, source: r.source[k] }]
|
|
108
114
|
})) as Record<MatrixAxis, RunsCell>
|
|
@@ -127,6 +133,7 @@ export function runsWords(c: RunsCells): string {
|
|
|
127
133
|
* prints short (yes, ·, moved, 24h); the legend names the declared value */
|
|
128
134
|
export function cellMeaning(k: MatrixAxis | 'cache', text: string): string {
|
|
129
135
|
const values = RUNS_LEGEND.find((a) => a.axis === k)!.values
|
|
136
|
+
if (k === 'onPreCommit' && HOOK_WORDS.includes(text)) return 'a commit gate: that git hook runs it on every commit, and `shipkit hooks list` shows it'
|
|
130
137
|
const declared = k === 'cache'
|
|
131
138
|
? text.split(/[\s:]/)[0]!
|
|
132
139
|
: text === 'yes' ? 'true'
|
package/src/core/slots.ts
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
// mutate lease's non blocking form (locked-write.ts tryLease): a slot whose
|
|
5
5
|
// holder died is free at once, and a live holder heartbeats it.
|
|
6
6
|
//
|
|
7
|
+
// A slot is a check, and a check is often a whole multi core tool (bun test
|
|
8
|
+
// workers, linter threads, tsc), so slots alone never bound the CPU. Past the
|
|
9
|
+
// cores the pool admits fewer slots in proportion to the 1 minute load, never
|
|
10
|
+
// fewer than one (admittedSlots). A slot already held is never taken back.
|
|
11
|
+
//
|
|
7
12
|
// The one constraint a reader breaks first: a process started BY a check
|
|
8
13
|
// (SHIPKIT_SLOT_HELD=1 in its environment) takes no slot. It already runs on
|
|
9
14
|
// its parent's, and a parent waiting on a child that waits for a slot the
|
|
@@ -12,6 +17,8 @@ import { mkdirSync, readdirSync } from 'fs'
|
|
|
12
17
|
import { homedir } from 'os'
|
|
13
18
|
import { join } from 'path'
|
|
14
19
|
import { leaseHolder, tryLease } from './locked-write.ts'
|
|
20
|
+
import { machineLoad } from './budget.ts'
|
|
21
|
+
import type { MachineLoad } from '../suite/judge-suites.ts'
|
|
15
22
|
import { PARALLEL_ENV, parallelCap, parallelCapInfo } from './parallel.ts'
|
|
16
23
|
|
|
17
24
|
/** where the slots live, for a test that must not touch the machine's pool */
|
|
@@ -19,7 +26,9 @@ export const SLOTS_DIR_ENV = 'SHIPKIT_SLOTS_DIR'
|
|
|
19
26
|
/** set for every process a check spawns: it runs on its parent's slot */
|
|
20
27
|
export const SLOT_HELD_ENV = 'SHIPKIT_SLOT_HELD'
|
|
21
28
|
|
|
22
|
-
|
|
29
|
+
/** `load`: the reading that scales what the pool admits. Omitted: the whole
|
|
30
|
+
* size, whatever the machine runs (a test's own pool) */
|
|
31
|
+
export type SlotPool = { dir: string; size: number; load?: () => MachineLoad }
|
|
23
32
|
|
|
24
33
|
export type SlotHolder = { slot: number; pid: number; repo: string; check: string; since: number }
|
|
25
34
|
|
|
@@ -37,9 +46,18 @@ export const autoGatePool = (): SlotPool => ({ dir: `${poolDir()}-auto-gate`, si
|
|
|
37
46
|
|
|
38
47
|
/** this process's pool, or null when it runs inside a check that holds one */
|
|
39
48
|
export function machinePool(): SlotPool | null {
|
|
40
|
-
return process.env[SLOT_HELD_ENV] === '1' ? null : { dir: poolDir(), size: parallelCap() }
|
|
49
|
+
return process.env[SLOT_HELD_ENV] === '1' ? null : { dir: poolDir(), size: parallelCap(), load: machineLoad }
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
/** how many of `size` slots a machine at this load admits: all of them up to
|
|
53
|
+
* a load of one per core, then size × cores / load, never below one. At 20
|
|
54
|
+
* on 8 cores a pool of 8 admits 3, at 81 it admits 1 */
|
|
55
|
+
export const admittedSlots = (size: number, m: MachineLoad): number =>
|
|
56
|
+
Math.max(1, Math.min(size, Math.floor((size * m.cores) / Math.max(m.cores, m.load1))))
|
|
57
|
+
|
|
58
|
+
const admitWords = (size: number, admitted: number, m: MachineLoad): string =>
|
|
59
|
+
`the pool admits ${admitted} of its ${size} slots at load ${m.load1.toFixed(1)} on ${m.cores} cores`
|
|
60
|
+
|
|
43
61
|
/** who holds which slot right now, dead holders left out. Every slot file in
|
|
44
62
|
* the dir is read, so a slot taken under a larger size still shows. */
|
|
45
63
|
export function poolOccupancy(pool: SlotPool = { dir: poolDir(), size: parallelCap() }): { size: number; dir: string; held: SlotHolder[] } {
|
|
@@ -64,25 +82,37 @@ export function poolLines(): string[] {
|
|
|
64
82
|
...(info.problem == null ? [] : [`WARN ${info.problem}`]),
|
|
65
83
|
pool == null
|
|
66
84
|
? `ok: parallel: inside a check that holds a machine slot, so this run takes none (up to ${info.cap} at once in this process)`
|
|
67
|
-
: `ok: parallel: one machine pool of ${info.cap} slot(s) shared by every shipkit process, ${poolOccupancy(pool).held.length} held now (${why})`,
|
|
85
|
+
: `ok: parallel: one machine pool of ${info.cap} slot(s) shared by every shipkit process, ${poolOccupancy(pool).held.length} held now (${why})${loadNote(pool)}`,
|
|
68
86
|
]
|
|
69
87
|
}
|
|
70
88
|
|
|
89
|
+
const loadNote = (pool: SlotPool): string => {
|
|
90
|
+
const m = pool.load?.()
|
|
91
|
+
if (m == null) return ''
|
|
92
|
+
const admitted = admittedSlots(pool.size, m)
|
|
93
|
+
return admitted < pool.size ? `; ${admitWords(pool.size, admitted, m)}` : ''
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
/** wait for a free slot and hold it until release(). null = abort said stop. */
|
|
72
97
|
export async function takeSlot(
|
|
73
98
|
pool: SlotPool,
|
|
74
99
|
who: { repo: string; check: string },
|
|
75
|
-
o: { abort?: () => boolean; pollMs?: number; onWait?: (held: SlotHolder[]) => void } = {},
|
|
100
|
+
o: { abort?: () => boolean; pollMs?: number; onWait?: (held: SlotHolder[], why: string) => void } = {},
|
|
76
101
|
): Promise<{ slot: number; release: () => void } | null> {
|
|
77
102
|
mkdirSync(pool.dir, { recursive: true })
|
|
78
103
|
let heard = false
|
|
79
104
|
for (;;) {
|
|
80
|
-
|
|
105
|
+
const m = pool.load?.() ?? null
|
|
106
|
+
const admitted = m == null ? pool.size : admittedSlots(pool.size, m)
|
|
107
|
+
for (let slot = 0; slot < admitted; slot++) {
|
|
81
108
|
const lease = tryLease(join(pool.dir, `slot-${slot}`), who.check, { repo: who.repo, check: who.check })
|
|
82
109
|
if (lease != null) return { slot, release: lease.release }
|
|
83
110
|
}
|
|
84
111
|
if (o.abort?.() === true) return null
|
|
85
|
-
if (!heard) {
|
|
112
|
+
if (!heard) {
|
|
113
|
+
heard = true
|
|
114
|
+
o.onWait?.(poolOccupancy(pool).held, m != null && admitted < pool.size ? admitWords(pool.size, admitted, m) : `all ${pool.size} slots are held`)
|
|
115
|
+
}
|
|
86
116
|
await Bun.sleep(o.pollMs ?? SLOT_POLL_MS)
|
|
87
117
|
}
|
|
88
118
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// the disk half of core/vendored.ts: a repo's vendored dirs, read off its
|
|
2
|
+
// config text, remembered per root while the file holds still.
|
|
3
|
+
import { existsSync, readFileSync, statSync } from 'fs'
|
|
4
|
+
import { dirname, join, resolve } from 'path'
|
|
5
|
+
import { vendoredOfText } from './vendored.ts'
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILE = 'repo.config.ts'
|
|
8
|
+
|
|
9
|
+
const memo = new Map<string, { stamp: string; dirs: string[] }>()
|
|
10
|
+
|
|
11
|
+
/** the vendored dirs of the repo at `root`, from its config text. Empty with
|
|
12
|
+
* no config. Remembered per root while the file's size and mtime hold */
|
|
13
|
+
export function readVendored(root: string): string[] {
|
|
14
|
+
const path = join(root, CONFIG_FILE)
|
|
15
|
+
let stamp: string
|
|
16
|
+
try {
|
|
17
|
+
const s = statSync(path)
|
|
18
|
+
stamp = `${s.size}:${s.mtimeMs}`
|
|
19
|
+
} catch {
|
|
20
|
+
return []
|
|
21
|
+
}
|
|
22
|
+
const hit = memo.get(root)
|
|
23
|
+
if (hit?.stamp === stamp) return hit.dirs
|
|
24
|
+
const dirs = vendoredOfText(readFileSync(path, 'utf8'))
|
|
25
|
+
memo.set(root, { stamp, dirs })
|
|
26
|
+
return dirs
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** the repo a dir sits in (the nearest dir up holding repo.config.ts) and its
|
|
30
|
+
* vendored dirs, null outside any shipkit repo */
|
|
31
|
+
export function vendoredAround(dir: string): { root: string; dirs: string[] } | null {
|
|
32
|
+
let cur = resolve(dir)
|
|
33
|
+
for (;;) {
|
|
34
|
+
if (existsSync(join(cur, CONFIG_FILE))) return { root: cur, dirs: readVendored(cur) }
|
|
35
|
+
const up = dirname(cur)
|
|
36
|
+
if (up === cur) return null
|
|
37
|
+
cur = up
|
|
38
|
+
}
|
|
39
|
+
}
|