rvlib-shipkit 0.32.0 → 0.34.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 +27 -0
- package/package.json +4 -1
- package/src/asks/asks-queue.ts +15 -2
- package/src/cli/cmd_Asks.ts +2 -1
- package/src/cli/cmd_Keys.ts +107 -0
- package/src/cli.ts +2 -0
- package/src/core/canonical-scripts.ts +3 -1
- package/src/core/check-suite.ts +95 -33
- package/src/core/discover-checks.ts +30 -1
- package/src/core/floor.ts +21 -2
- package/src/core/locked-write.ts +170 -1
- package/src/core/one-way-in-policy.ts +183 -0
- package/src/core/run-ci.ts +42 -3
- package/src/core/run-fixers.ts +61 -18
- package/src/core/task-runner.ts +55 -6
- package/src/hooks/one-way-in-guard.ts +39 -0
- package/src/hooks/run-hooks.ts +23 -2
- package/src/init-repo.ts +19 -1
- package/src/ledger/gate-units.ts +58 -0
- package/src/ledger/git-key.ts +330 -0
- package/src/ledger/git-scan.ts +314 -0
- package/src/ledger/toolchain-identity.ts +15 -8
- package/src/release/run-bump.ts +50 -5
- package/src/release/unit-gate.ts +17 -4
- package/src/rules/SK001-package-json-shape.ts +10 -3
- package/src/rules/SK002-contracts-fulfillness.ts +4 -0
- package/src/rules/SK003-claude-md-shape.ts +7 -2
- package/src/rules/SK004-gitignore-policy.ts +11 -2
- package/src/rules/SK005-rvlib-versions.ts +28 -1
- package/src/rules/SK006-npm-publish-safety.ts +3 -0
- package/src/rules/SK008-reviewer-agent.ts +6 -2
- package/src/rules/SK009-commit-ai-tells.ts +3 -0
- package/src/rules/SK010-undeclared-release-unit.ts +2 -0
- package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
- package/src/rules/SK012-config-barrel-import.ts +6 -2
- package/src/rules/SK013-one-way-in-policy.ts +156 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/rules/rule.ts +22 -1
- package/src/rules/run-fix.ts +5 -3
- package/src/scaffold/files/claude-settings-local.json +0 -3
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/ts/config.ts +66 -6
- package/src/ts/cycles.ts +7 -92
- package/src/ts/source-graph.ts +158 -0
- package/src/ts/tsconfig-projects.ts +101 -0
- package/src/ts/typecheck-cli.ts +46 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// WHAT IS ON DISK RIGHT NOW, as git object ids, using git's own index.
|
|
2
|
+
//
|
|
3
|
+
// A cache key needs the identity of the files a unit reads, INCLUDING files
|
|
4
|
+
// that are untracked or modified. `git hash-object` per file is O(files) and
|
|
5
|
+
// re-reads everything; git's index already carries a stat cache that answers
|
|
6
|
+
// "unchanged" without reading the blob. So: a scratch index of our own, per
|
|
7
|
+
// path adds, one `write-tree`, one `cat-file --batch-check`.
|
|
8
|
+
//
|
|
9
|
+
// FOUR THINGS THIS FILE EXISTS TO GET RIGHT, each reproduced before it was
|
|
10
|
+
// written:
|
|
11
|
+
//
|
|
12
|
+
// 1. PER PATH ADDS, EXIT CODE CHECKED. `git add -A -- a.txt missing/` exits
|
|
13
|
+
// 128, stages NOTHING, and `write-tree` then hands back the tree of the
|
|
14
|
+
// LAST successful write. Measured on a scratch repo: the batched form
|
|
15
|
+
// returned HEAD's tree while a.txt was modified on disk, so every key
|
|
16
|
+
// derived from it described a file nobody had. One add per path turns that
|
|
17
|
+
// into one loud failure on one path.
|
|
18
|
+
// 2. A MISSING DECLARED PATH IS NOT A GIT ERROR. A repo without `tests/` is
|
|
19
|
+
// normal, and hard-failing the gate on it would be wrong, so a path that
|
|
20
|
+
// does not exist never reaches `git add`: it comes back in `missing` and
|
|
21
|
+
// the caller refuses to cache that unit. That keeps rule 1's hard failure
|
|
22
|
+
// for what it is actually about, git going wrong on a path that IS there.
|
|
23
|
+
// 3. ONLY AN IGNORED PATH IS FORCE ADDED. `git add -Af` on a broad path force
|
|
24
|
+
// adds node_modules and costs 17.7s; the ignored paths that genuinely hold
|
|
25
|
+
// declared inputs here (.rv-social/patreon/, .rv-assets/out/) cost 76ms.
|
|
26
|
+
// One `ls-files --others -i --directory` names them, and node_modules is
|
|
27
|
+
// refused force-add at any depth.
|
|
28
|
+
// 4. THE SCRATCH INDEX IS SHARED, SO IT IS CONTENDED. Measured: 10 parallel
|
|
29
|
+
// `git add` on one index file, 9 fail with `Unable to create …lock`. Three
|
|
30
|
+
// agents in one checkout is the normal case here, so lock contention is
|
|
31
|
+
// retried and only a NON lock failure is fatal.
|
|
32
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
33
|
+
import { isAbsolute, join, normalize } from 'path'
|
|
34
|
+
|
|
35
|
+
/** what a scan measured. Every OID is a git object id of the CURRENT disk
|
|
36
|
+
* content, tracked or not. */
|
|
37
|
+
export type ScanResult = {
|
|
38
|
+
/** root tree of the scratch index after the adds */
|
|
39
|
+
treeOid: string
|
|
40
|
+
/** repo-relative path → git object id (tree for a dir, blob for a file) */
|
|
41
|
+
oidByPath: Map<string, string>
|
|
42
|
+
/** declared paths that are not on disk — the caller must not cache them */
|
|
43
|
+
missing: string[]
|
|
44
|
+
/** repo-relative path → ignored content under it that is NOT in the key
|
|
45
|
+
* (node_modules only, by policy: toolchain-identity.ts owns installs) */
|
|
46
|
+
excluded: Map<string, string[]>
|
|
47
|
+
/** wall time of the whole scan */
|
|
48
|
+
ms: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class GitScanError extends Error {}
|
|
52
|
+
|
|
53
|
+
/** the scratch index FORMAT. Bump it whenever what the scan stages changes.
|
|
54
|
+
* The index is persistent, and anything staged into it stays there until
|
|
55
|
+
* something stages over it: a run that force-added a path a later version no
|
|
56
|
+
* longer touches would keep serving that path's content forever, frozen at
|
|
57
|
+
* the moment of the bad run. A version stamp beside the file turns that into
|
|
58
|
+
* one discarded index instead of a permanently wrong key. */
|
|
59
|
+
export const SCAN_INDEX_VERSION = 2
|
|
60
|
+
|
|
61
|
+
const LOCK_RE = /Unable to create '.*\.lock'|index\.lock/i
|
|
62
|
+
const RETRIES = 12
|
|
63
|
+
|
|
64
|
+
const git = (root: string, args: string[], indexFile: string): { code: number; out: string } => {
|
|
65
|
+
const p = Bun.spawnSync(['git', ...args], {
|
|
66
|
+
cwd: root,
|
|
67
|
+
stdout: 'pipe',
|
|
68
|
+
stderr: 'pipe',
|
|
69
|
+
env: { ...process.env, GIT_INDEX_FILE: indexFile },
|
|
70
|
+
})
|
|
71
|
+
return { code: p.exitCode, out: `${p.stdout.toString()}${p.stderr.toString()}` }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** one git call that MUST succeed. Lock contention is retried (three agents in
|
|
75
|
+
* one checkout is normal); anything else throws with git's own words. */
|
|
76
|
+
function gitOrThrow(root: string, args: string[], indexFile: string): string {
|
|
77
|
+
let last = { code: -1, out: '' }
|
|
78
|
+
for (let attempt = 0; attempt < RETRIES; attempt++) {
|
|
79
|
+
last = git(root, args, indexFile)
|
|
80
|
+
if (last.code === 0) return last.out
|
|
81
|
+
if (!LOCK_RE.test(last.out)) break
|
|
82
|
+
Bun.sleepSync(10 + attempt * 10)
|
|
83
|
+
}
|
|
84
|
+
throw new GitScanError(`git ${args.join(' ')} exited ${last.code}: ${last.out.trim().split('\n').slice(0, 3).join(' / ')}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** the THREE index files a scan uses, and why it is three and not one.
|
|
88
|
+
*
|
|
89
|
+
* SCRATCH is where the plain adds go, and it is COPIED FRESH from the repo's
|
|
90
|
+
* real index on every scan. Keeping it across runs was the obvious design and
|
|
91
|
+
* it was wrong twice, both measured. Anything staged into an index stays
|
|
92
|
+
* staged, so a path force-added by one run rode in every later run's tree,
|
|
93
|
+
* which made the whole-tree id a function of this machine's scan history and
|
|
94
|
+
* reproducible on no other checkout. And a file that stopped being tracked
|
|
95
|
+
* (`git rm --cached`) stayed in the scratch index, so the tree still carried
|
|
96
|
+
* it and a rule keyed on tracked-ness saw nothing change. The copy costs
|
|
97
|
+
* about 1ms and the real index is already warm, so the whole reset plus
|
|
98
|
+
* `git add -- .` plus `write-tree` measures 30 to 40ms, FASTER than the
|
|
99
|
+
* persistent index it replaces.
|
|
100
|
+
*
|
|
101
|
+
* IGNORED is a separate index for the force-adds, so a path one unit named
|
|
102
|
+
* can never leak into the whole-tree id another unit is keyed on. Without the
|
|
103
|
+
* split, one unit's key changed depending on which OTHER units were in the
|
|
104
|
+
* run, which is exactly the silently narrowed key the excluded list exists to
|
|
105
|
+
* prevent. It stays warm across runs: an entry left in it can only affect the
|
|
106
|
+
* path it belongs to, never the root tree.
|
|
107
|
+
*
|
|
108
|
+
* REAL is read, never written, and only to answer "does git ignore this".
|
|
109
|
+
* `ls-files --others` means UNTRACKED, so asking an index we force-add into
|
|
110
|
+
* reports a force-added path as no longer ignored, and the next run then
|
|
111
|
+
* plain-adds it, which exits 1 and takes the scan down. */
|
|
112
|
+
export function indexPaths(root: string): { scratch: string; ignored: string; real: string } {
|
|
113
|
+
const p = Bun.spawnSync(['git', 'rev-parse', '--absolute-git-dir'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
114
|
+
if (p.exitCode !== 0) throw new GitScanError(`not a git repository: ${root} (${p.stderr.toString().trim()})`)
|
|
115
|
+
const gitDir = p.stdout.toString().trim()
|
|
116
|
+
const dir = join(gitDir, 'shipkit')
|
|
117
|
+
mkdirSync(dir, { recursive: true })
|
|
118
|
+
const real = join(gitDir, 'index')
|
|
119
|
+
const ignored = join(dir, 'scan-ignored.idx')
|
|
120
|
+
const stamp = join(dir, 'scan.idx.version')
|
|
121
|
+
const seen = ((): string => { try { return readFileSync(stamp, 'utf8').trim() } catch { return '' } })()
|
|
122
|
+
if (seen !== String(SCAN_INDEX_VERSION)) {
|
|
123
|
+
try { rmSync(ignored, { force: true }) } catch {}
|
|
124
|
+
try { writeFileSync(stamp, `${SCAN_INDEX_VERSION}\n`) } catch {}
|
|
125
|
+
}
|
|
126
|
+
// per-process scratch: it is rewritten from `real` every scan anyway, so
|
|
127
|
+
// sharing one file across concurrent runners buys no warmth and only adds
|
|
128
|
+
// lock contention
|
|
129
|
+
const scratch = join(dir, `scan-${process.pid}.idx`)
|
|
130
|
+
// a run that was killed leaves its file behind, and pids are reused, so
|
|
131
|
+
// sweep anything an hour old rather than trusting the pid to be free
|
|
132
|
+
try {
|
|
133
|
+
const cutoff = Date.now() - 3_600_000
|
|
134
|
+
for (const f of readdirSync(dir))
|
|
135
|
+
if (/^scan-\d+\.idx(\.tmp)?$/.test(f) && statSync(join(dir, f)).mtimeMs < cutoff) rmSync(join(dir, f), { force: true })
|
|
136
|
+
} catch {}
|
|
137
|
+
// tmp + rename: a half-copied index is a corrupt index, and git's error
|
|
138
|
+
// for that is not the one the retry loop is meant to absorb
|
|
139
|
+
try {
|
|
140
|
+
rmSync(scratch, { force: true })
|
|
141
|
+
if (existsSync(real)) {
|
|
142
|
+
const tmp = `${scratch}.tmp`
|
|
143
|
+
copyFileSync(real, tmp)
|
|
144
|
+
renameSync(tmp, scratch)
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// no seed is survivable: the adds are just colder
|
|
148
|
+
}
|
|
149
|
+
return { scratch, ignored, real }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** the whole work tree as one input. A rule that walks the repo (SK001's test
|
|
153
|
+
* file probe, SK010's `git ls-files`) genuinely reads all of it, and saying so
|
|
154
|
+
* is more honest than naming three of the files it happens to open. Its object
|
|
155
|
+
* id is the scan's root tree, so it moves on any edit anywhere. */
|
|
156
|
+
export const WHOLE_TREE = '.'
|
|
157
|
+
|
|
158
|
+
/** repo-relative posix, no './', no trailing slash. Throws on anything that
|
|
159
|
+
* escapes the repo — a key component outside the tree is a key nobody else
|
|
160
|
+
* can reproduce. */
|
|
161
|
+
export function normalizeInput(p: string): string {
|
|
162
|
+
const n = normalize(p).replace(/\\/g, '/').replace(/\/+$/, '').replace(/^\.\//, '')
|
|
163
|
+
if (n === '.' || n === '') return WHOLE_TREE
|
|
164
|
+
if (isAbsolute(p) || n === '..' || n.startsWith('../'))
|
|
165
|
+
throw new GitScanError(`input path '${p}' is outside the repo — declare a path inside it, or '.' for the whole work tree`)
|
|
166
|
+
return n
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** drop paths already covered by an ancestor in the same set: adding `wiki`
|
|
170
|
+
* stages `wiki/build/...` too, and a second add of the child is pure cost. */
|
|
171
|
+
export function dropCovered(paths: string[]): string[] {
|
|
172
|
+
if (paths.includes(WHOLE_TREE)) return [WHOLE_TREE]
|
|
173
|
+
const sorted = [...new Set(paths)].sort()
|
|
174
|
+
const kept: string[] = []
|
|
175
|
+
for (const p of sorted) if (!kept.some((k) => p === k || p.startsWith(`${k}/`))) kept.push(p)
|
|
176
|
+
return kept
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const underNodeModules = (p: string): boolean => p.split('/').includes('node_modules')
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* stage `paths` into the scratch index and read back one git object id each.
|
|
183
|
+
*
|
|
184
|
+
* Throws `GitScanError` on any git failure that is not lock contention. The
|
|
185
|
+
* caller's contract on a throw is absolute: produce NO key. A tree that may be
|
|
186
|
+
* stale describes files nobody has, and a key built on it is a false green
|
|
187
|
+
* waiting for its first hit.
|
|
188
|
+
*/
|
|
189
|
+
export function scanPaths(root: string, rawPaths: string[]): ScanResult {
|
|
190
|
+
const started = performance.now()
|
|
191
|
+
const { scratch: indexFile, ignored: ignoredIndex, real: realIndex } = indexPaths(root)
|
|
192
|
+
const wanted = [...new Set(rawPaths.map(normalizeInput))].sort()
|
|
193
|
+
const missing = wanted.filter((p) => !existsSync(join(root, p)))
|
|
194
|
+
const present = wanted.filter((p) => existsSync(join(root, p)))
|
|
195
|
+
const toAdd = dropCovered(present)
|
|
196
|
+
const named = wanted.filter((p) => p !== WHOLE_TREE)
|
|
197
|
+
|
|
198
|
+
// ignored content under what we are about to stage. One call, and it also
|
|
199
|
+
// answers "is this declared path itself ignored": `--directory` collapses
|
|
200
|
+
// a dir whose ENTIRE content is ignored, so the answer comes back as the
|
|
201
|
+
// dir with a trailing slash.
|
|
202
|
+
const ignored = toAdd.length === 0
|
|
203
|
+
? []
|
|
204
|
+
: gitOrThrow(root, ['ls-files', '--others', '-i', '--exclude-standard', '--directory', '--', ...toAdd], realIndex)
|
|
205
|
+
.split('\n')
|
|
206
|
+
.map((l) => l.trim().replace(/\/+$/, ''))
|
|
207
|
+
.filter((l) => l !== '')
|
|
208
|
+
// ONLY a path a declaration NAMES is force-added. Ignored content that
|
|
209
|
+
// merely sits under a declared path is reported and left out.
|
|
210
|
+
//
|
|
211
|
+
// This is the whole of defense 3, and the wide reading of it is a disaster
|
|
212
|
+
// measured twice: with `.` declared (which every repo-walking rule
|
|
213
|
+
// declares), "ignored content under a declared path" is every ignored path
|
|
214
|
+
// in the repo, so one run force-staged two entire worktrees, the log dir,
|
|
215
|
+
// the private model dumps and the prompt corpora, for 680ms. Naming the
|
|
216
|
+
// path is what makes it an input.
|
|
217
|
+
//
|
|
218
|
+
// Installs are refused on top of that, at any depth AND as an ancestor:
|
|
219
|
+
// `--directory` collapses a dir whose entire content is ignored, so
|
|
220
|
+
// force-adding that one entry stages the node_modules inside it.
|
|
221
|
+
const declared = new Set(wanted)
|
|
222
|
+
const denied = ignored.filter(underNodeModules)
|
|
223
|
+
const forceAdd = ignored.filter((p) => declared.has(p) && !underNodeModules(p) && !denied.some((d) => d.startsWith(`${p}/`)))
|
|
224
|
+
// a path git itself calls ignored must NOT get the plain add: `git add --
|
|
225
|
+
// <ignored>` exits 1 and takes the whole scan down with it
|
|
226
|
+
const ignoredSet = new Set(ignored)
|
|
227
|
+
for (const p of toAdd) if (!ignoredSet.has(p)) gitOrThrow(root, ['add', '--', p], indexFile)
|
|
228
|
+
// force-adds land in their OWN index, so a path one unit named can never
|
|
229
|
+
// ride inside the whole-tree id another unit is keyed on
|
|
230
|
+
for (const p of forceAdd) gitOrThrow(root, ['add', '-Af', '--', p], ignoredIndex)
|
|
231
|
+
|
|
232
|
+
const treeOid = gitOrThrow(root, ['write-tree'], indexFile).trim()
|
|
233
|
+
const oidByPath = new Map<string, string>()
|
|
234
|
+
// the work tree's own id IS the tree the adds just produced — there is no
|
|
235
|
+
// `<tree>:` path to ask git for
|
|
236
|
+
if (wanted.includes(WHOLE_TREE)) oidByPath.set(WHOLE_TREE, treeOid)
|
|
237
|
+
const forceSet = new Set(forceAdd)
|
|
238
|
+
const plain = named.filter((p) => !forceSet.has(p))
|
|
239
|
+
for (const [tree, paths, index] of [[treeOid, plain, indexFile], [null, forceAdd, ignoredIndex]] as const) {
|
|
240
|
+
if (paths.length === 0) continue
|
|
241
|
+
const root_ = tree ?? gitOrThrow(root, ['write-tree'], index).trim()
|
|
242
|
+
const query = paths.map((p) => `${root_}:${p}`).join('\n')
|
|
243
|
+
const p = Bun.spawnSync(['git', 'cat-file', '--batch-check'], {
|
|
244
|
+
cwd: root,
|
|
245
|
+
stdin: Buffer.from(`${query}\n`),
|
|
246
|
+
stdout: 'pipe',
|
|
247
|
+
stderr: 'pipe',
|
|
248
|
+
env: { ...process.env, GIT_INDEX_FILE: index },
|
|
249
|
+
})
|
|
250
|
+
if (p.exitCode !== 0) throw new GitScanError(`git cat-file --batch-check exited ${p.exitCode}: ${p.stderr.toString().trim()}`)
|
|
251
|
+
const lines = p.stdout.toString().split('\n').filter((l) => l !== '')
|
|
252
|
+
if (lines.length !== paths.length)
|
|
253
|
+
throw new GitScanError(`git cat-file --batch-check answered ${lines.length} of ${paths.length} paths`)
|
|
254
|
+
paths.forEach((path, i) => {
|
|
255
|
+
const oid = lines[i]!.split(' ')[0] ?? ''
|
|
256
|
+
// 'missing' / 'ambiguous' come back in place of an id — a path git
|
|
257
|
+
// cannot name is a path we cannot key on
|
|
258
|
+
if (/^[0-9a-f]{40}$/.test(oid)) oidByPath.set(path, oid)
|
|
259
|
+
else if (!missing.includes(path)) missing.push(path)
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const excluded = new Map<string, string[]>()
|
|
264
|
+
for (const d of ignored) {
|
|
265
|
+
// EVERY owner that covers it, not the first one found, and membership
|
|
266
|
+
// must not depend on what OTHER units declared. `.` sorts first and
|
|
267
|
+
// covers everything, so a `find` here handed the whole exclusion list
|
|
268
|
+
// to the whole-tree unit and left every narrower unit claiming it was
|
|
269
|
+
// blind to nothing. Filtering force-added paths out globally had the
|
|
270
|
+
// same shape: one unit naming `ign` deleted `excluded=ign` from a
|
|
271
|
+
// whole-tree unit that is still blind to it. Measured both ways, one
|
|
272
|
+
// unit producing two keys for one unchanged tree, and the component
|
|
273
|
+
// that vanished is the one that exists to stop a silently narrowed key.
|
|
274
|
+
for (const w of wanted) {
|
|
275
|
+
if (!(w === WHOLE_TREE || d === w || d.startsWith(`${w}/`))) continue
|
|
276
|
+
// the one owner that is NOT blind: it named the path itself, so it
|
|
277
|
+
// got the content through the force-add
|
|
278
|
+
if (d === w && forceSet.has(d)) continue
|
|
279
|
+
excluded.set(w, [...(excluded.get(w) ?? []), d])
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return { treeOid, oidByPath, missing: [...new Set(missing)].sort(), excluded, ms: performance.now() - started }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* blob ids for a list of FILES, one process, no index, no lock.
|
|
287
|
+
*
|
|
288
|
+
* The scratch index is the right tool for a declared input, which is usually a
|
|
289
|
+
* directory and usually unchanged (git's stat cache answers without reading).
|
|
290
|
+
* It is the wrong tool for the module-graph closure: those are individual
|
|
291
|
+
* files, they belong to no declared path, and staging each one would cost a
|
|
292
|
+
* `git add` spawn per file. `hash-object --stdin-paths` reads them all in one
|
|
293
|
+
* call and returns exactly what a blob of that content would be.
|
|
294
|
+
*
|
|
295
|
+
* A file that is not on disk is skipped and reported, never guessed at.
|
|
296
|
+
*/
|
|
297
|
+
export function hashFiles(root: string, files: string[]): { oids: Map<string, string>; missing: string[] } {
|
|
298
|
+
const oids = new Map<string, string>()
|
|
299
|
+
const wanted = [...new Set(files.map(normalizeInput))].sort()
|
|
300
|
+
const missing = wanted.filter((f) => !existsSync(join(root, f)))
|
|
301
|
+
const present = wanted.filter((f) => existsSync(join(root, f)))
|
|
302
|
+
if (present.length === 0) return { oids, missing }
|
|
303
|
+
const p = Bun.spawnSync(['git', 'hash-object', '--stdin-paths'], {
|
|
304
|
+
cwd: root,
|
|
305
|
+
stdin: Buffer.from(`${present.join('\n')}\n`),
|
|
306
|
+
stdout: 'pipe',
|
|
307
|
+
stderr: 'pipe',
|
|
308
|
+
})
|
|
309
|
+
if (p.exitCode !== 0) throw new GitScanError(`git hash-object exited ${p.exitCode}: ${p.stderr.toString().trim().split('\n').slice(0, 3).join(' / ')}`)
|
|
310
|
+
const lines = p.stdout.toString().split('\n').filter((l) => l !== '')
|
|
311
|
+
if (lines.length !== present.length) throw new GitScanError(`git hash-object answered ${lines.length} of ${present.length} files`)
|
|
312
|
+
present.forEach((f, i) => oids.set(f, lines[i]!.trim()))
|
|
313
|
+
return { oids, missing }
|
|
314
|
+
}
|
|
@@ -56,8 +56,8 @@ function installedNames(nm: string): string[] {
|
|
|
56
56
|
return names.sort()
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/** the gate binary's own source, content-hashed.
|
|
60
|
-
*
|
|
59
|
+
/** the gate binary's own source, content-hashed. This is what makes editing
|
|
60
|
+
* shipkit invalidate every key in every repo, in-repo copy or dependency. */
|
|
61
61
|
function hashCliTree(h: ReturnType<typeof createHash>, cli: string): void {
|
|
62
62
|
const srcRoot = resolve(cli, '..')
|
|
63
63
|
const files: string[] = []
|
|
@@ -94,12 +94,19 @@ export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain
|
|
|
94
94
|
// the runtime. Free, and a bun upgrade genuinely changes behaviour
|
|
95
95
|
h.update(`bun=${Bun.version}\n`)
|
|
96
96
|
|
|
97
|
-
// the gate binary
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
97
|
+
// the gate binary, ALWAYS content-hashed, and its path never folded in.
|
|
98
|
+
//
|
|
99
|
+
// The earlier shape wrote `cli=in-repo` for a binary inside the repo and
|
|
100
|
+
// leaned on the git tree key to cover it. Both halves were wrong, and both
|
|
101
|
+
// were measured. In a CONSUMER repo the binary sits in `node_modules`,
|
|
102
|
+
// which is path-inside the root and inside no git tree, so the tree key
|
|
103
|
+
// covered none of it. And between a checkout that HOSTS the binary and a
|
|
104
|
+
// worktree that runs the same one through the global shim, one wrote
|
|
105
|
+
// `in-repo` and the other wrote an absolute path, so two checkouts running
|
|
106
|
+
// one identical program could never agree on a key. Content-hashing costs
|
|
107
|
+
// 3.5 to 9.2ms on a 163-file tree, and it is the only form that is the
|
|
108
|
+
// same value wherever the caller stands.
|
|
109
|
+
hashCliTree(h, realpathSync(cliEntry))
|
|
103
110
|
|
|
104
111
|
// what is installed. The lockfile states the intent; the tree states the fact
|
|
105
112
|
const lock = join(root, 'bun.lock')
|
package/src/release/run-bump.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// every selected unit's package.json and changelog. The publish part is
|
|
27
27
|
// RESUMABLE instead, because an npm publish cannot be undone and a rollback
|
|
28
28
|
// that cannot roll back is a lie.
|
|
29
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
29
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'fs'
|
|
30
30
|
import { homedir } from 'os'
|
|
31
31
|
import { join, relative } from 'path'
|
|
32
32
|
import { declaredKind, hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
|
|
@@ -136,6 +136,26 @@ function mergeIntoReleaseBranch(
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/** where a branch is checked out, or null. `git worktree list` is the only
|
|
140
|
+
* source that sees the OTHER checkouts: a linked worktree and its main
|
|
141
|
+
* checkout share one object store and one branch namespace, and git refuses
|
|
142
|
+
* to check a branch out twice. Three agents in one checkout, one of them in a
|
|
143
|
+
* worktree, is the normal case here, so this is asked before any ref moves. */
|
|
144
|
+
function branchCheckedOutAt(root: string, git: Git, branch: string): string | null {
|
|
145
|
+
const r = git(['worktree', 'list', '--porcelain'])
|
|
146
|
+
if (r.code !== 0) return null
|
|
147
|
+
let path: string | null = null
|
|
148
|
+
for (const line of r.out.split('\n')) {
|
|
149
|
+
if (line.startsWith('worktree ')) path = line.slice('worktree '.length).trim()
|
|
150
|
+
else if (line.trim() === `branch refs/heads/${branch}`) {
|
|
151
|
+
if (path == null) continue
|
|
152
|
+
try { if (realpathSync(path) === realpathSync(root)) return null } catch { /* a stale worktree entry: treat it as foreign */ }
|
|
153
|
+
return path
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
139
159
|
/** the rehearsal's answer to the same two questions, from the object database:
|
|
140
160
|
* has the release branch diverged from its remote, and would the merge
|
|
141
161
|
* conflict. Nothing is checked out and no ref moves, which is the whole point
|
|
@@ -189,6 +209,10 @@ function probeMergeIntoReleaseBranch(
|
|
|
189
209
|
return { refuse: false, inconclusive: stale }
|
|
190
210
|
}
|
|
191
211
|
|
|
212
|
+
/** a rehearsal moves no ref and checks nothing out, so a branch held by
|
|
213
|
+
* another worktree cannot stop it */
|
|
214
|
+
const rehearsalOnly = (o: BumpOpts): boolean => o.dryRun === true
|
|
215
|
+
|
|
192
216
|
type Selected = { unit: ReleaseUnit; version: string; changelogRel: string; kind: BumpKind }
|
|
193
217
|
|
|
194
218
|
export async function runBump(root: string, config: RepoConfig, kind: BumpKind | null, o: BumpOpts = {}): Promise<number> {
|
|
@@ -242,7 +266,11 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
|
|
|
242
266
|
const paths = dirtyPathList(preDirty)
|
|
243
267
|
if (paths.every((p) => machine.has(p))) {
|
|
244
268
|
gitAt(root, ['add', '--', ...paths])
|
|
245
|
-
|
|
269
|
+
// PATHSPEC on the commit too, not just the add. Three agents in
|
|
270
|
+
// one checkout share one index, so a bare `git commit` here takes
|
|
271
|
+
// whatever a neighbour staged a second ago and ships it under a
|
|
272
|
+
// message about machine rows.
|
|
273
|
+
const c = gitAt(root, ['commit', '-m', 'machine rows refresh before release', '--', ...paths])
|
|
246
274
|
if (c.code === 0) print('ok: machine-written rows committed (they re-dirty on every gate run)')
|
|
247
275
|
else print(`WARN machine-row auto-commit failed (exit ${c.code}), the refusal below says what to do:\n${c.out.split('\n').slice(-6).join('\n')}`)
|
|
248
276
|
} else {
|
|
@@ -263,6 +291,20 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
|
|
|
263
291
|
|
|
264
292
|
const wantMerge = cur !== branch && o.noMerge !== true && (config.release?.mergeFromBranch ?? true)
|
|
265
293
|
if (cur !== branch && !wantMerge) { print(`FAIL: releases ship from '${branch}', HEAD is on '${cur}'`); return 1 }
|
|
294
|
+
// the merge CHECKS OUT the release branch here, and git refuses to have
|
|
295
|
+
// one branch checked out in two worktrees. Left to git the run dies on a
|
|
296
|
+
// raw `fatal: 'main' is already used by worktree at …`, which reads like a
|
|
297
|
+
// shipkit bug. Worse, in the other direction, a bump run in the checkout
|
|
298
|
+
// that DOES hold the branch moves the HEAD of an agent working in it.
|
|
299
|
+
// Refuse by name, and say which checkout owns the branch.
|
|
300
|
+
if (wantMerge && !rehearsalOnly(o)) {
|
|
301
|
+
const held = branchCheckedOutAt(root, git, branch)
|
|
302
|
+
if (held != null) {
|
|
303
|
+
print(`FAIL: '${branch}' is checked out at ${held}, so this worktree cannot merge into it`)
|
|
304
|
+
print(` fix: bump from ${held}, or \`shipkit bump --no-merge\` after merging '${cur}' into '${branch}' by hand there`)
|
|
305
|
+
return 1
|
|
306
|
+
}
|
|
307
|
+
}
|
|
266
308
|
const remote = deriveRemote(root)
|
|
267
309
|
if ('error' in remote) { print(`FAIL: ${remote.error}`); return 1 }
|
|
268
310
|
if (wantMerge) print(`note: on '${cur}', so '${cur}' merges into '${branch}' first`)
|
|
@@ -482,12 +524,15 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
|
|
|
482
524
|
}
|
|
483
525
|
}
|
|
484
526
|
|
|
485
|
-
const add = git(['add', ...files])
|
|
527
|
+
const add = git(['add', '--', ...files])
|
|
486
528
|
if (add.code !== 0) return failKeep('git add failed', 'the version files are written but unstaged. git status, then commit them')
|
|
487
529
|
const label = selected.map((s) => (s.unit.id === 'root' ? s.version : `${s.unit.id}@${s.version}`)).join(', ')
|
|
488
530
|
// the hooks already ran through the gate above; --no-verify is NOT used,
|
|
489
|
-
// so the repo's own commit gates still apply to this commit
|
|
490
|
-
|
|
531
|
+
// so the repo's own commit gates still apply to this commit.
|
|
532
|
+
// PATHSPEC, because the index is shared with every other agent in this
|
|
533
|
+
// checkout: a bare commit here tags whatever a neighbour happened to have
|
|
534
|
+
// staged, and the tag then describes a tree nobody gated.
|
|
535
|
+
const commit = git(['commit', '-m', `release ${label}`, '--', ...files])
|
|
491
536
|
if (commit.code !== 0)
|
|
492
537
|
return failKeep(`commit failed — ${commit.out}`, 'the version files are written but uncommitted. fix the gate, commit, then `shipkit release`')
|
|
493
538
|
print(`ok: committed release ${label}`)
|
package/src/release/unit-gate.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { existsSync } from 'fs'
|
|
7
7
|
import { join } from 'path'
|
|
8
8
|
import { dirtyPaths } from './git-facts.ts'
|
|
9
|
+
import { resolveTypecheckProjects } from '../ts/tsconfig-projects.ts'
|
|
9
10
|
import type { ReleaseUnit } from './units.ts'
|
|
10
11
|
import type { RepoConfig } from '../repo-config.ts'
|
|
11
12
|
|
|
@@ -56,10 +57,22 @@ export function unitBlockers(root: string, config: RepoConfig, unit: ReleaseUnit
|
|
|
56
57
|
const tsc = resolveTsc(root, unit)
|
|
57
58
|
if (tsc == null) out.push(`unit '${unit.id}': tsc not resolvable — bun add -d typescript at the repo root`)
|
|
58
59
|
else {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// a solution-style tsconfig (`files: []` plus `references`) makes
|
|
61
|
+
// tsc compile ZERO files and exit 0, which would publish a package
|
|
62
|
+
// whose only proof of soundness was that nobody looked
|
|
63
|
+
let projects: ReturnType<typeof resolveTypecheckProjects> | null = null
|
|
64
|
+
try { projects = resolveTypecheckProjects(unit.tsconfig) }
|
|
65
|
+
catch (e) { out.push(`unit '${unit.id}': ${String((e as Error)?.message ?? e)}`) }
|
|
66
|
+
for (const m of projects?.missing ?? [])
|
|
67
|
+
out.push(`unit '${unit.id}': its tsconfig references '${m}', which does not exist, so whatever that project covered is unchecked`)
|
|
68
|
+
if (projects != null && projects.vacuous)
|
|
69
|
+
out.push(`unit '${unit.id}': its tsconfig compiles no files of its own and reaches no project that does, so a typecheck over it proves nothing — it cannot be published`)
|
|
70
|
+
for (const p of projects?.projects ?? []) {
|
|
71
|
+
const r = Bun.spawnSync([tsc, '--noEmit', '-p', p], { cwd: root })
|
|
72
|
+
if (r.exitCode !== 0) {
|
|
73
|
+
const lines = `${r.stdout.toString()}${r.stderr.toString()}`.trim().split('\n').slice(0, 8).join('\n')
|
|
74
|
+
out.push(`unit '${unit.id}': does not typecheck under ${p} — it cannot be published\n${lines}`)
|
|
75
|
+
}
|
|
63
76
|
}
|
|
64
77
|
}
|
|
65
78
|
}
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
// tool body they used to hold sits at `<id>:raw`. That is what makes a cache
|
|
9
9
|
// worth having: `bun run test` is the reflex, and the reflex has to land in
|
|
10
10
|
// shipkit rather than beside it.
|
|
11
|
-
import { existsSync, readdirSync, readFileSync
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from 'fs'
|
|
12
12
|
import { join } from 'path'
|
|
13
13
|
import { canonicalBodies, conditionalBodies, inferBin, ROUTED_SCRIPTS, rawName, routedBody, routesToShipkit } from '../core/canonical-scripts.ts'
|
|
14
14
|
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
15
|
-
import { resolveFloor
|
|
15
|
+
import { resolveFloor } from '../core/floor.ts'
|
|
16
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
16
17
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
18
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
17
19
|
|
|
18
20
|
type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string> }
|
|
19
21
|
|
|
@@ -165,6 +167,11 @@ export const packageJsonShape: Rule = {
|
|
|
165
167
|
blocking: true,
|
|
166
168
|
title: 'package.json',
|
|
167
169
|
owner: OWNER,
|
|
170
|
+
// the whole tree: package.json is most of it, but the baseline-script
|
|
171
|
+
// check also walks the repo for a `*.test.ts` and probes `.rv-comfy`
|
|
172
|
+
// and `deploy.sh`, so naming three files would key it on less than it
|
|
173
|
+
// reads
|
|
174
|
+
inputs: ['.'],
|
|
168
175
|
lint: (t: CheckCtx, env: RuleEnv) => evaluatePackageJson(t, env.root, env.config),
|
|
169
176
|
// mechanical repairs only: field flips + scripts whose canonical body the
|
|
170
177
|
// vocabulary page states. Bodies it does NOT state (assets, comfy:up) are
|
|
@@ -229,7 +236,7 @@ export const packageJsonShape: Rule = {
|
|
|
229
236
|
}
|
|
230
237
|
if (changes.length > 0) {
|
|
231
238
|
const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
|
|
232
|
-
|
|
239
|
+
atomicWrite(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
|
|
233
240
|
}
|
|
234
241
|
const summary = [
|
|
235
242
|
changes.length > 0 ? changes.join(', ') : 'nothing mechanical to change',
|
|
@@ -14,6 +14,10 @@ export const contractsFulfillness: Rule = {
|
|
|
14
14
|
blocking: true,
|
|
15
15
|
title: 'contracts',
|
|
16
16
|
owner: 'meta/contracts.md',
|
|
17
|
+
// the contract engine is pure filesystem (no fetch, no spawn anywhere in
|
|
18
|
+
// contracts/), and WHICH folders it reads depends on the venues declared,
|
|
19
|
+
// so the honest input is the work tree
|
|
20
|
+
inputs: ['.'],
|
|
17
21
|
lint: async (t, env) => {
|
|
18
22
|
applyContractsResult(t, await evaluateContractsOnce(activeContracts(env.config), contractsConfigFrom(env.config, env.root)), contractsFulfillness)
|
|
19
23
|
},
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
// start --include` flags own which base include a machine wires in. A repo
|
|
8
8
|
// with neither CLAUDE.md nor agent/ is not a two-line-contract repo and
|
|
9
9
|
// passes.
|
|
10
|
-
import { existsSync, readFileSync
|
|
10
|
+
import { existsSync, readFileSync } from 'fs'
|
|
11
11
|
import { join } from 'path'
|
|
12
12
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
13
13
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
14
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
14
15
|
|
|
15
16
|
const AGENT_LINE = '@./agent/index.md'
|
|
16
17
|
const LOCAL_LINE = '@./CLAUDE.local.md'
|
|
@@ -20,6 +21,10 @@ export const claudeMdShape: Rule = {
|
|
|
20
21
|
blocking: true,
|
|
21
22
|
title: 'CLAUDE.md',
|
|
22
23
|
owner: 'meta/agent-folder.md',
|
|
24
|
+
// CLAUDE.md plus whether agent/index.md exists — and a repo that has
|
|
25
|
+
// neither is the case this rule exists for, so the tree rather than two
|
|
26
|
+
// paths that may not be there
|
|
27
|
+
inputs: ['.'],
|
|
23
28
|
lint: async (t, env) => {
|
|
24
29
|
const claudePath = join(env.root, 'CLAUDE.md')
|
|
25
30
|
const hasAgent = existsSync(join(env.root, 'agent/index.md'))
|
|
@@ -59,7 +64,7 @@ export const claudeMdShape: Rule = {
|
|
|
59
64
|
if (!existsSync(claudePath)) return { changed: false, summary: 'CLAUDE.md missing — creation needs the base include (shipkit start --include), not autofixable' }
|
|
60
65
|
const cur = readFileSync(claudePath, 'utf8')
|
|
61
66
|
if (cur.split('\n').includes(AGENT_LINE)) return { changed: false, summary: `${AGENT_LINE} already routed — remaining findings need a human` }
|
|
62
|
-
|
|
67
|
+
atomicWrite(claudePath, `${cur.endsWith('\n') || cur === '' ? cur : `${cur}\n`}${AGENT_LINE}\n`)
|
|
63
68
|
return { changed: true, summary: `${AGENT_LINE} appended as the last line` }
|
|
64
69
|
},
|
|
65
70
|
}
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
// PLACE — lines below it (e.g. a live venue's `!.rv-itch/` override, where
|
|
9
9
|
// last-match-wins) keep their position; everything outside the block is
|
|
10
10
|
// never touched. `shipkit init` writes the same block through this fixer.
|
|
11
|
-
import { existsSync, readFileSync
|
|
11
|
+
import { existsSync, readFileSync } from 'fs'
|
|
12
12
|
import { join } from 'path'
|
|
13
13
|
import type { RepoConfig } from '../repo-config.ts'
|
|
14
14
|
import { GENERATED_IGNORE_LINES, trackedGeneratedPaths, untrackGenerated } from './untrack-generated.ts'
|
|
15
15
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
16
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
16
17
|
|
|
17
18
|
const BEGIN = '# >>> shipkit gitignore policy (SK004) — managed block, edit outside only'
|
|
18
19
|
const END = '# <<< shipkit gitignore policy'
|
|
@@ -33,6 +34,11 @@ const POLICY_LINES = [
|
|
|
33
34
|
'.rv-discord/',
|
|
34
35
|
'CLAUDE.local.md',
|
|
35
36
|
'tmp/',
|
|
37
|
+
// atomicWrite lands every file via `.<name>.tmp-<pid>` next to its target,
|
|
38
|
+
// so a run killed mid write leaves one behind ANYWHERE in the tree, and an
|
|
39
|
+
// agent finds untracked debris it did not create. The ignore row ships in
|
|
40
|
+
// the same policy that started writing them.
|
|
41
|
+
'.*.tmp-*',
|
|
36
42
|
// comfy-ts state (schema dumps, drafts) and per-workflow lora tags name a
|
|
37
43
|
// private model collection; shipkit itself writes .comfy-ts/ at hub boot,
|
|
38
44
|
// so every consumer repo needs both ignored before its first commit
|
|
@@ -92,7 +98,7 @@ function writeBlock(root: string, config: RuleEnv['config']): void {
|
|
|
92
98
|
out = kept.length > 0 ? [...kept, '', ...block] : block
|
|
93
99
|
}
|
|
94
100
|
const eol = f?.crlf === true ? '\r\n' : '\n'
|
|
95
|
-
|
|
101
|
+
atomicWrite(p, `${out.join(eol)}${out.at(-1) === '' ? '' : eol}`)
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
export const gitignorePolicy: Rule = {
|
|
@@ -100,6 +106,9 @@ export const gitignorePolicy: Rule = {
|
|
|
100
106
|
blocking: true,
|
|
101
107
|
title: '.gitignore',
|
|
102
108
|
owner: 'meta/contracts.md',
|
|
109
|
+
// .gitignore plus what git currently TRACKS, which the tree carries and a
|
|
110
|
+
// single path does not
|
|
111
|
+
inputs: ['.'],
|
|
103
112
|
lint: (t, env) => {
|
|
104
113
|
// a generated path still in the index is the half-applied state the
|
|
105
114
|
// managed block cannot fix on its own
|