rvlib-shipkit 0.34.0 → 0.35.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 +8 -0
- package/package.json +1 -1
- package/src/cli/cmd_Ci.ts +3 -0
- package/src/cli/cmd_Keys.ts +10 -4
- package/src/core/check-suite.ts +37 -92
- package/src/core/discover-checks.ts +13 -7
- package/src/core/floor.ts +13 -4
- package/src/core/motherhome.ts +54 -1
- package/src/core/run-check.ts +103 -12
- package/src/core/run-ci.ts +103 -29
- package/src/core/task-runner.ts +7 -0
- package/src/ledger/gate-units.ts +29 -3
- package/src/ledger/git-key.ts +56 -5
- package/src/ledger/git-scan.ts +15 -2
- package/src/ledger/ledger.ts +202 -0
- package/src/ledger/store.ts +122 -0
- package/src/ledger/toolchain-identity.ts +30 -0
- package/src/pb/collection-scaffold.ts +4 -4
- package/src/release/run-release.ts +5 -0
- package/src/release/run-releases.ts +7 -1
- package/src/repo-config.ts +26 -4
- package/src/rules/SK004-gitignore-policy.ts +1 -2
- package/src/rules/SK013-one-way-in-policy.ts +7 -1
|
@@ -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
|
+
}
|
|
@@ -75,6 +75,36 @@ function hashCliTree(h: ReturnType<typeof createHash>, cli: string): void {
|
|
|
75
75
|
h.update(`clifiles=${files.length}\n`)
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* what ONE install holds, as a hash: every top-level package's name, version
|
|
80
|
+
* and link target.
|
|
81
|
+
*
|
|
82
|
+
* Exported because a repo's own install is not the only one a gate can run
|
|
83
|
+
* against. Sub-packages carry their own `node_modules`, gitignored like every
|
|
84
|
+
* other, so no git key can see them and the root toolchain does not describe
|
|
85
|
+
* them either. A unit whose declared inputs COVER such a directory folds this
|
|
86
|
+
* in beside the path, or the toolchain component stops at the repo root while
|
|
87
|
+
* the task it is meant to describe does not.
|
|
88
|
+
*
|
|
89
|
+
* ~2ms per install, and only for a path a scan already reported as ignored.
|
|
90
|
+
*/
|
|
91
|
+
export function installIdentity(nodeModules: string): string {
|
|
92
|
+
if (!existsSync(nodeModules)) return 'install=absent'
|
|
93
|
+
const h = createHash('sha256')
|
|
94
|
+
let names: string[] = []
|
|
95
|
+
try { names = installedNames(nodeModules) } catch { return 'install=unreadable' }
|
|
96
|
+
for (const n of names) {
|
|
97
|
+
const dir = join(nodeModules, n)
|
|
98
|
+
let version = 'MISSING'
|
|
99
|
+
try { version = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { version?: string }).version ?? '?' } catch {}
|
|
100
|
+
let link = ''
|
|
101
|
+
try { if (lstatSync(dir).isSymbolicLink()) link = `->${realpathSync(dir)}` } catch {}
|
|
102
|
+
h.update(`${n}@${version}${link}\n`)
|
|
103
|
+
}
|
|
104
|
+
h.update(`n=${names.length}\n`)
|
|
105
|
+
return `install=${h.digest('hex').slice(0, 16)}`
|
|
106
|
+
}
|
|
107
|
+
|
|
78
108
|
/**
|
|
79
109
|
* the identity of the program about to run, for `repoRoot`, driven by `cliEntry`.
|
|
80
110
|
*
|
|
@@ -101,7 +101,7 @@ function columnsFile(col: PbCollection): string {
|
|
|
101
101
|
lines.push('// means the cell is derived and the database cannot order by it.')
|
|
102
102
|
lines.push('// labels are plain text, swap them for i18n.t.<collection>.<field> when the')
|
|
103
103
|
lines.push('// app carries a dict.')
|
|
104
|
-
lines.push(`import type { Column } from "rvlib-pb
|
|
104
|
+
lines.push(`import type { Column } from "rvlib-pb/ui"`)
|
|
105
105
|
lines.push(`import type { ${col.prefix}Response } from "${TYPES}"`)
|
|
106
106
|
lines.push('')
|
|
107
107
|
lines.push(`export const columns: Column<${col.prefix}Response>[] = [`)
|
|
@@ -144,8 +144,8 @@ function gridFile(col: PbCollection): string {
|
|
|
144
144
|
return [
|
|
145
145
|
`// the default grid for \`${col.name}\`. A page that wants the plain table renders`,
|
|
146
146
|
'// this; a page that wants extra columns passes its own to LiveGrid directly.',
|
|
147
|
-
`import type { LiveList } from "rvlib-pb
|
|
148
|
-
`import { LiveGrid } from "rvlib-pb
|
|
147
|
+
`import type { LiveList } from "rvlib-pb"`,
|
|
148
|
+
`import { LiveGrid } from "rvlib-pb/ui"`,
|
|
149
149
|
`import type { ${col.prefix}Response } from "${TYPES}"`,
|
|
150
150
|
`import { columns } from "./columns"`,
|
|
151
151
|
'',
|
|
@@ -247,7 +247,7 @@ function indexFile(col: PbCollection, hasForm: boolean): string {
|
|
|
247
247
|
lines.push('// registration: after this import, a LiveGrid or LiveCards over')
|
|
248
248
|
lines.push(`// \`${col.name}\` with no columns of its own knows what to render, and any`)
|
|
249
249
|
lines.push('// relation cell pointing AT one knows how to name it.')
|
|
250
|
-
lines.push(`import { defineCollectionUI } from "rvlib-pb
|
|
250
|
+
lines.push(`import { defineCollectionUI } from "rvlib-pb/ui"`)
|
|
251
251
|
lines.push(`import { db } from "${PB}"`)
|
|
252
252
|
lines.push(`import { ${col.prefix}Card } from "./card"`)
|
|
253
253
|
lines.push(`import { columns } from "./columns"`)
|
|
@@ -110,6 +110,11 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
|
|
|
110
110
|
frozen: true,
|
|
111
111
|
status: false,
|
|
112
112
|
config,
|
|
113
|
+
// THE CAP ON EVERY REMAINING HOLE IN THE KEY. Not a flag, not a config
|
|
114
|
+
// field: a release re-measures every unit, so the worst a wrong key
|
|
115
|
+
// can ever produce is an agent looking at a stale local green, never a
|
|
116
|
+
// stale green that authorised a publish (ledger/ledger.ts rule 4).
|
|
117
|
+
ledgerRead: false,
|
|
113
118
|
// the FULL tier, always. A release runs on a clean tree with time to
|
|
114
119
|
// spare, and the suites the working gate skips (the ones that build
|
|
115
120
|
// real repositories) are exactly the ones a publish must not be wrong
|
|
@@ -204,7 +204,13 @@ export function attentionLines(r: UnitReport): string[] {
|
|
|
204
204
|
// shape of a command
|
|
205
205
|
const KIND = '<patch|minor|major>'
|
|
206
206
|
|
|
207
|
-
if (r.
|
|
207
|
+
if (r.npmTarget && r.npmLatest === 'unpublished') {
|
|
208
|
+
// NO version of this name is on the registry, which every drift branch
|
|
209
|
+
// below reads as "a bit behind" and none of them says. Every `bun add
|
|
210
|
+
// <name>` in a doc, a seed or a scaffold answers 404 while this holds,
|
|
211
|
+
// and that is the one state where the docs are not stale but dead
|
|
212
|
+
out.push(`WARN ${r.id}: nothing is published under the name ${r.name} — every "bun add ${r.name}" 404s until ${notesClause}shipkit bump ${KIND} ${r.id}`)
|
|
213
|
+
} else if (r.commitsAfterNotes != null && r.commitsAfterNotes > 0 && !r.tagLocal) {
|
|
208
214
|
// the trap: a version stamped into package.json while work kept
|
|
209
215
|
// landing. Publishing it ships that work under notes describing none
|
|
210
216
|
// of it, so the answer is a NEW version, never this one.
|
package/src/repo-config.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type { Rule } from './rules/rule.ts'
|
|
|
23
23
|
import type { HooksConfig } from './hooks/run-hooks.ts'
|
|
24
24
|
import type { ReleaseConfig } from './release/run-release.ts'
|
|
25
25
|
import { UNIT_DECL_KEYS, type ReleaseUnitDecl } from './release/units.ts'
|
|
26
|
-
import type
|
|
26
|
+
import { TASK_DEF_KEYS, type TaskDef } from './core/task-runner.ts'
|
|
27
27
|
import type { TuiPanelDef } from './tui/tui-panel.ts'
|
|
28
28
|
import type { MdGraph } from './wiki/md-graph.ts'
|
|
29
29
|
|
|
@@ -51,8 +51,13 @@ export type RepoConfig = {
|
|
|
51
51
|
social?: string[]
|
|
52
52
|
/** prefix for contract fix: pointers (docs checkout or URL) — written by init, never baked in */
|
|
53
53
|
docsBase?: string
|
|
54
|
-
/**
|
|
55
|
-
|
|
54
|
+
/** a floor slot: `false` disables it (always visible), a def with a
|
|
55
|
+
* `cmd` or `logic` REPLACES it, and a def with neither DECORATES whatever
|
|
56
|
+
* fills it. The decoration is how a repo declares what its own `test`
|
|
57
|
+
* script reads (`{ test: { inputs: ['tests', 'src'] } }`) without
|
|
58
|
+
* restating the command: the read set is repo knowledge, the command is
|
|
59
|
+
* already in package.json, and restating it is a second source of truth. */
|
|
60
|
+
defaults?: Record<string, Partial<TaskDef> | false>
|
|
56
61
|
/** repo-specific commit-gate tasks; deps double as race guards */
|
|
57
62
|
tasks?: TaskDef[]
|
|
58
63
|
/** emptyText: the `shipkit asks` all-clear line, in the repo's own voice */
|
|
@@ -158,9 +163,19 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
158
163
|
if (isAbsolute(d) || rel === '.' || rel === '' || rel === '..' || rel.startsWith('../'))
|
|
159
164
|
throw new Error(`defineRepo: notReleasable '${d}' is the repo root or outside it, and would excuse every package at once — name one package dir per entry`)
|
|
160
165
|
}
|
|
161
|
-
for (const k of Object.
|
|
166
|
+
for (const [k, slot] of Object.entries(c.defaults ?? {})) {
|
|
162
167
|
if (!(FLOOR_IDS as readonly string[]).includes(k))
|
|
163
168
|
throw new Error(`defineRepo: defaults.${k} is not a floor task (floor: ${FLOOR_IDS.join(', ')})`)
|
|
169
|
+
// the slot's own FIELDS, not just its name. A slot with no cmd or logic
|
|
170
|
+
// decorates the resolved task, so a typo there (`input` for `inputs`)
|
|
171
|
+
// is not a type error at any call site and lands as a task that is
|
|
172
|
+
// simply never cached, silently, which is the one direction this file
|
|
173
|
+
// promises a typo cannot go.
|
|
174
|
+
if (slot === false || slot == null) continue
|
|
175
|
+
for (const f of Object.keys(slot))
|
|
176
|
+
if (!(TASK_DEF_KEYS as readonly string[]).includes(f))
|
|
177
|
+
throw new Error(`defineRepo: defaults.${k}.${f} is not a task field (fields: ${TASK_DEF_KEYS.join(', ')})`)
|
|
178
|
+
}
|
|
164
179
|
for (const n of c.oneWayIn?.exempt ?? [])
|
|
165
180
|
if (!(ROUTED_SCRIPTS as readonly string[]).includes(n))
|
|
166
181
|
throw new Error(`defineRepo: oneWayIn.exempt '${n}' is not a governed script name (governed: ${ROUTED_SCRIPTS.join(', ')})`)
|
|
@@ -181,6 +196,13 @@ export async function loadRepoConfig(root: string): Promise<RepoConfig> {
|
|
|
181
196
|
// the contracts commit-gate task — venue deploy tasks should dep on it.
|
|
182
197
|
// Same SK002-tagged rendering as `shipkit check` and `shipkit contracts`
|
|
183
198
|
// (contracts/apply-contracts.ts is the one bridge).
|
|
199
|
+
//
|
|
200
|
+
// DELIBERATELY UNDECLARED for the ledger, and this is where that decision
|
|
201
|
+
// lives rather than in a commit message. It runs in process, shares one
|
|
202
|
+
// evaluation with rule SK002 through `withContractsScope`, and measures 0.0s,
|
|
203
|
+
// so a key would be risk against no reward. It is not `never:` either: the
|
|
204
|
+
// contract dirs describe it perfectly well, so claiming nothing can is false,
|
|
205
|
+
// and `never` has to keep meaning "somebody looked and no key exists".
|
|
184
206
|
export function contractsTask(c: RepoConfig, root: string): TaskDef {
|
|
185
207
|
return {
|
|
186
208
|
id: 'contracts',
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// SK004 — the .gitignore policy block (meta/contracts.md git policy; tmp/
|
|
2
|
-
// included because shipkit
|
|
3
|
-
// repo). A marker-keyed managed block so the always-gitignored contract
|
|
2
|
+
// included because shipkit writes its per-run logs there in every repo). A marker-keyed managed block so the always-gitignored contract
|
|
4
3
|
// surfaces can never silently rot per repo. Ecosystem-specific names the
|
|
5
4
|
// published lib may not carry ride as repo-contributed lines:
|
|
6
5
|
// `defineRepo({ gitignore: [...] })` renders them INSIDE the block, so the
|
|
@@ -103,10 +103,12 @@ export const oneWayInPolicy: Rule = {
|
|
|
103
103
|
if (missing.length > 0)
|
|
104
104
|
t.fail(`${SETTINGS_PATH}: ${missing.length} raw runner(s) still permitted (${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''}) — those calls never reach shipkit, so nothing is cached and no run can be attached to`)
|
|
105
105
|
const guard = guardHookCommand(s)
|
|
106
|
+
let guardRuns = false
|
|
106
107
|
if (guard == null)
|
|
107
108
|
t.fail(`${SETTINGS_PATH}: no PreToolUse guard — a bare deny tells the model only that it was denied, so it goes looking for another runner instead of the shipkit command`)
|
|
108
109
|
else {
|
|
109
110
|
const missing = guardScriptMissing(env.root, guard)
|
|
111
|
+
guardRuns = missing == null
|
|
110
112
|
// the hook is DECLARED either way, so it starts working the moment
|
|
111
113
|
// the dependency lands: no second fix run, nothing to remember. An
|
|
112
114
|
// uninstalled dependency is therefore a WARN naming the command
|
|
@@ -124,7 +126,11 @@ export const oneWayInPolicy: Rule = {
|
|
|
124
126
|
`point it at this repo's own copy of hooks/one-way-in-guard.ts, or delete the hook and let the deny list stand alone`,
|
|
125
127
|
)
|
|
126
128
|
}
|
|
127
|
-
|
|
129
|
+
// the summary states only what the file actually delivers: claiming the
|
|
130
|
+
// replacement is named while the guard cannot run is the green that
|
|
131
|
+
// measured nothing, one line under the WARN that says otherwise
|
|
132
|
+
if (!t.failed)
|
|
133
|
+
t.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s)${guardRuns ? ', with the replacement named' : ' — the guard that names the replacement is declared, not yet runnable'}`)
|
|
128
134
|
},
|
|
129
135
|
// MERGES, never rewrites: this file also carries the attribution silencer,
|
|
130
136
|
// a repo's own allow list and its own hooks, and clobbering those to fix a
|