rvlib-shipkit 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 1.0.1
2
+
3
+ - **A layout move lands once, and its commit says what it moved.** A tracked root `STATUS.md` that named an old folder was dropped and then written back by the text sweep in the same pass, so the move commit kept it, and the next run landed a second commit, titled as a move, that moved nothing and reported `moved under .shipkit/`. A legacy generated file is now only dropped, never swept. The commit title is built from what the pass did (`chore(machine): move asks/ and .rv-identity/ under .shipkit/, and drop the root STATUS.md, which shipkit writes under .shipkit/`), so a pass that moves no folder never carries the move title, and the fix pass and SK004 print the same words (`layoutSummary` in `core/migrate-layout.ts`, which replaces `MIGRATION_MESSAGE`).
4
+
5
+ - **An update never guesses the package manager.** `detectPackageManager` returns the manager the tracked lock files name, or a refusal (`{ refused }`, `isRefusal`) when they name none or two: no lock at all used to mean bun, and two managers' locks used to mean bun with a warning, so an npm repo could be updated with bun, which moves package.json and leaves `package-lock.json` on the old version. SK005 and the self-update WARN with the refusal and run nothing. `shipkit fleet --update` refuses a repo it cannot tell, and a non bun repo whose installed shipkit is older than 1.0.0, whose own update ran bun whatever the lock said; it prints the command in the repo's manager instead (`updateRefusal`, `PM_AWARE_SINCE` in `fleet/scan-fleet.ts`). The `warn` field of `PackageManager` is gone.
6
+
7
+ - **The layout move rewrites the paths `repo.config.ts` holds.** Every quoted value that names a moved folder as a path (`hooks.leakAllow` paths, `hooks.blockedPaths`, `gitignore` lines, a prose include, any other string starting with `asks/`, `tmp/`, `hooks/` or a `.rv-<x>/` folder) now names its new place, in the same machine commit. The ignore block is rendered from those rewritten values, and the rule fixers that run after the move in the same fix pass read the rewritten config (`runFix` takes `config`, and the fix pass reports the config as reshaped). Before, `leakAllow: [{ paths: ['asks/'] }]` kept naming a folder that was gone, and a repo line in `gitignore` came back under its old name, which took a second move commit to clear. `sweepConfigSource` and `rebaseConfigPaths` are exported from `core/migrate-layout.ts`, and `renderGitignore` receives the moved config as a third argument.
8
+
9
+ - **The seeded `sk-` key shape starts where a token starts.** `re:sk-[A-Za-z0-9_-]{20,}` matched inside a word ending in `sk`, so an ask file named `ask-the-town-hall-...` was refused as an API key. The starter row is now `re:(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{20,}` (`SK_KEY_ROW`), and a keyword file that still carries the old seeded row is read with the fixed shape under its old id, so a waiver written against its fingerprint keeps working.
10
+
11
+ - **Every file a check file or `repo.config.ts` reaches compiles under a strict, node only consumer tsconfig.** The package ships its source, so a consumer's compiler judges it. Unused imports in `core/run-ci.ts`, `repo-config.ts`, `core/floor-resolve.ts`, `collections/checks.ts`, `wiki/scan-md.ts` and `assets/gen/derived.ts` failed a consumer with `noUnusedLocals` or `noUnusedParameters`, and `smoke/run-smoke.ts` named the dom `document`, which fails a consumer whose `lib` has no dom. `tsconfig.consumer.json` compiles `check.ts`, `repo-config.ts` and the barrel with those flags, `lib: ["ESNext"]` and bun types only, and `tests/consumer-typecheck.test.ts` (full tier) runs it, with samples that prove each flag bites.
12
+
1
13
  ## 1.0.0
2
14
 
3
15
  - **The PreToolUse guard refuses a piped gate.** `shipkit ci …|`, `bun <dir>/cli.ts ci …|`, `bunx shipkit ci …|`, `bun run test …|` and `bun run ci …|` exit 2 with the reason and the fix, whatever the deny list says: a pipeline exits with its last command's code, so a red gate reads green. A `>` redirect, `bun run test:raw <file> | tail` and a pipeline under `set -o pipefail` pass. `pipedGateIn`, `pipedGateRefusal` and `shellParts` (each segment with the operator that ends it) are exported from `core/one-way-in-policy.ts`, and a redirection's `&` (`2>&1`, `&>log`) no longer splits a segment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "funding": "https://github.com/sponsors/rvion",
