rvlib-shipkit 0.33.0 → 0.35.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 +23 -1
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +15 -2
- package/src/cli/cmd_Asks.ts +2 -1
- package/src/cli/cmd_Ci.ts +3 -0
- package/src/cli/cmd_Keys.ts +113 -0
- package/src/cli.ts +2 -0
- package/src/core/check-suite.ts +54 -47
- package/src/core/discover-checks.ts +30 -7
- package/src/core/floor.ts +34 -6
- package/src/core/locked-write.ts +170 -1
- package/src/core/motherhome.ts +54 -1
- package/src/core/one-way-in-policy.ts +8 -8
- package/src/core/run-check.ts +92 -12
- package/src/core/run-ci.ts +115 -17
- package/src/core/run-fixers.ts +61 -18
- package/src/core/task-runner.ts +62 -6
- package/src/hooks/run-hooks.ts +23 -2
- package/src/ledger/gate-units.ts +84 -0
- package/src/ledger/git-key.ts +381 -0
- package/src/ledger/git-scan.ts +327 -0
- package/src/ledger/ledger.ts +202 -0
- package/src/ledger/store.ts +122 -0
- package/src/ledger/toolchain-identity.ts +45 -8
- package/src/pb/collection-scaffold.ts +4 -4
- package/src/release/run-bump.ts +50 -5
- package/src/release/run-release.ts +5 -0
- package/src/release/run-releases.ts +7 -1
- package/src/release/unit-gate.ts +17 -4
- package/src/repo-config.ts +26 -4
- package/src/rules/SK001-package-json-shape.ts +8 -2
- 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 +12 -4
- 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 +24 -4
- package/src/rules/rule.ts +22 -1
- package/src/rules/run-fix.ts +5 -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,327 @@
|
|
|
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, utimesSync, 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
|
+
//
|
|
140
|
+
// THE COPY KEEPS THE ORIGINAL'S MTIME, and that one line is the whole of a
|
|
141
|
+
// reproduced false green. Git decides an entry is "racily clean" by
|
|
142
|
+
// comparing the entry's cached mtime against the INDEX FILE's own mtime,
|
|
143
|
+
// and re-reads content whenever it cannot tell them apart. macOS git
|
|
144
|
+
// records mtimes at one-second granularity, so a file rewritten to the
|
|
145
|
+
// same SIZE inside the second it was staged is invisible to the stat cache
|
|
146
|
+
// and only that racy rule catches it. A fresh copy has mtime NOW, which
|
|
147
|
+
// makes every entry look comfortably older than the index and switches the
|
|
148
|
+
// rule off. Measured: 1 miss in 30 on a file edited between two scans, and
|
|
149
|
+
// a miss here is a replay of an answer about content nobody has any more.
|
|
150
|
+
try {
|
|
151
|
+
rmSync(scratch, { force: true })
|
|
152
|
+
if (existsSync(real)) {
|
|
153
|
+
const st = statSync(real)
|
|
154
|
+
const tmp = `${scratch}.tmp`
|
|
155
|
+
copyFileSync(real, tmp)
|
|
156
|
+
utimesSync(tmp, st.atime, st.mtime)
|
|
157
|
+
renameSync(tmp, scratch)
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// no seed is survivable: the adds are just colder
|
|
161
|
+
}
|
|
162
|
+
return { scratch, ignored, real }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** the whole work tree as one input. A rule that walks the repo (SK001's test
|
|
166
|
+
* file probe, SK010's `git ls-files`) genuinely reads all of it, and saying so
|
|
167
|
+
* is more honest than naming three of the files it happens to open. Its object
|
|
168
|
+
* id is the scan's root tree, so it moves on any edit anywhere. */
|
|
169
|
+
export const WHOLE_TREE = '.'
|
|
170
|
+
|
|
171
|
+
/** repo-relative posix, no './', no trailing slash. Throws on anything that
|
|
172
|
+
* escapes the repo — a key component outside the tree is a key nobody else
|
|
173
|
+
* can reproduce. */
|
|
174
|
+
export function normalizeInput(p: string): string {
|
|
175
|
+
const n = normalize(p).replace(/\\/g, '/').replace(/\/+$/, '').replace(/^\.\//, '')
|
|
176
|
+
if (n === '.' || n === '') return WHOLE_TREE
|
|
177
|
+
if (isAbsolute(p) || n === '..' || n.startsWith('../'))
|
|
178
|
+
throw new GitScanError(`input path '${p}' is outside the repo — declare a path inside it, or '.' for the whole work tree`)
|
|
179
|
+
return n
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** drop paths already covered by an ancestor in the same set: adding `wiki`
|
|
183
|
+
* stages `wiki/build/...` too, and a second add of the child is pure cost. */
|
|
184
|
+
export function dropCovered(paths: string[]): string[] {
|
|
185
|
+
if (paths.includes(WHOLE_TREE)) return [WHOLE_TREE]
|
|
186
|
+
const sorted = [...new Set(paths)].sort()
|
|
187
|
+
const kept: string[] = []
|
|
188
|
+
for (const p of sorted) if (!kept.some((k) => p === k || p.startsWith(`${k}/`))) kept.push(p)
|
|
189
|
+
return kept
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const underNodeModules = (p: string): boolean => p.split('/').includes('node_modules')
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* stage `paths` into the scratch index and read back one git object id each.
|
|
196
|
+
*
|
|
197
|
+
* Throws `GitScanError` on any git failure that is not lock contention. The
|
|
198
|
+
* caller's contract on a throw is absolute: produce NO key. A tree that may be
|
|
199
|
+
* stale describes files nobody has, and a key built on it is a false green
|
|
200
|
+
* waiting for its first hit.
|
|
201
|
+
*/
|
|
202
|
+
export function scanPaths(root: string, rawPaths: string[]): ScanResult {
|
|
203
|
+
const started = performance.now()
|
|
204
|
+
const { scratch: indexFile, ignored: ignoredIndex, real: realIndex } = indexPaths(root)
|
|
205
|
+
const wanted = [...new Set(rawPaths.map(normalizeInput))].sort()
|
|
206
|
+
const missing = wanted.filter((p) => !existsSync(join(root, p)))
|
|
207
|
+
const present = wanted.filter((p) => existsSync(join(root, p)))
|
|
208
|
+
const toAdd = dropCovered(present)
|
|
209
|
+
const named = wanted.filter((p) => p !== WHOLE_TREE)
|
|
210
|
+
|
|
211
|
+
// ignored content under what we are about to stage. One call, and it also
|
|
212
|
+
// answers "is this declared path itself ignored": `--directory` collapses
|
|
213
|
+
// a dir whose ENTIRE content is ignored, so the answer comes back as the
|
|
214
|
+
// dir with a trailing slash.
|
|
215
|
+
const ignored = toAdd.length === 0
|
|
216
|
+
? []
|
|
217
|
+
: gitOrThrow(root, ['ls-files', '--others', '-i', '--exclude-standard', '--directory', '--', ...toAdd], realIndex)
|
|
218
|
+
.split('\n')
|
|
219
|
+
.map((l) => l.trim().replace(/\/+$/, ''))
|
|
220
|
+
.filter((l) => l !== '')
|
|
221
|
+
// ONLY a path a declaration NAMES is force-added. Ignored content that
|
|
222
|
+
// merely sits under a declared path is reported and left out.
|
|
223
|
+
//
|
|
224
|
+
// This is the whole of defense 3, and the wide reading of it is a disaster
|
|
225
|
+
// measured twice: with `.` declared (which every repo-walking rule
|
|
226
|
+
// declares), "ignored content under a declared path" is every ignored path
|
|
227
|
+
// in the repo, so one run force-staged two entire worktrees, the log dir,
|
|
228
|
+
// the private model dumps and the prompt corpora, for 680ms. Naming the
|
|
229
|
+
// path is what makes it an input.
|
|
230
|
+
//
|
|
231
|
+
// Installs are refused on top of that, at any depth AND as an ancestor:
|
|
232
|
+
// `--directory` collapses a dir whose entire content is ignored, so
|
|
233
|
+
// force-adding that one entry stages the node_modules inside it.
|
|
234
|
+
const declared = new Set(wanted)
|
|
235
|
+
const denied = ignored.filter(underNodeModules)
|
|
236
|
+
const forceAdd = ignored.filter((p) => declared.has(p) && !underNodeModules(p) && !denied.some((d) => d.startsWith(`${p}/`)))
|
|
237
|
+
// a path git itself calls ignored must NOT get the plain add: `git add --
|
|
238
|
+
// <ignored>` exits 1 and takes the whole scan down with it
|
|
239
|
+
const ignoredSet = new Set(ignored)
|
|
240
|
+
for (const p of toAdd) if (!ignoredSet.has(p)) gitOrThrow(root, ['add', '--', p], indexFile)
|
|
241
|
+
// force-adds land in their OWN index, so a path one unit named can never
|
|
242
|
+
// ride inside the whole-tree id another unit is keyed on
|
|
243
|
+
for (const p of forceAdd) gitOrThrow(root, ['add', '-Af', '--', p], ignoredIndex)
|
|
244
|
+
|
|
245
|
+
const treeOid = gitOrThrow(root, ['write-tree'], indexFile).trim()
|
|
246
|
+
const oidByPath = new Map<string, string>()
|
|
247
|
+
// the work tree's own id IS the tree the adds just produced — there is no
|
|
248
|
+
// `<tree>:` path to ask git for
|
|
249
|
+
if (wanted.includes(WHOLE_TREE)) oidByPath.set(WHOLE_TREE, treeOid)
|
|
250
|
+
const forceSet = new Set(forceAdd)
|
|
251
|
+
const plain = named.filter((p) => !forceSet.has(p))
|
|
252
|
+
for (const [tree, paths, index] of [[treeOid, plain, indexFile], [null, forceAdd, ignoredIndex]] as const) {
|
|
253
|
+
if (paths.length === 0) continue
|
|
254
|
+
const root_ = tree ?? gitOrThrow(root, ['write-tree'], index).trim()
|
|
255
|
+
const query = paths.map((p) => `${root_}:${p}`).join('\n')
|
|
256
|
+
const p = Bun.spawnSync(['git', 'cat-file', '--batch-check'], {
|
|
257
|
+
cwd: root,
|
|
258
|
+
stdin: Buffer.from(`${query}\n`),
|
|
259
|
+
stdout: 'pipe',
|
|
260
|
+
stderr: 'pipe',
|
|
261
|
+
env: { ...process.env, GIT_INDEX_FILE: index },
|
|
262
|
+
})
|
|
263
|
+
if (p.exitCode !== 0) throw new GitScanError(`git cat-file --batch-check exited ${p.exitCode}: ${p.stderr.toString().trim()}`)
|
|
264
|
+
const lines = p.stdout.toString().split('\n').filter((l) => l !== '')
|
|
265
|
+
if (lines.length !== paths.length)
|
|
266
|
+
throw new GitScanError(`git cat-file --batch-check answered ${lines.length} of ${paths.length} paths`)
|
|
267
|
+
paths.forEach((path, i) => {
|
|
268
|
+
const oid = lines[i]!.split(' ')[0] ?? ''
|
|
269
|
+
// 'missing' / 'ambiguous' come back in place of an id — a path git
|
|
270
|
+
// cannot name is a path we cannot key on
|
|
271
|
+
if (/^[0-9a-f]{40}$/.test(oid)) oidByPath.set(path, oid)
|
|
272
|
+
else if (!missing.includes(path)) missing.push(path)
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const excluded = new Map<string, string[]>()
|
|
277
|
+
for (const d of ignored) {
|
|
278
|
+
// EVERY owner that covers it, not the first one found, and membership
|
|
279
|
+
// must not depend on what OTHER units declared. `.` sorts first and
|
|
280
|
+
// covers everything, so a `find` here handed the whole exclusion list
|
|
281
|
+
// to the whole-tree unit and left every narrower unit claiming it was
|
|
282
|
+
// blind to nothing. Filtering force-added paths out globally had the
|
|
283
|
+
// same shape: one unit naming `ign` deleted `excluded=ign` from a
|
|
284
|
+
// whole-tree unit that is still blind to it. Measured both ways, one
|
|
285
|
+
// unit producing two keys for one unchanged tree, and the component
|
|
286
|
+
// that vanished is the one that exists to stop a silently narrowed key.
|
|
287
|
+
for (const w of wanted) {
|
|
288
|
+
if (!(w === WHOLE_TREE || d === w || d.startsWith(`${w}/`))) continue
|
|
289
|
+
// the one owner that is NOT blind: it named the path itself, so it
|
|
290
|
+
// got the content through the force-add
|
|
291
|
+
if (d === w && forceSet.has(d)) continue
|
|
292
|
+
excluded.set(w, [...(excluded.get(w) ?? []), d])
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return { treeOid, oidByPath, missing: [...new Set(missing)].sort(), excluded, ms: performance.now() - started }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* blob ids for a list of FILES, one process, no index, no lock.
|
|
300
|
+
*
|
|
301
|
+
* The scratch index is the right tool for a declared input, which is usually a
|
|
302
|
+
* directory and usually unchanged (git's stat cache answers without reading).
|
|
303
|
+
* It is the wrong tool for the module-graph closure: those are individual
|
|
304
|
+
* files, they belong to no declared path, and staging each one would cost a
|
|
305
|
+
* `git add` spawn per file. `hash-object --stdin-paths` reads them all in one
|
|
306
|
+
* call and returns exactly what a blob of that content would be.
|
|
307
|
+
*
|
|
308
|
+
* A file that is not on disk is skipped and reported, never guessed at.
|
|
309
|
+
*/
|
|
310
|
+
export function hashFiles(root: string, files: string[]): { oids: Map<string, string>; missing: string[] } {
|
|
311
|
+
const oids = new Map<string, string>()
|
|
312
|
+
const wanted = [...new Set(files.map(normalizeInput))].sort()
|
|
313
|
+
const missing = wanted.filter((f) => !existsSync(join(root, f)))
|
|
314
|
+
const present = wanted.filter((f) => existsSync(join(root, f)))
|
|
315
|
+
if (present.length === 0) return { oids, missing }
|
|
316
|
+
const p = Bun.spawnSync(['git', 'hash-object', '--stdin-paths'], {
|
|
317
|
+
cwd: root,
|
|
318
|
+
stdin: Buffer.from(`${present.join('\n')}\n`),
|
|
319
|
+
stdout: 'pipe',
|
|
320
|
+
stderr: 'pipe',
|
|
321
|
+
})
|
|
322
|
+
if (p.exitCode !== 0) throw new GitScanError(`git hash-object exited ${p.exitCode}: ${p.stderr.toString().trim().split('\n').slice(0, 3).join(' / ')}`)
|
|
323
|
+
const lines = p.stdout.toString().split('\n').filter((l) => l !== '')
|
|
324
|
+
if (lines.length !== present.length) throw new GitScanError(`git hash-object answered ${lines.length} of ${present.length} files`)
|
|
325
|
+
present.forEach((f, i) => oids.set(f, lines[i]!.trim()))
|
|
326
|
+
return { oids, missing }
|
|
327
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// THE LEDGER — the CONTENT half of the three populations: a unit's answer,
|
|
2
|
+
// stored under the identity of the question it answered, replayed only when
|
|
3
|
+
// that identity is bit-for-bit the same question again.
|
|
4
|
+
//
|
|
5
|
+
// ledger/git-key.ts owns the identity, ledger/store.ts owns the file. This
|
|
6
|
+
// module owns the POLICY, and the policy is where a cache turns into a false
|
|
7
|
+
// green, so every rule below exists because a specific failure was reproduced
|
|
8
|
+
// during the design pass:
|
|
9
|
+
//
|
|
10
|
+
// 1. DEFAULT DENY. A unit that declared nothing is never replayed and never
|
|
11
|
+
// stored. Forgetting to declare costs speed, never correctness, which is
|
|
12
|
+
// what makes the burn-down a to-do rather than a hazard.
|
|
13
|
+
// 2. POST RUN RE HASH. Results are BUFFERED while the gate runs and written
|
|
14
|
+
// only after the inputs are hashed a second time. A unit whose inputs
|
|
15
|
+
// moved between its run and that second hash answered about a tree that no
|
|
16
|
+
// longer exists, so its result is discarded rather than stored under a key
|
|
17
|
+
// that describes the file as it was BEFORE the edit. Three agents in one
|
|
18
|
+
// checkout is the normal case here, so that window is not theoretical.
|
|
19
|
+
// 3. THE GATE BINARY IS IN THE KEY. Editing shipkit moves every key in every
|
|
20
|
+
// repo (ledger/toolchain-identity.ts content-hashes the cli tree).
|
|
21
|
+
// 4. RELEASE NEVER READS IT. `bump` and `release` pass read:false, not
|
|
22
|
+
// configurably. Every remaining hole is then capped at "an agent saw a
|
|
23
|
+
// stale local green", never "a stale green authorised a publish".
|
|
24
|
+
// 5. NO SILENT REPLAY. Every replayed line is stamped with the word
|
|
25
|
+
// `replayed` and what the measured run cost, and the run prints hit, miss
|
|
26
|
+
// and uncacheable counts. A cached green that cannot be told from a
|
|
27
|
+
// measured green is the bug this whole design exists to not ship.
|
|
28
|
+
// 6. ONLY A GREEN REPLAYS. A red is the one outcome an agent acts on
|
|
29
|
+
// immediately, and acting on it changes content, which moves the key — so
|
|
30
|
+
// replaying a red buys almost nothing, while an undeclared flake would
|
|
31
|
+
// pin its red until something unrelated happened to change. The safe
|
|
32
|
+
// direction here is re-measuring, and it costs only the run that was
|
|
33
|
+
// already going red.
|
|
34
|
+
import type { RvtConfig } from '../ts/config.ts'
|
|
35
|
+
import type { Line } from '../core/wire.ts'
|
|
36
|
+
import { KeyLayer, type UnitDecl, type UnitKey } from './git-key.ts'
|
|
37
|
+
import { entryPath, FRESH_ENV, KEEP_MS, readStored, storeRoot, sweepStore, writeStored } from './store.ts'
|
|
38
|
+
|
|
39
|
+
/** the env var that turns the READ path on or off for a whole session.
|
|
40
|
+
* Writing is unconditional either way: an entry nobody reads breaks nothing. */
|
|
41
|
+
export const LEDGER_ENV = 'SHIPKIT_LEDGER'
|
|
42
|
+
|
|
43
|
+
/** where unit results live inside the one store */
|
|
44
|
+
const NS = 'units'
|
|
45
|
+
|
|
46
|
+
/** whether the read path is on by default, before any flag.
|
|
47
|
+
*
|
|
48
|
+
* It shipped OFF and ran that way while two other efforts gated in this
|
|
49
|
+
* checkout, because a bug here would have handed one of them a green nobody
|
|
50
|
+
* measured. It went ON once eleven scenarios pinned the failure modes, a race
|
|
51
|
+
* of five real gates on one checkout agreed on one verdict, and the dark run
|
|
52
|
+
* had turned up and fixed a false green in the key layer that predates it.
|
|
53
|
+
* `SHIPKIT_LEDGER=0` turns it off for a session, and this line is the revert. */
|
|
54
|
+
export const READ_DEFAULT = true
|
|
55
|
+
|
|
56
|
+
export type UnitOutcome = {
|
|
57
|
+
/** only a green is ever stored (rule 6 above) */
|
|
58
|
+
status: 'pass' | 'warn'
|
|
59
|
+
lines: Line[]
|
|
60
|
+
/** the task-runner headline, which the gate greps for the test count */
|
|
61
|
+
lastLine?: string
|
|
62
|
+
/** what the measured run cost, ms — printed on the replay so a hit says
|
|
63
|
+
* what it saved instead of pretending the work was free */
|
|
64
|
+
measuredMs: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type LedgerOpts = {
|
|
68
|
+
/** the cli entry driving this run (toolchain identity needs it) */
|
|
69
|
+
cliEntry: string
|
|
70
|
+
/** read the store at all. false = every unit is measured this run. */
|
|
71
|
+
read?: boolean
|
|
72
|
+
/** this run re-measures everything, and re-stores what it measured */
|
|
73
|
+
fresh?: boolean
|
|
74
|
+
/** module-graph config, loaded from the repo when omitted */
|
|
75
|
+
cfg?: RvtConfig
|
|
76
|
+
now?: () => number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type CommitReport = {
|
|
80
|
+
/** unit ids written to the store */
|
|
81
|
+
stored: string[]
|
|
82
|
+
/** unit ids whose result was thrown away because an input moved */
|
|
83
|
+
discarded: string[]
|
|
84
|
+
/** the paths that moved */
|
|
85
|
+
drifted: string[]
|
|
86
|
+
/** the re-hash itself failed; nothing was stored */
|
|
87
|
+
error: string | null
|
|
88
|
+
ms: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** is the read path on for this process? A run may still turn it off. */
|
|
92
|
+
export function ledgerReadEnabled(): boolean {
|
|
93
|
+
const v = process.env[LEDGER_ENV]
|
|
94
|
+
if (v === '1' || v === 'true') return true
|
|
95
|
+
if (v === '0' || v === 'false') return false
|
|
96
|
+
return READ_DEFAULT
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class Ledger {
|
|
100
|
+
private readonly buffered = new Map<string, UnitOutcome>()
|
|
101
|
+
private readonly keys = new Map<string, UnitKey>()
|
|
102
|
+
private hits = 0
|
|
103
|
+
private misses = 0
|
|
104
|
+
private unkeyed = 0
|
|
105
|
+
|
|
106
|
+
private constructor(
|
|
107
|
+
readonly layer: KeyLayer,
|
|
108
|
+
private readonly store: string,
|
|
109
|
+
/** replays are allowed */
|
|
110
|
+
readonly reading: boolean,
|
|
111
|
+
private readonly now: () => number,
|
|
112
|
+
) {}
|
|
113
|
+
|
|
114
|
+
static open(root: string, decls: UnitDecl[], o: LedgerOpts): Ledger {
|
|
115
|
+
const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg })
|
|
116
|
+
const fresh = o.fresh === true || process.env[FRESH_ENV] != null
|
|
117
|
+
const reading = (o.read ?? ledgerReadEnabled()) && !fresh
|
|
118
|
+
return new Ledger(layer, storeRoot(root), reading, o.now ?? Date.now)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** the key for a unit, computed once per run and remembered: `keyFor`
|
|
122
|
+
* re-derives the component list every call, and the gate asks twice (once
|
|
123
|
+
* to replay, once to record). */
|
|
124
|
+
private key(id: string): UnitKey {
|
|
125
|
+
let k = this.keys.get(id)
|
|
126
|
+
if (k == null) { k = this.layer.keyFor(id); this.keys.set(id, k) }
|
|
127
|
+
return k
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private path(k: UnitKey): string {
|
|
131
|
+
return entryPath(this.store, NS, `${k.kind}:${k.id}`, k.key!)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** the stored answer for this unit, or null when it must run. Counts what
|
|
135
|
+
* it was asked, so the run can report hit/miss/uncacheable honestly. */
|
|
136
|
+
replay(id: string): UnitOutcome | null {
|
|
137
|
+
const k = this.key(id)
|
|
138
|
+
if (k.key == null) { this.unkeyed++; return null }
|
|
139
|
+
if (!this.reading) { this.misses++; return null }
|
|
140
|
+
const e = readStored<UnitOutcome>(this.path(k), k.key)
|
|
141
|
+
if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; return null }
|
|
142
|
+
this.hits++
|
|
143
|
+
return e.payload
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** buffer what a unit just measured. Nothing reaches the store until
|
|
147
|
+
* commit() has re-hashed the inputs (rule 2). */
|
|
148
|
+
record(id: string, outcome: UnitOutcome): void {
|
|
149
|
+
const k = this.key(id)
|
|
150
|
+
if (k.key == null) return
|
|
151
|
+
this.buffered.set(id, outcome)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** re-hash, then write what still describes the tree on disk. */
|
|
155
|
+
commit(): CommitReport {
|
|
156
|
+
const empty: CommitReport = { stored: [], discarded: [], drifted: [], error: null, ms: 0 }
|
|
157
|
+
if (this.buffered.size === 0) return empty
|
|
158
|
+
const started = performance.now()
|
|
159
|
+
const d = this.layer.rescan()
|
|
160
|
+
if (d.error != null) return { ...empty, discarded: [...this.buffered.keys()].sort(), error: d.error, ms: performance.now() - started }
|
|
161
|
+
const touched = this.layer.unitsTouchedBy(d.drifted)
|
|
162
|
+
const stored: string[] = []
|
|
163
|
+
const discarded: string[] = []
|
|
164
|
+
for (const [id, outcome] of this.buffered) {
|
|
165
|
+
if (touched.has(id)) { discarded.push(id); continue }
|
|
166
|
+
const k = this.key(id)
|
|
167
|
+
writeStored(this.path(k), k.key!, outcome, this.now())
|
|
168
|
+
stored.push(id)
|
|
169
|
+
}
|
|
170
|
+
sweepStore(this.store, NS, this.now(), KEEP_MS)
|
|
171
|
+
return { stored: stored.sort(), discarded: discarded.sort(), drifted: d.drifted, error: null, ms: performance.now() - started }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
get counts(): { hit: number; miss: number; uncacheable: number } {
|
|
175
|
+
return { hit: this.hits, miss: this.misses, uncacheable: this.unkeyed }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** the one line a run prints about its own cache. Never silent: a run that
|
|
179
|
+
* replayed nothing says so, so a green is always attributable. */
|
|
180
|
+
header(commit: CommitReport): Line[] {
|
|
181
|
+
const c = this.counts
|
|
182
|
+
const asked = c.hit + c.miss + c.uncacheable
|
|
183
|
+
if (asked === 0) return []
|
|
184
|
+
const out: Line[] = []
|
|
185
|
+
// `skip:` and not a bare note: the ci wire prefixes are a frozen
|
|
186
|
+
// contract every agent and every parser reads, and these lines are
|
|
187
|
+
// exactly what the run did NOT measure
|
|
188
|
+
const mode = this.reading ? '' : this.layer.failure != null ? ', reads off because the git scan failed' : ', reads off'
|
|
189
|
+
out.push({ kind: 'skip', text: `ledger — ${c.hit} replayed, ${c.miss} measured, ${c.uncacheable} uncacheable${mode}` })
|
|
190
|
+
if (commit.error != null) out.push({ kind: 'warn', text: `ledger stored nothing this run — the post-run re-hash failed: ${commit.error}` })
|
|
191
|
+
else if (commit.discarded.length > 0)
|
|
192
|
+
out.push({ kind: 'skip', text: `ledger stored nothing for ${commit.discarded.length} unit(s) — ${commit.drifted.slice(0, 3).join(', ')}${commit.drifted.length > 3 ? ` +${commit.drifted.length - 3} more` : ''} moved while the gate ran` })
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** stamp a replayed unit's lines. The FIRST line carries the word, because
|
|
198
|
+
* that is the line an agent reads, and a replay that renders identically to a
|
|
199
|
+
* measurement is the one thing this design refuses to ship. */
|
|
200
|
+
export function stampReplayed(lines: Line[], measuredMs: number): Line[] {
|
|
201
|
+
return lines.map((l, i) => (i === 0 ? { ...l, text: `${l.text} (replayed, ${(measuredMs / 1000).toFixed(1)}s saved)` } : l))
|
|
202
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// ONE STORE. Everything this toolkit remembers between runs lives here, in
|
|
2
|
+
// one shape, under one root, busted by one flag.
|
|
3
|
+
//
|
|
4
|
+
// There used to be two: `tmp/.shipkit-cache` for the check orchestrator and
|
|
5
|
+
// `tmp/.check-cache` for the page checks, each with its own bust env var, each
|
|
6
|
+
// per worktree. Two stores means two staleness windows over one question, and
|
|
7
|
+
// the stamps in this repo's own history went BACKWARDS between checkouts
|
|
8
|
+
// because of it. This module is the single mechanism; what differs between
|
|
9
|
+
// populations is the KEY, never the storage.
|
|
10
|
+
//
|
|
11
|
+
// THE SHAPE, unchanged from the check cache slice 2 landed and proved:
|
|
12
|
+
// one immutable file per key, named by the key, written tmp + rename. No
|
|
13
|
+
// lock, no merge, no read-modify-write, and no prune of another runner's
|
|
14
|
+
// entries. Three agents in one checkout is the normal case, so the only safe
|
|
15
|
+
// aggregate is no aggregate: a run with a different key writes a DIFFERENT
|
|
16
|
+
// file, a run with the same key replaces its own whole answer or none of it,
|
|
17
|
+
// and housekeeping sweeps by AGE, which needs no knowledge of what the
|
|
18
|
+
// current run knows about.
|
|
19
|
+
//
|
|
20
|
+
// WHERE. `<git-common-dir>/shipkit/store/`, which is SHARED by every linked
|
|
21
|
+
// worktree of the repo. That is safe only because no key component is a path:
|
|
22
|
+
// the toolchain identity content-hashes the gate binary and every installed
|
|
23
|
+
// package, so two worktrees carrying different installs produce different
|
|
24
|
+
// keys and simply miss each other (ledger/toolchain-identity.ts owns the
|
|
25
|
+
// measurement). It is also outside the work tree, so nothing here can dirty a
|
|
26
|
+
// diff or reach a commit.
|
|
27
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
|
|
28
|
+
import { dirname, join } from 'path'
|
|
29
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
30
|
+
|
|
31
|
+
/** the storage FORMAT. Bumped when the envelope changes, so an old file can
|
|
32
|
+
* never be handed to a new reader. The KEY carries everything about the
|
|
33
|
+
* question; this is only about the file around it. */
|
|
34
|
+
export const STORE_VERSION = 1
|
|
35
|
+
|
|
36
|
+
/** the ONE env var that busts every remembered answer, content or TTL. */
|
|
37
|
+
export const FRESH_ENV = 'SHIPKIT_FRESH'
|
|
38
|
+
|
|
39
|
+
/** one remembered answer. `key` is stored as well as hashed into the file
|
|
40
|
+
* name, and re-checked on read: a name collision must miss, never serve a
|
|
41
|
+
* neighbour's answer. */
|
|
42
|
+
export type Stored<T> = {
|
|
43
|
+
v: number
|
|
44
|
+
key: string
|
|
45
|
+
/** when it was written, ms since epoch */
|
|
46
|
+
at: number
|
|
47
|
+
payload: T
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** how long a superseded entry survives. Far past the longest TTL any check
|
|
51
|
+
* declares, so a live entry is never swept out from under a replay. */
|
|
52
|
+
export const KEEP_MS = 30 * 24 * 60 * 60 * 1000
|
|
53
|
+
|
|
54
|
+
/** where this repo's remembered answers live.
|
|
55
|
+
*
|
|
56
|
+
* `--git-common-dir` rather than `--git-dir`: a linked worktree has its own
|
|
57
|
+
* git dir but shares the common one, and slice 3 measured that sharing is
|
|
58
|
+
* SAFE here because every key component is content or a version string.
|
|
59
|
+
* A tree that is not a git repo at all still gets a store, under `tmp/`,
|
|
60
|
+
* because a scaffold in progress must not crash on it. */
|
|
61
|
+
export function storeRoot(root: string): string {
|
|
62
|
+
const p = Bun.spawnSync(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
63
|
+
const dir = p.exitCode === 0 ? p.stdout.toString().trim() : ''
|
|
64
|
+
return dir === '' ? join(root, 'tmp/.shipkit-store') : join(dir, 'shipkit/store')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const slug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
|
|
68
|
+
|
|
69
|
+
/** where ONE answer lives. The key is in the file NAME, so a run whose key
|
|
70
|
+
* moved writes a new file instead of rewriting the old one, and two runners
|
|
71
|
+
* with different keys never collide. `ns` keeps populations apart so a sweep
|
|
72
|
+
* or a manual `rm` can be scoped. */
|
|
73
|
+
export function entryPath(store: string, ns: string, name: string, key: string): string {
|
|
74
|
+
const h = new Bun.CryptoHasher('sha256').update(`${ns}\0${name}\0${key}`).digest('hex').slice(0, 12)
|
|
75
|
+
return join(store, slug(ns), `${slug(name)}.${h}.json`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** a missing or half-written file reads as "no answer", which is the safe
|
|
79
|
+
* side: the unit runs. tmp + rename means half-written cannot happen from a
|
|
80
|
+
* shipkit write, so this only ever catches a truncated file from something
|
|
81
|
+
* else, and treats it the same way. */
|
|
82
|
+
export function readStored<T>(path: string, key: string): Stored<T> | null {
|
|
83
|
+
try {
|
|
84
|
+
const e = JSON.parse(readFileSync(path, 'utf8')) as Partial<Stored<T>>
|
|
85
|
+
if (e.v !== STORE_VERSION || e.key !== key || typeof e.at !== 'number' || e.payload == null) return null
|
|
86
|
+
return e as Stored<T>
|
|
87
|
+
} catch { return null }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function writeStored<T>(path: string, key: string, payload: T, at: number): void {
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
93
|
+
atomicWrite(path, JSON.stringify({ v: STORE_VERSION, key, at, payload }))
|
|
94
|
+
} catch {
|
|
95
|
+
// a store that cannot be written is slow, never wrong: the answer was
|
|
96
|
+
// already printed, and the next run measures again
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function dropStored(path: string): void {
|
|
101
|
+
try { rmSync(path, { force: true }) } catch { /* raced with another runner: it is gone either way */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** age-based housekeeping. Sweeping by "the units I know about" is what made
|
|
105
|
+
* the old prune wrong: a run narrowed with --only knows about one unit and
|
|
106
|
+
* would delete the other sixty-five. */
|
|
107
|
+
export function sweepStore(store: string, ns: string, now: number, keepMs = KEEP_MS): void {
|
|
108
|
+
const dir = join(store, slug(ns))
|
|
109
|
+
const cutoff = now - keepMs
|
|
110
|
+
try {
|
|
111
|
+
for (const f of readdirSync(dir)) {
|
|
112
|
+
// `.<name>.tmp-<pid>` is what atomicWrite lands beside its target,
|
|
113
|
+
// and a writer killed between the write and the rename leaves one
|
|
114
|
+
// behind. Sweeping only *.json accreted them without bound.
|
|
115
|
+
if (!f.endsWith('.json') && !/\.tmp-\d+$/.test(f)) continue
|
|
116
|
+
const p = join(dir, f)
|
|
117
|
+
try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { force: true }) } catch { /* raced: gone either way */ }
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// no dir yet, or unreadable: housekeeping never fails a run
|
|
121
|
+
}
|
|
122
|
+
}
|