rvlib-shipkit 1.9.0 → 2.0.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 +38 -0
- package/package.json +1 -1
- 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 +3 -3
- 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/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 +19 -6
- package/src/core/config-rewrite.ts +27 -6
- package/src/core/discover-checks.ts +30 -10
- package/src/core/floor-resolve.ts +16 -7
- 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 +1 -1
- 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 +16 -10
- 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 +1 -1
- 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/fleet/update-fleet.ts +11 -2
- package/src/git-bootstrap.ts +1 -1
- package/src/home/attach.ts +3 -5
- package/src/home/insights.ts +6 -1
- package/src/home/pm-config.ts +2 -2
- 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/problems/extract.ts +27 -5
- package/src/problems/problem-line.ts +2 -3
- package/src/release/run-release.ts +13 -8
- package/src/repo-config.ts +44 -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 +0 -3
- package/src/serve/serve-hub.ts +1 -16
- package/src/serve/tracked-watch.ts +1 -1
- package/src/start-repo.ts +15 -3
- package/src/suite/judge-suites.ts +29 -28
- package/src/suite/select.ts +2 -0
- package/src/suite/suite-cli.ts +6 -3
- 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/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
|
@@ -137,18 +137,22 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
137
137
|
// what the reader never acts on: kept for STATUS.md and --verbose, never
|
|
138
138
|
// printed on a default run, where every line costs a reader tokens
|
|
139
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)
|
|
140
144
|
let c = o.config ?? (await loadRepoConfig(root))
|
|
141
145
|
// mechanical failures are not information, so they never cost a round trip
|
|
142
146
|
let fixFailed = false
|
|
143
|
-
// a NARROWED run repairs nothing unless --fix asks, and even then only
|
|
144
|
-
//
|
|
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
|
|
145
150
|
// question its name promises without leaving files in the agent's diff
|
|
146
|
-
// that it never wrote: in a shared tree
|
|
147
|
-
// agent's files too.
|
|
148
|
-
// repo config, and arming rewrites hooks/: the full gate's price alone
|
|
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
|
|
149
153
|
const narrowed = (o.onlyIds ?? []).length > 0
|
|
150
154
|
if (fixPassRuns(o)) {
|
|
151
|
-
const pass = await runFixPass(root, c, { rules:
|
|
155
|
+
const pass = await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink, detail, ...(narrowed ? { only: o.onlyIds } : {}) })
|
|
152
156
|
fixFailed = pass.failed
|
|
153
157
|
// a fixer that rewrote the config or moved a check file leaves the
|
|
154
158
|
// config above describing a repo that is gone, so the gate reads the
|
|
@@ -179,7 +183,9 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
179
183
|
// the harness refused it the raw runner, so the line says what to add
|
|
180
184
|
// rather than leaving it with `unknown check id` and no next move
|
|
181
185
|
if ((o.onlyIds ?? []).includes(u.id))
|
|
182
|
-
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)`)
|
|
183
189
|
}
|
|
184
190
|
// the run record: one run id for every check of this gate, so a breakdown
|
|
185
191
|
// of the wall time is reconstructable. It is never read here, only written
|
|
@@ -252,7 +258,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
252
258
|
runLog,
|
|
253
259
|
onCheck: o.onCheck,
|
|
254
260
|
reportHome: false,
|
|
255
|
-
print:
|
|
261
|
+
print: checkLine,
|
|
256
262
|
detail,
|
|
257
263
|
})
|
|
258
264
|
// what the suite ACTUALLY ran, from its own headline. A flag that was set
|
|
@@ -289,10 +295,10 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
289
295
|
for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
|
|
290
296
|
// why every check that did not replay ran, grouped: a re-run nobody
|
|
291
297
|
// can explain costs a reader more than the line does
|
|
292
|
-
for (const { line
|
|
298
|
+
for (const { line } of ledger.explain()) detail(formatLine(line, false))
|
|
293
299
|
}
|
|
294
300
|
// one write for the whole gate, after everything has finished
|
|
295
|
-
for (const l of runLog.flush())
|
|
301
|
+
for (const l of runLog.flush()) checkLine(formatLine(l, false))
|
|
296
302
|
const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownOff.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
|
|
297
303
|
// THE VERDICT, one line and only on a pass: a red run already said so in
|
|
298
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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// holder died is free at once, and a live holder heartbeats it.
|
|
6
6
|
//
|
|
7
7
|
// A slot is a check, and a check is often a whole multi core tool (bun test
|
|
8
|
-
// workers,
|
|
8
|
+
// workers, linter threads, tsc), so slots alone never bound the CPU. Past the
|
|
9
9
|
// cores the pool admits fewer slots in proportion to the 1 minute load, never
|
|
10
10
|
// fewer than one (admittedSlots). A slot already held is never taken back.
|
|
11
11
|
//
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `vendored` in repo.config.ts: repo-relative dirs of THIRD PARTY code (a git
|
|
2
|
+
// subtree, a copied library) that no shipkit tool lints, formats, rewrites or
|
|
3
|
+
// analyses as the repo's own source. Read as TEXT, never by loading the
|
|
4
|
+
// config: the source-graph cli, the md scan and the file listings run where
|
|
5
|
+
// the config is never loaded, and a prefix test per path is the whole cost.
|
|
6
|
+
// loadRepoConfig refuses a config whose loaded value and text disagree.
|
|
7
|
+
// Pure, so a check's closure can import it: the disk half is vendored-read.ts.
|
|
8
|
+
//
|
|
9
|
+
// the one constraint a reader breaks first: cache keys and watchers still
|
|
10
|
+
// read vendored files. Vendored code is still an INPUT to builds and tests;
|
|
11
|
+
// only the tools that treat the tree as the repo's own source omit it.
|
|
12
|
+
import { configDepths } from './identity-color.ts'
|
|
13
|
+
|
|
14
|
+
const CONFIG_FILE = 'repo.config.ts'
|
|
15
|
+
|
|
16
|
+
const KEY_RE = /(?:^|[{,])\s*(['"]?)vendored\1\s*:\s*/gm
|
|
17
|
+
|
|
18
|
+
/** an entry as every tool compares it: posix, no `./`, no trailing `/` */
|
|
19
|
+
export const normalizeVendored = (d: string): string => d.replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/+$/, '')
|
|
20
|
+
|
|
21
|
+
/** why an entry cannot be a vendored dir, null when it can */
|
|
22
|
+
export function vendoredProblem(entry: unknown): string | null {
|
|
23
|
+
if (typeof entry !== 'string' || entry.trim() === '') return 'is not a repo-relative dir path'
|
|
24
|
+
const d = normalizeVendored(entry)
|
|
25
|
+
if (d === '' || d === '.') return 'is the repo root: it would omit the whole repo from every tool'
|
|
26
|
+
if (entry.startsWith('/') || d.split('/').includes('..')) return 'leaves the repo: name a dir inside it'
|
|
27
|
+
if (/[*?[\]{}!]/.test(d)) return 'is a glob: name the dir itself, every path under it is omitted'
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** the `vendored` entries the config text declares, normalized. Empty when
|
|
32
|
+
* the key is absent. Throws when the value is anything but a literal array
|
|
33
|
+
* of string literals, which a reader of the text cannot evaluate */
|
|
34
|
+
export function vendoredOfText(text: string): string[] {
|
|
35
|
+
const depth = configDepths(text)
|
|
36
|
+
const found: string[][] = []
|
|
37
|
+
for (const m of text.matchAll(KEY_RE)) {
|
|
38
|
+
const sep = /^[{,]/.test(m[0]) ? m.index : m.index - 1
|
|
39
|
+
const colon = m.index + m[0].lastIndexOf(':')
|
|
40
|
+
if (sep < 0 || depth[sep] !== 1 || depth[colon] !== 1) continue
|
|
41
|
+
const open = m.index + m[0].length
|
|
42
|
+
if (text[open] !== '[') throw new Error(`${CONFIG_FILE}: \`vendored\` must be a literal array of strings, the tools that never load the config read it as text`)
|
|
43
|
+
let close = open + 1
|
|
44
|
+
while (close < text.length && !(text[close] === ']' && depth[close] === 1)) close++
|
|
45
|
+
if (close >= text.length) throw new Error(`${CONFIG_FILE}: \`vendored\` has no closing \`]\``)
|
|
46
|
+
const body = text.slice(open + 1, close).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
|
|
47
|
+
const items: string[] = []
|
|
48
|
+
const rest = body.replace(/(['"])((?:\\.|(?!\1).)*)\1/g, (_, _q: string, s: string) => { items.push(s); return '' })
|
|
49
|
+
if (!/^[\s,]*$/.test(rest)) throw new Error(`${CONFIG_FILE}: \`vendored\` must list string literals only, the tools that never load the config read it as text`)
|
|
50
|
+
found.push(items.map(normalizeVendored))
|
|
51
|
+
}
|
|
52
|
+
if (found.length > 1) throw new Error(`${CONFIG_FILE} declares \`vendored\` more than once`)
|
|
53
|
+
return found[0] ?? []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** is this repo-relative path a vendored dir or inside one */
|
|
57
|
+
export function isVendored(rel: string, dirs: readonly string[]): boolean {
|
|
58
|
+
if (dirs.length === 0) return false
|
|
59
|
+
const n = rel.replace(/\\/g, '/').replace(/^(\.\/)+/, '')
|
|
60
|
+
return dirs.some((d) => n === d || n.startsWith(`${d}/`))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** the glob's fixed leading dirs, before its first wildcard */
|
|
64
|
+
const globBase = (glob: string): string => {
|
|
65
|
+
const segs = glob.replace(/^(\.\/)+/, '').split('/')
|
|
66
|
+
const fixed: string[] = []
|
|
67
|
+
for (const s of segs.slice(0, -1)) {
|
|
68
|
+
if (/[*?[\]{}!]/.test(s)) break
|
|
69
|
+
fixed.push(s)
|
|
70
|
+
}
|
|
71
|
+
return fixed.join('/')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** a listing with the vendored files left out, unless the glob itself names a
|
|
75
|
+
* path inside a vendored dir: a check that asks for `vendor/lib/src/**`
|
|
76
|
+
* wants exactly those files */
|
|
77
|
+
export function listingWithoutVendored(files: readonly string[], glob: string, dirs: readonly string[]): string[] {
|
|
78
|
+
if (dirs.length === 0) return [...files]
|
|
79
|
+
const base = globBase(glob)
|
|
80
|
+
if (base !== '' && isVendored(base, dirs)) return [...files]
|
|
81
|
+
return files.filter((f) => !isVendored(f, dirs))
|
|
82
|
+
}
|