5
5
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@ export async function recordParents(destDir: string, seed: number, parents: Reco
47
47
  /** A derived asset is STALE when its PICKED roll's recorded parent picks
48
48
  * differ from the parents' CURRENT picks (or were never recorded). An
49
49
  * unpicked asset is never stale — it's just unpicked. */
50
- export async function isStale(kit: KitConfig, workspace: string, f: FoundAsset): Promise<boolean> {
50
+ export async function isStale(_kit: KitConfig, workspace: string, f: FoundAsset): Promise<boolean> {
51
51
  const parentIds = parentIdsOf(f)
52
52
  if (parentIds.length === 0) return false
53
53
  const dir = assetDir(workspace, f.asset.id)
@@ -20,7 +20,7 @@ import { GENERATED_IGNORE_LINES, GENERATED_TRACKED_PATHS, untrackGenerated } fro
20
20
  import { check, type CheckCtx, type FixResult } from '../check.ts'
21
21
  import { gitBin } from '../core/git-bin.ts'
22
22
  import { LAYOUT, SHIPKIT_DIR, TRACKED_DIRS } from '../core/layout.ts'
23
- import { LEGACY_CONTRACT_PREFIX, LEGACY_IGNORED, LEGACY_TMP, LEGACY_VENUES, legacyLayoutLeft, migrateLayout } from '../core/migrate-layout.ts'
23
+ import { LEGACY_CONTRACT_PREFIX, LEGACY_IGNORED, LEGACY_TMP, LEGACY_VENUES, layoutSummary, legacyLayoutLeft, migrateLayout } from '../core/migrate-layout.ts'
24
24
 
25
25
  /** what this check reads, writes and asks git through */
26
26
  type Disk = Pick<CheckCtx, 'root' | 'read' | 'write' | 'exists' | 'exec'>
@@ -272,11 +272,11 @@ export const gitignorePolicy = check({
272
272
  },
273
273
  fix: async (c): Promise<FixResult> => {
274
274
  // the move first: it writes the new block itself, before any folder moves
275
- const moved = process.env.SHIPKIT_FROZEN === '1' ? null : migrateLayout(c.root, { config: c.config, renderGitignore: (t, legacy) => renderPolicy(t, c.config, legacy) })
275
+ const moved = process.env.SHIPKIT_FROZEN === '1' ? null : migrateLayout(c.root, { config: c.config, renderGitignore: (t, legacy, cfg) => renderPolicy(t, cfg ?? c.config, legacy) })
276
276
  const layout = moved == null ? null
277
277
  : moved.deferred != null ? `layout move deferred: ${moved.deferred}`
278
- : moved.moved.length === 0 && moved.removed.length === 0 ? null
279
- : `moved ${moved.moved.map((m) => m.from).join(', ')} under ${SHIPKIT_DIR}/${moved.commit != null ? ` (committed as ${moved.commit})` : moved.uncommitted === true ? ', the commit did not land' : ''}`
278
+ : layoutSummary(moved) === '' ? null
279
+ : `layout: ${layoutSummary(moved)}${moved.commit != null ? ` (committed as ${moved.commit})` : moved.uncommitted === true ? ', the commit did not land' : ''}`
280
280
  const legacy = hasLegacy(c.root)
281
281
  const before = blockState(c, c.config, legacy)
282
282
  // terminate an unterminated file even when the block itself is current:
@@ -42,7 +42,7 @@ import { existsSync, readFileSync } from 'fs'
42
42
  import { join } from 'path'
43
43
  import type { CheckCtx } from '../core/check-ctx.ts'
44
44
  import { mutateLease, withMutateLease } from '../core/locked-write.ts'
45
- import { detectPackageManager } from '../core/package-manager.ts'
45
+ import { detectPackageManager, isRefusal, LOCK_FILES } from '../core/package-manager.ts'
46
46
  import { check, type CheckEnv } from '../check.ts'
47
47
 
48
48
  type Pkg = { version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
@@ -85,7 +85,7 @@ export const rvlibVersions = check({
85
85
  const deps = Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }).filter(([n]) => n.startsWith('rvlib-'))
86
86
  if (deps.length === 0) return c.ok('no rvlib-* deps')
87
87
  const pm = detectPackageManager(c.root)
88
- const manifest = ['package.json', ...pm.locks]
88
+ const manifest = ['package.json', ...(isRefusal(pm) ? LOCK_FILES : pm.locks)]
89
89
 
90
90
  const stale: Array<{ name: string; installed: string; latest: string }> = []
91
91
  for (const [name, spec] of deps) {
@@ -95,7 +95,7 @@ export const rvlibVersions = check({
95
95
  continue
96
96
  }
97
97
  const installed = installedVersion(c.root, name)
98
- if (installed == null) { c.fail(`${name} declared (${spec}) but not installed — run: ${pm.install.join(' ')}`); continue }
98
+ if (installed == null) { c.fail(`${name} declared (${spec}) but not installed — ${isRefusal(pm) ? pm.refused : `run: ${pm.install.join(' ')}`}`); continue }
99
99
  let latest: string
100
100
  try { latest = await latestVersion(c, name) } catch (e) {
101
101
  c.lines.push({ kind: 'skip', text: `${name} ${installed} — registry unreachable (${String(e).slice(0, 80)}), version drift unknown` })
@@ -113,8 +113,11 @@ export const rvlibVersions = check({
113
113
 
114
114
  const drift = stale.map((s) => `${s.name} ${s.installed} → ${s.latest}`).join(', ')
115
115
  const names = stale.map((s) => s.name)
116
+ if (isRefusal(pm)) {
117
+ c.warn(`newer rvlib live: ${drift}, not updating: ${pm.refused}`)
118
+ return
119
+ }
116
120
  const cmd = pm.update(names)
117
- if (pm.warn != null) c.warn(pm.warn)
118
121
  if (process.env['SHIPKIT_FROZEN'] === '1') {
119
122
  c.warn(`newer rvlib live: ${drift} (frozen — apply with: ${cmd.join(' ')})`)
120
123
  return
@@ -5,14 +5,17 @@
5
5
  import { Command, Option } from 'clipanion'
6
6
  import { existsSync } from 'fs'
7
7
  import { join } from 'path'
8
- import { npmLatest, resolveLatest, scanFleet, type FleetRepo } from '../fleet/scan-fleet.ts'
8
+ import { npmLatest, resolveLatest, scanFleet, updateRefusal, type FleetRepo } from '../fleet/scan-fleet.ts'
9
9
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
10
- import { detectPackageManager } from '../core/package-manager.ts'
10
+ import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
11
11
 
12
12
  const short = (name: string): string => name.replace(/^rvlib-/, '')
13
13
  // the hand-run fallback speaks the manager THAT repo installs with: telling
14
14
  // an npm consumer to run bun leaves its package-lock.json behind
15
- const manualUpdate = (r: FleetRepo): string => detectPackageManager(r.dir).update(r.behind.map((d) => d.name)).join(' ')
15
+ const manualUpdate = (r: FleetRepo): string => {
16
+ const pm = detectPackageManager(r.dir)
17
+ return isRefusal(pm) ? `<its package manager> update ${r.behind.map((d) => d.name).join(' ')} (${pm.refused})` : pm.update(r.behind.map((d) => d.name)).join(' ')
18
+ }
16
19
 
17
20
  export class FleetCommand extends Command {
18
21
  static override paths = [['fleet']]
@@ -92,6 +95,12 @@ export class FleetCommand extends Command {
92
95
  failed++
93
96
  continue
94
97
  }
98
+ const refused = updateRefusal(r)
99
+ if (refused != null) {
100
+ w(`WARN ${r.name}: not updating: ${refused}`)
101
+ failed++
102
+ continue
103
+ }
95
104
  const cli = join(r.dir, 'node_modules', 'rvlib-shipkit', 'src', 'cli.ts')
96
105
  if (!existsSync(cli)) {
97
106
  w(`WARN ${r.name}: no shipkit installed, so it cannot update itself — run: cd ${r.dir} && ${manualUpdate(r)}`)
@@ -142,7 +142,6 @@ function instanceOf(root: string, c: RepoConfig, row: CheckRow, stat: CheckStat
142
142
  keepsUntil: keepsUntil(row.decl.ttl, stat),
143
143
  recent: recent ?? [],
144
144
  }
145
- const wire = last?.lines ?? []
146
145
  return {
147
146
  id: `check:${id}`,
148
147
  text: feedLine(row, facts),
@@ -99,7 +99,7 @@ const SUITE_CLI = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/suite/sui
99
99
  * bunfig, its preload and its `units[].tests` budget apply, after the checks
100
100
  * its `deps` name. Its inputs are its dir and the dirs of the
101
101
  * sibling units its package.json names, derived rather than declared */
102
- function unitTestTasks(c: RepoConfig, root: string, io: FloorReader, subs: TestUnitDecl[], siblings: Map<string, string[]>, notes: string[], unfilled: { id: string; reason: string }[]): TaskDef[] {
102
+ function unitTestTasks(root: string, io: FloorReader, subs: TestUnitDecl[], siblings: Map<string, string[]>, notes: string[], unfilled: { id: string; reason: string }[]): TaskDef[] {
103
103
  const read = io.text
104
104
  const out: TaskDef[] = []
105
105
  for (const u of subs) {
@@ -258,7 +258,7 @@ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): Floo
258
258
  else { tasks.push(decorate(b.task)); sources[id] = b.source }
259
259
  }
260
260
  const siblings = siblingUnits(subs, io.text)
261
- for (const t of unitTestTasks(c, root, io, subs, siblings, notes, unfilled)) { tasks.push(t); sources[t.id] = 'unit' }
261
+ for (const t of unitTestTasks(root, io, subs, siblings, notes, unfilled)) { tasks.push(t); sources[t.id] = 'unit' }
262
262
  for (const t of unitTypecheckTasks(root, io, subs, siblings, unfilled)) { tasks.push(t); sources[t.id] = 'unit' }
263
263
  return { tasks, disabled, unfilled, sources, notes }
264
264
  }
@@ -34,7 +34,20 @@ import { nestedIn } from './nesting.ts'
34
34
  /** the prefix every contract folder carried before `.shipkit/` */
35
35
  export const LEGACY_CONTRACT_PREFIX = '.rv-'
36
36
 
37
- export const MIGRATION_MESSAGE = 'chore(machine): move the shipkit folders under .shipkit/'
37
+ const CONFIG_FILE = 'repo.config.ts'
38
+
39
+ /** what one pass did, in words: the folders it moved and the root files it
40
+ * dropped. Empty when it did neither, so no caller prints a sentence with a
41
+ * hole where the list should be */
42
+ export function layoutSummary(r: { moved: LayoutMove[]; removed: string[] }): string {
43
+ const list = (xs: string[]): string => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`)
44
+ const folders = r.moved.filter((m) => m.kind !== 'cache').map((m) => `${m.from}/`)
45
+ const parts = [
46
+ ...(folders.length > 0 ? [`move ${list(folders)} under ${SHIPKIT_DIR}/`] : []),
47
+ ...(r.removed.length > 0 ? [`drop the root ${list(r.removed)}, which shipkit writes under ${SHIPKIT_DIR}/`] : []),
48
+ ]
49
+ return parts.join(', and ')
50
+ }
38
51
 
39
52
  /** the contract names a typo is matched against */
40
53
  const KNOWN = ['identity', 'assets', 'comfy', 'private', 'reflections', 'social', 'journal', 'domain', 'itch', 'steam', 'discord'] as const
@@ -200,6 +213,39 @@ export function rebaseRelative(text: string, at: { from: string; to: string }, m
200
213
  .replace(/(\]\()(\.\.?\/[^)\s#]+)/g, (_m, lead: string, spec: string) => `${lead}${rebase(spec)}`)
201
214
  }
202
215
 
216
+ /** one repo.config.ts value that names a moved folder, as a path: the folder
217
+ * then a slash, behind an optional `!`, `/` or `./`. A word, a bare folder
218
+ * name and a longer name that merely starts the same stay as they are */
219
+ function rebasePathValue(v: string, moves: LayoutMove[]): string {
220
+ const m = /^(!?(?:\/|\.\/)?)(.*)$/s.exec(v)!
221
+ const rest = m[2]!
222
+ const hit = moves.find((x) => rest.startsWith(`${x.from}/`))
223
+ return hit == null ? v : `${m[1]}${hit.to}${rest.slice(hit.from.length)}`
224
+ }
225
+
226
+ /** PURE: repo.config.ts source with every quoted path that names a moved
227
+ * folder rewritten (`leakAllow` paths, `blockedPaths`, `gitignore` lines, a
228
+ * prose include, any other value): each of them is a repo path by contract */
229
+ export function sweepConfigSource(text: string, moves: LayoutMove[]): string {
230
+ if (moves.length === 0) return text
231
+ return text.replace(/(['"`])((?:\\.|(?!\1)[^\\\n])*)\1/g, (lit, q: string, body: string) => {
232
+ if (q === '`' && body.includes('${')) return lit
233
+ const next = rebasePathValue(body, moves)
234
+ return next === body ? lit : `${q}${next}${q}`
235
+ })
236
+ }
237
+
238
+ /** PURE: the loaded config with the same rewrite, so what this pass renders
239
+ * from it (the ignore block) reads the values the file now holds. Only plain
240
+ * objects and arrays are copied; functions and class instances are kept */
241
+ export function rebaseConfigPaths<T>(v: T, moves: LayoutMove[]): T {
242
+ if (typeof v === 'string') return rebasePathValue(v, moves) as T
243
+ if (Array.isArray(v)) return v.map((x) => rebaseConfigPaths(x, moves)) as T
244
+ if (v != null && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype)
245
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, rebaseConfigPaths(x, moves)])) as T
246
+ return v
247
+ }
248
+
203
249
  /** PURE: the .gitignore after the move. Old names turn into new ones, except
204
250
  * the folders still on disk (`keep`), whose lines keep guarding them. A
205
251
  * negation of one FILE inside a folder (`!.shipkit/social/README.md`) is dead
@@ -400,8 +446,10 @@ function inProgress(root: string): string | null {
400
446
  export type MigrateOpts = {
401
447
  config?: RepoConfig
402
448
  /** re-render the SK004 block into this .gitignore text (SK004 owns the
403
- * block). `legacy`: an old folder is still on disk, so its name stays ignored */
404
- renderGitignore?: (text: string, legacy: boolean) => string
449
+ * block). `legacy`: an old folder is still on disk, so its name stays
450
+ * ignored. `config`: the repo config with its paths already moved, which
451
+ * is what the block must be rendered from */
452
+ renderGitignore?: (text: string, legacy: boolean, config?: RepoConfig) => string
405
453
  }
406
454
 
407
455
  /** tracked symlinks, with where each points */
@@ -442,8 +490,13 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
442
490
  const targets = Object.fromEntries(plan.moves.filter((m) => m.kind === 'contract').map((m) => [m.from, m.to]))
443
491
  const sources = plan.moves.filter((m) => m.kind !== 'cache').map((m) => m.from)
444
492
  const tracked = inGit ? lsFiles(root, [...sources, ...legacyStatus]) : []
445
- // a moved file is rebased whether or not it names an old folder
446
- const swept = inGit ? [...new Set([...sweepTargets(root, contractNames), ...tracked.filter((t) => /\.(m?[jt]sx?|json|md)$/.test(t))])] : []
493
+ // a moved file is rebased whether or not it names an old folder, and
494
+ // repo.config.ts whenever one of its values names one
495
+ const configNames = inGit && lsFiles(root, [CONFIG_FILE]).length > 0 && sweepConfigSource(readFileSync(join(root, CONFIG_FILE), 'utf8'), plan.moves) !== readFileSync(join(root, CONFIG_FILE), 'utf8') ? [CONFIG_FILE] : []
496
+ // a legacy generated file is dropped, never rewritten: a rewrite would
497
+ // write it back after the drop, and the commit would keep it
498
+ const dropped = new Set<string>(legacyStatus)
499
+ const swept = inGit ? [...new Set([...sweepTargets(root, contractNames), ...tracked.filter((t) => /\.(m?[jt]sx?|json|md)$/.test(t)), ...configNames])].filter((f) => !dropped.has(f)) : []
447
500
  const links = inGit ? trackedLinks(root) : []
448
501
  // commit the ignore file when git tracks it, or when this pass creates it:
449
502
  // it is what keeps the moved private folders out. An existing untracked
@@ -464,7 +517,8 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
464
517
  const text = readFileSync(join(root, rel), 'utf8')
465
518
  if (text.includes(SWEEP_SKIP)) continue
466
519
  const now = movedTo(moves, rel)
467
- const next = sweepText(rebaseRelative(text, { from: rel, to: now }, moves), targets, { from: rel, to: now })
520
+ const paths = rel === CONFIG_FILE ? sweepConfigSource(text, moves) : text
521
+ const next = sweepText(rebaseRelative(paths, { from: rel, to: now }, moves), targets, { from: rel, to: now })
468
522
  if (next !== text) writes.push({ now, text: next })
469
523
  }
470
524
  const relinks: { now: string; target: string }[] = []
@@ -486,7 +540,8 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
486
540
 
487
541
  const gi = join(root, '.gitignore')
488
542
  const giOriginal = existsSync(gi) ? readFileSync(gi, 'utf8') : ''
489
- const render = (text: string, legacy: boolean): string => (o.renderGitignore != null ? o.renderGitignore(text, legacy) : text)
543
+ const config = o.config == null ? undefined : rebaseConfigPaths(o.config, moves)
544
+ const render = (text: string, legacy: boolean): string => (o.renderGitignore != null ? o.renderGitignore(text, legacy, config) : text)
490
545
  const stillOld = (): string[] => dirsIn(root).filter((d) => d.startsWith(LEGACY_CONTRACT_PREFIX))
491
546
  try {
492
547
  // 1. both layouts ignored while any folder is in flight
@@ -550,7 +605,11 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
550
605
  const giChanged = giNew || (giTracked && readFileSync(join(root, '.gitignore'), 'utf8') !== giOriginal)
551
606
  const touched = [...new Set([...tracked, ...added, ...sweptTracked, ...(giChanged ? ['.gitignore'] : [])])]
552
607
  if (touched.length === 0) return out
553
- const landed = machineCommit({ anchor: root, message: MIGRATION_MESSAGE, paths: touched.map((p) => join(root, p)) })
608
+ // the title names what this commit carries; a pass that did neither has
609
+ // nothing tracked to land and returned above
610
+ const what = layoutSummary(out)
611
+ if (what === '') return out
612
+ const landed = machineCommit({ anchor: root, message: `chore(machine): ${what}`, paths: touched.map((p) => join(root, p)) })
554
613
  if (!landed) return { ...out, uncommitted: true, pending: touched }
555
614
  out.commit = git(root, ['rev-parse', '--short', 'HEAD']).out.trim()
556
615
  return out
@@ -7,9 +7,8 @@
7
7
  // TRACKED locks decide, because an untracked bun.lock is exactly what a wrong
8
8
  // update leaves behind, and reading it as the repo's choice would make that
9
9
  // first mistake permanent. Nothing tracked (a fresh tree, no git) falls back
10
- // to what sits on disk. Among several, the order below IS the preference, and
11
- // the loser is named so the drift is visible. No lock at all is bun, the
12
- // house default.
10
+ // to what sits on disk. No lock at all, or locks of two managers, is a
11
+ // REFUSAL that names what it saw: a guessed manager is the failure above.
13
12
  import { existsSync } from 'fs'
14
13
  import { join } from 'path'
15
14
  import { gitBin } from './git-bin.ts'
@@ -24,10 +23,11 @@ export type PackageManager = {
24
23
  install: string[]
25
24
  /** move these deps to their latest version, manifest AND lock in one call */
26
25
  update: (names: string[]) => string[]
27
- /** set when several managers own a lock here, so the pick is a guess */
28
- warn?: string
29
26
  }
30
27
 
28
+ /** the manager, or why the lock files do not name exactly one */
29
+ export type PackageManagerPick = PackageManager | { refused: string }
30
+
31
31
  const BUN_LOCKS = ['bun.lock', 'bun.lockb']
32
32
  export const LOCK_FILES = [...BUN_LOCKS, 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json']
33
33
 
@@ -70,17 +70,19 @@ export function trackedLocks(root: string): string[] {
70
70
  }
71
71
 
72
72
  /** the manager this repo installs with, read off its lock files */
73
- export function detectPackageManager(root: string, tracked: string[] = trackedLocks(root)): PackageManager {
73
+ export function detectPackageManager(root: string, tracked: string[] = trackedLocks(root)): PackageManagerPick {
74
74
  const present = LOCK_FILES.filter((f) => existsSync(join(root, f)))
75
75
  const owned = present.filter((f) => tracked.includes(f))
76
76
  const locks = owned.length > 0 ? owned : present
77
- const has = (f: string): boolean => locks.includes(f)
78
- let pick = bun
79
- if (BUN_LOCKS.some(has)) pick = bun
80
- else if (has('pnpm-lock.yaml')) pick = pnpm
81
- else if (has('yarn.lock')) pick = yarn(existsSync(join(root, '.yarnrc.yml')))
82
- else if (has('package-lock.json')) pick = npm
83
- const others = locks.filter((f) => !pick.locks.includes(f))
84
- if (others.length === 0) return pick
85
- return { ...pick, warn: `several lock files at the root (${locks.join(', ')}) — updating with ${pick.name}, so ${others.join(', ')} will drift; delete the one this repo does not install with` }
77
+ const managers = [
78
+ ...(locks.some((f) => BUN_LOCKS.includes(f)) ? [bun] : []),
79
+ ...(locks.includes('pnpm-lock.yaml') ? [pnpm] : []),
80
+ ...(locks.includes('yarn.lock') ? [yarn(existsSync(join(root, '.yarnrc.yml')))] : []),
81
+ ...(locks.includes('package-lock.json') ? [npm] : []),
82
+ ]
83
+ if (managers.length === 1) return managers[0]!
84
+ if (managers.length === 0) return { refused: `no lock file at the root (looked for ${LOCK_FILES.join(', ')}), so which package manager installs this repo is unknown: install once with the right one and commit its lock` }
85
+ return { refused: `lock files of ${managers.length} package managers ${owned.length > 0 ? 'are tracked' : 'sit at the root'} (${locks.join(', ')}), so which one installs this repo is unknown: delete the lock of the one it does not install with` }
86
86
  }
87
+
88
+ export const isRefusal = (p: PackageManagerPick): p is { refused: string } => 'refused' in p
@@ -6,15 +6,14 @@
6
6
  // (write-status.ts — atomic + lock) unless status:false, and the fix pass plus
7
7
  // some checks WRITE, so a run is not read-only unless frozen is set and
8
8
  // --no-fix is passed.
9
- import { join } from 'path'
10
9
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
11
10
  import { reportToMotherhome } from './motherhome.ts'
12
11
  import { runCheckPass, type CheckOpts } from './run-check.ts'
13
12
  import { poolLines } from './slots.ts'
14
13
  import { runFixPass } from './run-fixers.ts'
15
- import { formatLine, type Line } from './wire.ts'
14
+ import { formatLine } from './wire.ts'
16
15
  import { writeStatus } from './write-status.ts'
17
- import { allTasks, composeChecks, envTier, FULL_TESTS_ENV, gateInventory, type TestTier } from '../ledger/gate-checks.ts'
16
+ import { allTasks, composeChecks, envTier, FULL_TESTS_ENV, type TestTier } from '../ledger/gate-checks.ts'
18
17
  import { CHECK_TABLE_FILE, writeCheckTable } from '../ledger/check-table.ts'
19
18
  import { Ledger } from '../ledger/ledger.ts'
20
19
  import { RunLog, type InvokedBy } from '../ledger/run-log.ts'
@@ -17,11 +17,14 @@ export type FixOpts = {
17
17
  * not wire-shaped, which the frozen output contract forbids */
18
18
  quiet?: boolean
19
19
  print?: (line: string) => void
20
+ /** the config as it stands on disk now, when the caller holds one: a
21
+ * second load of a rewritten repo.config.ts can hit the module cache */
22
+ config?: RepoConfig
20
23
  }
21
24
 
22
25
  export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
23
26
  const print = o.print ?? ((l: string) => console.log(l))
24
- const config = await loadRepoConfig(root)
27
+ const config = o.config ?? (await loadRepoConfig(root))
25
28
  const env: CheckEnv = { root, config }
26
29
  // a fresh context per body: a fixer may rewrite the config, and the body
27
30
  // after it must read the one on disk now
@@ -12,13 +12,13 @@ import { existsSync, readFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import { fileURLToPath } from 'url'
14
14
  import { armHooks } from '../hooks/run-hooks.ts'
15
- import type { RepoConfig } from '../repo-config.ts'
15
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
16
16
  import { runFix } from './run-fix.ts'
17
17
  import { routesToShipkit } from './canonical-scripts.ts'
18
18
  import { discoverLegacyChecks } from './discover-legacy-checks.ts'
19
19
  import { repoCheckFiles } from './discover-checks.ts'
20
20
  import { mutateLease, withMutateLease } from './locked-write.ts'
21
- import { migrateLayout } from './migrate-layout.ts'
21
+ import { layoutSummary, migrateLayout } from './migrate-layout.ts'
22
22
  import { renderPolicy } from '../checks/SK004-gitignore-policy.check.ts'
23
23
  import { spawnWithTimeout } from './spawn-timeout.ts'
24
24
  import { gitBin } from './git-bin.ts'
@@ -180,20 +180,29 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
180
180
  return { ran: false, touched: [], failed: false, reshaped: false }
181
181
  }
182
182
 
183
- async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, verify: boolean): Promise<FixPassResult> {
183
+ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, verify: boolean): Promise<FixPassResult> {
184
+ let config = loaded
185
+ const shapeBefore = shapeStamp(root)
184
186
  // the old root layout moves under .shipkit/ before anything else reads a
185
187
  // path: a move is not a decision, so it lands as one machine commit and
186
188
  // only what a machine cannot decide reaches a human. Runs before the tree
187
189
  // snapshot, and before hooks arm, because the hooks folder may be what moves
188
190
  if (!verify) {
189
- const m = migrateLayout(root, { config, renderGitignore: (t, legacy) => renderPolicy(t, config, legacy) })
190
- if (m.moved.length > 0 || m.removed.length > 0) {
191
- const what = m.moved.filter((x) => x.kind !== 'cache').map((x) => `${x.from}/`).join(' ')
191
+ const m = migrateLayout(root, { config, renderGitignore: (t, legacy, cfg) => renderPolicy(t, cfg ?? config, legacy) })
192
+ // the move rewrote repo.config.ts, so the config loaded before it names
193
+ // folders that are gone, and every fixer below must read the new one
194
+ if (m.swept.includes('repo.config.ts')) {
195
+ try { config = await loadRepoConfig(root, { fresh: true }) } catch (e) {
196
+ o.print(`WARN repo.config.ts does not load after the move rewrote it: ${String((e as Error)?.message ?? e).slice(0, 160)}`)
197
+ }
198
+ }
199
+ const what = layoutSummary(m)
200
+ if (what !== '') {
192
201
  if (m.uncommitted === true) {
193
- o.print(`WARN moved ${what} under .shipkit/, and the commit did not land`)
202
+ o.print(`WARN layout: ${what}. The commit did not land`)
194
203
  const p = m.pending ?? []
195
- o.print(` fix: commit exactly these paths, never a bare commit: git commit -m 'chore: move the shipkit folders under .shipkit/' -- ${p.slice(0, 12).join(' ')}${p.length > 12 ? ` (+${p.length - 12} more, listed by git status)` : ''}`)
196
- } else if (what !== '') o.print(`ok: moved ${what} under .shipkit/${m.commit != null ? `, committed as ${m.commit}` : ''}`)
204
+ o.print(` fix: commit exactly these paths, never a bare commit: git commit -m 'chore: ${what}' -- ${p.slice(0, 12).join(' ')}${p.length > 12 ? ` (+${p.length - 12} more, listed by git status)` : ''}`)
205
+ } else o.print(`ok: layout: ${what}${m.commit != null ? `, committed as ${m.commit}` : ''}`)
197
206
  }
198
207
  if (m.deferred != null) {
199
208
  o.print(`WARN the move under .shipkit/ is deferred: ${m.deferred}`)
@@ -218,7 +227,6 @@ async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, ver
218
227
  for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
219
228
  for (const d of disabled) o.print(`skip: ${d.id} (${d.reason})`)
220
229
  const before = treeState(root)
221
- const shapeBefore = shapeStamp(root)
222
230
 
223
231
  for (const s of slots) {
224
232
  const r = await spawnWithTimeout(s.cmd, { cwd: root, timeoutMs: FIXER_TIMEOUT_MS })
@@ -228,7 +236,7 @@ async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, ver
228
236
  o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
229
237
  }
230
238
  // structural repo-config repair is never implicit, and never in verify mode
231
- if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print })
239
+ if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print, config })
232
240
 
233
241
  const after = treeState(root)
234
242
  const reshaped = shapeStamp(root) !== shapeBefore
@@ -14,7 +14,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
14
14
  import { homedir } from 'os'
15
15
  import { dirname, join } from 'path'
16
16
  import { fileURLToPath } from 'url'
17
- import { detectPackageManager } from './package-manager.ts'
17
+ import { detectPackageManager, isRefusal } from './package-manager.ts'
18
18
  import { gitBin } from './git-bin.ts'
19
19
 
20
20
  const PKG = 'rvlib-shipkit'
@@ -104,7 +104,9 @@ export async function maybeSelfUpdateAndRestart(root: string): Promise<void> {
104
104
  const plan = planSelfUpdate({ ...base, latest: await latestCached(), manifestDirty: manifestDirty(root) })
105
105
  if (plan.action === 'warn') { console.error(`WARN self-update: ${plan.reason}`); return }
106
106
  if (plan.action !== 'update') return
107
- const cmd = detectPackageManager(root).update([PKG])
107
+ const pm = detectPackageManager(root)
108
+ if (isRefusal(pm)) { console.error(`WARN self-update: rvlib-shipkit ${plan.installed} is behind ${plan.latest}, not updating: ${pm.refused}`); return }
109
+ const cmd = pm.update([PKG])
108
110
  console.error(`ok: rvlib-shipkit ${plan.installed} → ${plan.latest}, self-updating (${cmd.join(' ')})`)
109
111
  const up = Bun.spawnSync(cmd, { cwd: root, stdout: 'inherit', stderr: 'inherit' })
110
112
  if (up.exitCode !== 0) { console.error(`WARN self-update failed (exit ${up.exitCode}), continuing on ${plan.installed}`) ; return }
@@ -12,6 +12,7 @@
12
12
  // so the fix travels through that one code path.
13
13
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
14
14
  import { join } from 'path'
15
+ import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
15
16
 
16
17
  export type Pkg = { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
17
18
 
@@ -140,3 +141,18 @@ export async function npmLatest(name: string): Promise<string | null> {
140
141
  if (!res.ok) return null
141
142
  return ((await res.json()) as Pkg).version ?? null
142
143
  }
144
+
145
+ /** the first release whose SK005 updates through the manager the repo's lock
146
+ * files name. An older one runs bun whatever the lock says */
147
+ export const PM_AWARE_SINCE = '1.0.0'
148
+
149
+ /** why `fleet --update` must not hand this repo its own update, or null.
150
+ * The fleet runs the REPO's shipkit, so what matters is what THAT binary
151
+ * does: one older than PM_AWARE_SINCE updates an npm repo with bun */
152
+ export function updateRefusal(r: FleetRepo): string | null {
153
+ const pm = detectPackageManager(r.dir)
154
+ if (isRefusal(pm)) return `cannot tell which package manager installs it: ${pm.refused}`
155
+ const own = r.deps.find((d) => d.name === 'rvlib-shipkit')?.installed
156
+ if (pm.name === 'bun' || own == null || !isBehind(own, PM_AWARE_SINCE)) return null
157
+ return `its shipkit ${own} updates with bun whatever the lock says, and it installs with ${pm.name}. Run: cd ${r.dir} && ${pm.update(r.behind.map((d) => d.name)).join(' ')}`
158
+ }
@@ -80,6 +80,15 @@ export type ParsedPatterns = {
80
80
 
81
81
  const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
82
82
 
83
+ /** a key shape is a token, so it starts where a token starts: `ask-the-...`
84
+ * or `desk-lamp-...` is a word that ends in `sk`, never a key */
85
+ export const SK_KEY_ROW = 're:(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{20,}'
86
+
87
+ /** rows the hooks seeded before their shape was fixed, read as the fixed
88
+ * shape. Their id stays the seeded row, so a waiver written against its
89
+ * fingerprint keeps working */
90
+ const SEEDED_ROW_FIXES: Record<string, string> = { 're:sk-[A-Za-z0-9_-]{20,}': SK_KEY_ROW }
91
+
83
92
  /** Parse the keyword file: blank lines and `#` comments dropped, `re:<pattern>`
84
93
  * rows become case-insensitive regexes, `w:<word>` rows match on word
85
94
  * boundaries, everything else a case-insensitive substring.
@@ -95,7 +104,7 @@ export function parsePatterns(text: string): ParsedPatterns {
95
104
  const row = raw.trim()
96
105
  if (row === '' || row.startsWith('#')) continue
97
106
  if (row.startsWith('re:')) {
98
- try { regexes.push({ row, re: new RegExp(row.slice(3), 'i') }) }
107
+ try { regexes.push({ row, re: new RegExp((SEEDED_ROW_FIXES[row] ?? row).slice(3), 'i') }) }
99
108
  catch { /* a malformed row must not disable the whole guard */ }
100
109
  } else if (row.startsWith('w:')) {
101
110
  const word = row.slice(2).trim().toLowerCase()
@@ -8,7 +8,7 @@ import { dirname, join } from 'path'
8
8
  import { fileURLToPath } from 'url'
9
9
  import { KEYWORD_FILE, LAYOUT } from '../core/layout.ts'
10
10
  import { commitMessageOffenses } from './commit-message.ts'
11
- import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
11
+ import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, SK_KEY_ROW, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
12
12
  import { runRootIdentity } from './root-identity.ts'
13
13
  import { gitRoot } from '../core/git-root.ts'
14
14
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
@@ -143,14 +143,14 @@ export function installHooks(root: string, binInvocation: string, keywordFile?:
143
143
  // generic secret shapes only. private names live in the machine-global
144
144
  // ~/.shipkit/private/banned-keywords.txt, which the guard reads on every run
145
145
  // (never copied into a repo: a copy rots and a copy can leak)
146
- const KEYWORD_STARTER = `# banned-keywords — the leak guard greps every commit (added lines, paths,
146
+ export const KEYWORD_STARTER = `# banned-keywords — the leak guard greps every commit (added lines, paths,
147
147
  # message) for these rows. one row = one case-insensitive substring, or
148
148
  # re:<regex>. add repo-specific names here; machine-wide private names go in
149
149
  # ~/.shipkit/private/banned-keywords.txt, read automatically alongside this file.
150
150
  # this file is gitignored on purpose: the list itself is private.
151
151
  re:-----BEGIN [A-Z ]*PRIVATE KEY-----
152
152
  re:AKIA[0-9A-Z]{16}
153
- re:sk-[A-Za-z0-9_-]{20,}
153
+ ${SK_KEY_ROW}
154
154
  re:ghp_[A-Za-z0-9]{36}
155
155
  re:xox[baprs]-[A-Za-z0-9-]{10,}
156
156
  `
@@ -8,11 +8,10 @@
8
8
  // a slot that can't fill emits a visible reason, disabling via
9
9
  // `defaults: { x: false }` prints a skipped-by-config line — never silence.
10
10
  // The `contracts` task is always present when any contract applies.
11
- import { existsSync, readFileSync } from 'fs'
11
+ import { existsSync } from 'fs'
12
12
  import { isAbsolute, join, normalize } from 'path'
13
- import { fileURLToPath } from 'url'
14
13
  import { ROUTED_SCRIPTS } from './core/canonical-scripts.ts'
15
- import { FLOOR_IDS, resolveFloor, type FloorResolution } from './core/floor.ts'
14
+ import { FLOOR_IDS } from './core/floor.ts'
16
15
  export { resolveFloor, type FloorResolution, type FloorSource } from './core/floor.ts'
17
16
  import type { ContractManifest } from './contracts/contract-types.ts'
18
17
  import type { ProseConfig } from './prose/run-prose.ts'
@@ -379,8 +379,12 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
379
379
  const chars = (await page.locator('body').innerText().catch(() => '')).trim().length
380
380
  const matches = waitFor == null ? 0 : await page.locator(waitFor).count().catch(() => 0)
381
381
  const overflowPx = await page.evaluate(() => {
382
- const de = document.documentElement
383
- return Math.max(de.scrollWidth - de.clientWidth, document.body.scrollWidth - document.body.clientWidth)
382
+ // runs in the browser, typed locally: a consumer compiling this
383
+ // file may have no dom lib
384
+ type Box = { scrollWidth: number; clientWidth: number }
385
+ const doc = (globalThis as unknown as { document: { documentElement: Box; body: Box } }).document
386
+ const de = doc.documentElement
387
+ return Math.max(de.scrollWidth - de.clientWidth, doc.body.scrollWidth - doc.body.clientWidth)
384
388
  }).catch(() => 0)
385
389
  await page.screenshot({ path: join(shots, `${routeSlug(label)}.png`), fullPage: true })
386
390
  return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, overflowPx })
@@ -7,7 +7,7 @@
7
7
  // Depth = 0-1 BFS from the index (eager edge keeps depth, lazy adds 1);
8
8
  // unreachable files stay in the graph with no depth (lint-md.ts turns those,
9
9
  // plus dangling refs, into failures — the graph itself is a viewer).
10
- import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
10
+ import { readdirSync, readFileSync, statSync } from 'fs'
11
11
  import { basename, dirname, join, relative, resolve } from 'path'
12
12
  import type { MdEdge, MdGraph, MdNode } from 'rvlib-shipkit/src/wiki/md-graph.ts'
13
13