rvlib-shipkit 0.34.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.
@@ -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-shell"`)
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-mobx"`,
148
- `import { LiveGrid } from "rvlib-pb-shell"`,
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-shell"`)
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.commitsAfterNotes != null && r.commitsAfterNotes > 0 && !r.tagLocal) {
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.
@@ -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 { TaskDef } from './core/task-runner.ts'
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
- /** override a floor task's def, or `false` to disable (always visible) */
55
- defaults?: Record<string, TaskDef | false>
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.keys(c.defaults ?? {}))
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 itself writes tmp/.shipkit-cache in every
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