rvlib-shipkit 1.8.0 → 1.9.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 CHANGED
@@ -1,3 +1,35 @@
1
+ ## 1.9.0
2
+
3
+ - **A commit in a linked worktree passes the pre-commit hook.** git sets GIT_DIR for a hook there, and the installed copy then asked git where the house sits and got the worktree back, so it never handed off to the house source. Then it ran itself and failed every built-in check with ENOENT on a `?v=` stamped path. A directory with its own `.git` is now asked with the locating variables stripped, and an installed copy that runs itself (CI, `SHIPKIT_NO_HOME`) loads its own check files.
4
+ - **A broken built-in check no longer aborts every repo's gate.** In a repo that only consumes the toolkit, a built-in that does not load or is refused is one non-blocking WARN that names the file. The repo that hosts the toolkit source still fails on it. Before, it threw a misleading "names the page that OWNS the invariant" error before any check ran.
5
+ - **`sk gp open <id>` probes before it trusts "up".** A process that died since pm last looked now starts before its window opens, instead of a window on a dead port.
6
+ - **A linked worktree of a house repo is no longer a house repo of its own.** The home, the global context and pm skip a sibling whose `.git` is a file, so its global processes no longer collide with the main checkout's ids and ports.
7
+ - **`sk ci --only <check>` leaves the tree alone.** A narrowed run no longer runs the fix pass unless `--fix` is named, so `bun run test`, `lint` and `typecheck`, which route through `--only`, never reformat a shared tree. Before, the formatter ran on every narrowed run and rewrote 478 files of one checkout, other agents' uncommitted work included. A whole gate still repairs first, and `--no-fix` still turns it off.
8
+
9
+ - **The machine pool admits fewer checks on a loaded machine.** A slot is a check, and a check is often a whole multi core tool, so eight slots on eight cores could put several times the cores to work. Past a 1 minute load of one per core the pool now admits slots in proportion to the load, never fewer than one: at load 20 on 8 cores it admits 3 of 8. A slot already held is never taken back. A waiting check prints the load and what the pool admits.
10
+
11
+ - **The plan holds save runs while someone gates the repo.** A check a save moved now also waits 10 minutes after the latest run a terminal, an agent or a click started in its repo. On one repo with several agents, most of the plan's test runs had started within those 10 minutes and measured a tree the agents were gating themselves. The run is held, not dropped, so the tree still gets one verdict once they stop. `sk planned` shows the hold as a `deferred:` line.
12
+ - **A suite over the CPU budget in its lone run runs alone once more, and the smaller figure stands.** On a busy Apple silicon machine the scheduler can spill a lone run onto the efficiency cores, where the same work burns up to 4x the CPU time, so a suite inside its budget failed one run in three and passed a minute later. A spill only raises CPU time, so the second run can clear a false red and never hide a real one. A wait or a suite with no CPU figure keeps its one run, and a green gate pays nothing.
13
+
14
+ - **The gate's test line says the command the suite runner ran.** A plain `bun test` body does not run as written under `sk ci`: the runner adds `--parallel=<n>` and `--no-isolate`. The line now reads `ok: test 412 tests (ran 1.5s) as \`bun test --parallel=7 --no-isolate\` (added --parallel=7 --no-isolate)`, so a reader of package.json knows that `bun run test` outside the gate runs another way. Per file ignores stay out of it.
15
+
16
+ - **A test phase killed at its ceiling names the suite that hung, and both things that hang a worker.** bun lists the suites still running when a phase is killed, and the parallel phase now reads that list, so the line says `with tests/x.test.ts still running`. It names a synchronous loop and `expect(p).resolves` or `.rejects` on a promise that never settles: bun's per test timeout stops neither, while a plain `await` of such a promise fails at the per test timeout. And a red suite is no longer judged on time: its time is its failure's, so a test that timed out no longer runs again alone and no longer adds a budget FAIL.
17
+ - **The gate typecheck keeps its own incremental cache, and a release can publish with bun.** The built-in typecheck passes `--incremental --tsBuildInfoFile <project dir>/node_modules/.cache/shipkit/<config>.tsbuildinfo` for every project, so a `tsc --watch` task or a raw script on the same tsconfig no longer writes the cache the gate reads. A config without `incremental` gets it too: rvlib-shipkit's own typecheck went from 0.81 s to 0.35 s warm. `release.publisher: 'bun'` makes `sk release` and `sk bump` run `bun publish --access public`, with the token from `release.npmTokenCommand` in `NPM_CONFIG_TOKEN`, for a repo that tests its tarball with `bun pm pack`. The default stays npm, and an unknown publisher fails `defineRepo`.
18
+
19
+ - **`alert`, `confirm` and `prompt` work in the shipkit app and in every process app.** The app window answered each one at once with no panel: `confirm` returned false and `prompt` null, so a page button behind one did nothing. Each now shows as a sheet on its window. The prompt's text field holds the default text, selected. Return is OK and Esc is Cancel. An app picks it up the next time `sk gui` or `sk gp open <id> --app` opens it, because a changed source rebuilds the app and quits the running copy.
20
+
21
+ - **The `prompt` and `seed` vars contract binds only the workflows a kit kind drives.** Loading the workflows folder no longer refuses a module that lacks them, so a workflow the repo drives with its own tools (portraits, voice, music) names its vars freely. The check runs when the atelier resolves the workflow for a kind, and the error names that kind.
22
+
23
+ - **`sk assets gen` works on a repo whose comfy-ts is newer than shipkit's.** The one comfy-ts instance of the process is now created through the copy the repo's workflow modules import, so a module gets the vars builder of its own version. Before, shipkit's older copy rooted it, and a module using a newer var kind failed every roll (`v.model is not a function`).
24
+
25
+ - **The og card draws accented names.** The default card names a font family list (Helvetica, Arial, DejaVu Sans, Liberation Sans, Noto Sans, then sans-serif). The generic sans-serif alone resolved on macOS to a face with no Latin-1 accents, so a French name rendered with a missing glyph box.
26
+
27
+ - **`sk smoke` serves canvas apps: a route takes `minChars`**, the visible text a settled route needs (default 40). A game that draws into one canvas declares `{ path: '/', waitFor: 'canvas', minChars: 0 }`, and a missing canvas still fails the route.
28
+
29
+ - **`serve: { assets: false }` hides the asset kit from the hub page and from `sk needs`**, for a repo whose own tool mounts `studioRoutes` and owns the picking. Workflows and posts stay.
30
+
31
+ - **`defineKit({ rollFormat: 'webp-lossless' })` stores new image rolls as lossless webp**, the same pixels at about 60 percent of the png bytes, encoded with sharp. Existing png rolls stay readable beside them.
32
+
1
33
  ## 1.8.0
2
34
 
3
35
  - **The quick tier can run only the suites a change reaches** (opt in: `tests: { select: true }`, or `SHIPKIT_TEST_SELECT=1` for one run). It keeps the snapshot of the last run and its red suites, and runs those plus every suite whose import closure, or whose recorded runtime reads, a change since then reaches. What it cannot see it runs: an unclaimed change under a code root, a spawn it does not know, a module that threw while loading, a run config file. The full tier still runs every suite.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
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",
@@ -40,6 +40,19 @@ const varsOf = (wf: DefinedWorkflow): Record<string, AnyVar> => wf.vars as Recor
40
40
  export const isRollImage = (img: Pick<MediaImage, 'ComfyNode'>): boolean =>
41
41
  !/preview/i.test(img.ComfyNode?.class_type ?? '')
42
42
 
43
+ /** the ComfyTS class of the comfy-ts a bare import from `dir` reaches,
44
+ * shipkit's own when the repo carries none */
45
+ async function comfyTsOf(dir: string): Promise<typeof ComfyTS> {
46
+ let path: string
47
+ try {
48
+ path = Bun.resolveSync('comfy-ts', dir)
49
+ } catch {
50
+ return ComfyTS
51
+ }
52
+ const mod = (await import(path)) as { ComfyTS?: typeof ComfyTS }
53
+ return typeof mod.ComfyTS?.create === 'function' ? mod.ComfyTS : ComfyTS
54
+ }
55
+
43
56
  /** dir = the flat folder holding the <id>.cflow.ts workflow modules.
44
57
  * root pins `.comfy-ts/` (drafts, hosts cache) to the repo, cwd-independent. */
45
58
  export async function loadCflowWorkflows(root: string, dir: string): Promise<Map<string, LoadedWorkflow>> {
@@ -51,9 +64,12 @@ export async function loadCflowWorkflows(root: string, dir: string): Promise<Map
51
64
  // module then returns this same instance. comfy-ts keeps ONE instance per
52
65
  // process, and the daemon serves every repo from one process, so only the
53
66
  // first repo roots it. What it roots is the host caches, shared by every
54
- // repo on the same host; each repo's drafts resolve from its own root
55
- if ((globalThis as { comfyts?: unknown }).comfyts == null) ComfyTS.create({ rootPath: root })
67
+ // repo on the same host; each repo's drafts resolve from its own root.
68
+ // The instance is created through the copy the modules import, never
69
+ // shipkit's own: a host hands its vars builder to defineWorkflow, so an
70
+ // older copy gives a newer module a v without its newer var kinds
56
71
  if (!existsSync(dir)) fail(`${dir}/ missing — ship at least one <id>.cflow.ts comfy-ts workflow module (copy from the rv-comfy template)`)
72
+ if ((globalThis as { comfyts?: unknown }).comfyts == null) (await comfyTsOf(dir)).create({ rootPath: root })
57
73
  const out = new Map<string, LoadedWorkflow>()
58
74
  for (const f of readdirSync(dir).filter((x) => x.endsWith('.cflow.ts')).sort()) {
59
75
  const file = join(dir, f)
@@ -65,24 +81,25 @@ export async function loadCflowWorkflows(root: string, dir: string): Promise<Map
65
81
  // so letting them diverge would strand every per-asset draft
66
82
  if (wf.spec.id != null && wf.spec.id !== id) fail(`workflow file '${f}' declares id '${wf.spec.id}' — the file name IS the id, rename one`)
67
83
  wf.spec.id ??= id
68
- const vars = varsOf(wf)
69
- if (vars.prompt?.kind !== 'prompt' || vars.seed?.kind !== 'seed')
70
- fail(`workflow '${id}' must declare vars.prompt (v.prompt) and vars.seed (v.seed) — shipkit drives both`)
71
84
  out.set(id, { id, root, file, wf })
72
85
  }
73
86
  if (out.size === 0) fail(`${dir}/ has no *.cflow.ts workflow modules — seed one from the rv-comfy template`)
74
87
  return out
75
88
  }
76
89
 
77
- /** Resolve a workflow: explicit id, else the sole loaded workflow. */
78
- export function workflowFor(workflows: Map<string, LoadedWorkflow>, ref: { workflow?: string; assetId?: string }): LoadedWorkflow {
79
- if (ref.workflow != null) {
80
- const lw = workflows.get(ref.workflow)
81
- if (lw == null) fail(`asset '${ref.assetId ?? '?'}': unknown workflow '${ref.workflow}' (have: ${[...workflows.keys()].join(', ')})`)
82
- return lw
83
- }
84
- if (workflows.size === 1) return [...workflows.values()][0]!
85
- return fail(`asset '${ref.assetId ?? '?'}' names no workflow and ${workflows.size} are loaded (${[...workflows.keys()].join(', ')}) — set workflow on the kind`)
90
+ /** Resolve the workflow a kit kind drives: explicit id, else the sole loaded
91
+ * workflow. The atelier writes prompt and seed, so the contract binds here,
92
+ * never at folder load: a workflow no kind drives names its vars freely. */
93
+ export function workflowFor(workflows: Map<string, LoadedWorkflow>, ref: { workflow?: string; kind?: string; assetId?: string }): LoadedWorkflow {
94
+ const lw = ref.workflow != null
95
+ ? (workflows.get(ref.workflow) ?? fail(`asset '${ref.assetId ?? '?'}': unknown workflow '${ref.workflow}' (have: ${[...workflows.keys()].join(', ')})`))
96
+ : workflows.size === 1
97
+ ? [...workflows.values()][0]!
98
+ : fail(`asset '${ref.assetId ?? '?'}' names no workflow and ${workflows.size} are loaded (${[...workflows.keys()].join(', ')}) — set workflow on the kind`)
99
+ const vars = varsOf(lw.wf)
100
+ if (vars.prompt?.kind !== 'prompt' || vars.seed?.kind !== 'seed')
101
+ fail(`kind '${ref.kind ?? '?'}' drives workflow '${lw.id}', which must declare vars.prompt (v.prompt) and vars.seed (v.seed) — the atelier writes both`)
102
+ return lw
86
103
  }
87
104
 
88
105
  /** Apply a name→value record onto a workflow's vars: strings go through
@@ -4,9 +4,9 @@
4
4
  // in both (before this, canvas draw lived only in the studio and the CLI
5
5
  // refused canvas kinds — A30). Comfy context is resolved LAZILY: canvas-only
6
6
  // kits never touch a comfy config or the box.
7
- import { mkdirSync } from 'fs'
7
+ import { mkdirSync, unlinkSync } from 'fs'
8
8
  import { join } from 'path'
9
- import type { FoundAsset } from '../kit-config.ts'
9
+ import type { FoundAsset, RollFormat } from '../kit-config.ts'
10
10
  import { generateComfyTs, loadCflowWorkflows, workflowFor, type ComfyTsRuntime, type OnProgress } from './comfyts-gen.ts'
11
11
  import { recordParents, type ParentContext } from './derived.ts'
12
12
  import { generateGemini } from './gemini-gen.ts'
@@ -16,11 +16,22 @@ export const CANVAS_SIZE = 1024
16
16
 
17
17
  export type ComfyResolver = () => Promise<ComfyTsRuntime>
18
18
 
19
+ /** re-encode freshly written png rolls in the kit's roll format, before any
20
+ * sidecar names them. `exact` keeps the colour under transparent pixels */
21
+ async function encodeRolls(saved: string[], format: RollFormat | undefined): Promise<void> {
22
+ if (format !== 'webp-lossless') return
23
+ const { default: sharp } = await import('sharp')
24
+ for (const png of saved.filter((p) => p.endsWith('.png'))) {
25
+ await sharp(png).webp({ lossless: true, exact: true }).toFile(png.replace(/\.png$/, '.webp'))
26
+ unlinkSync(png)
27
+ }
28
+ }
29
+
19
30
  /** Draw/generate one roll for `found` into destDir as v<seed>.png (canvas) or
20
31
  * v<seed>.<ext> (comfy). `resolveComfy` is only awaited for comfy backends. */
21
32
  export async function generateRoll(
22
33
  found: FoundAsset,
23
- args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress; context?: ParentContext },
34
+ args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress; context?: ParentContext; rollFormat?: RollFormat },
24
35
  resolveComfy: ComfyResolver,
25
36
  ): Promise<void> {
26
37
  const gen = found.kind.generate
@@ -31,7 +42,7 @@ export async function generateRoll(
31
42
  // resolves them (queue/CLI) so a blocker names the parent, loud
32
43
  if ((found.asset.from?.length ?? 0) > 0 && args.context == null)
33
44
  throw new Error(`'${found.asset.id}' derives from ${found.asset.from!.join(', ')} — parent context missing (are the parents picked?)`)
34
- await generateGemini({}, { prompt: args.prompt, model: gen.model, destDir: args.destDir, name: `v${args.seed}`, context: args.context?.images ?? [] })
45
+ await encodeRolls(await generateGemini({}, { prompt: args.prompt, model: gen.model, destDir: args.destDir, name: `v${args.seed}`, context: args.context?.images ?? [] }), args.rollFormat)
35
46
  if (args.context != null) await recordParents(args.destDir, args.seed, args.context.parents)
36
47
  return
37
48
  }
@@ -40,12 +51,14 @@ export async function generateRoll(
40
51
  const c = createCanvas(CANVAS_SIZE, CANVAS_SIZE)
41
52
  await gen.draw(c.getContext('2d'), { ...found.asset, prompt: args.prompt })
42
53
  mkdirSync(args.destDir, { recursive: true })
43
- await Bun.write(join(args.destDir, `v${args.seed}.png`), c.toBuffer('image/png'))
54
+ const png = join(args.destDir, `v${args.seed}.png`)
55
+ await Bun.write(png, c.toBuffer('image/png'))
56
+ await encodeRolls([png], args.rollFormat)
44
57
  return
45
58
  }
46
59
  const rt = await resolveComfy()
47
- const lw = workflowFor(rt.workflows, { workflow: gen.workflow, assetId: found.asset.id })
48
- await generateComfyTs(rt, lw, { prompt: args.prompt, seed: args.seed, assetId: found.asset.id, draftId: found.kind.id, destDir: args.destDir, name: `v${args.seed}`, vars: gen.vars, onProgress: args.onProgress })
60
+ const lw = workflowFor(rt.workflows, { workflow: gen.workflow, kind: found.kind.id, assetId: found.asset.id })
61
+ await encodeRolls(await generateComfyTs(rt, lw, { prompt: args.prompt, seed: args.seed, assetId: found.asset.id, draftId: found.kind.id, destDir: args.destDir, name: `v${args.seed}`, vars: gen.vars, onProgress: args.onProgress }), args.rollFormat)
49
62
  }
50
63
 
51
64
  /** Memoized lazy comfy runtime for a resolved kit — loads the cflow modules on
@@ -127,14 +127,23 @@ export type KitConfig = {
127
127
  /** workflows = dir of comfy-ts .cflow.ts modules (each module owns its
128
128
  * host); heal = shell command shown in error hints, never executed */
129
129
  comfy?: { workflows: string; heal?: string }
130
+ /** absent: an image roll lands in the format its backend produced (png
131
+ * from comfy and canvas). 'webp-lossless' re-encodes every new image
132
+ * roll as lossless webp, the same pixels at about 60% of the bytes */
133
+ rollFormat?: RollFormat
130
134
  kinds: Kind[]
131
135
  }
132
136
 
137
+ export const ROLL_FORMATS = ['webp-lossless'] as const
138
+ export type RollFormat = (typeof ROLL_FORMATS)[number]
139
+
133
140
  export type ResolvedKit = KitConfig & { workspace: string }
134
141
 
135
142
  export function validateKit(k: KitConfig): string[] {
136
143
  const errs: string[] = []
137
144
  if (!Array.isArray(k.kinds) || k.kinds.length === 0) errs.push('kinds must be a non-empty array')
145
+ if (k.rollFormat != null && !(ROLL_FORMATS as readonly string[]).includes(k.rollFormat))
146
+ errs.push(`unknown rollFormat '${String(k.rollFormat)}' (${ROLL_FORMATS.join(' | ')}, or absent for the backend's own format)`)
138
147
  for (const key of Object.keys(k.comfy ?? {}))
139
148
  if (key !== 'workflows' && key !== 'heal')
140
149
  errs.push(`comfy config has unknown key '${key}'${key === 'host' ? " — host is gone, the workflow's _host.ts owns the box (comfy-ts migration)" : ''}`)
@@ -51,10 +51,16 @@ export function identityKinds(brand: Brand): Kind[] {
51
51
  ]
52
52
  }
53
53
 
54
+ // named faces first: the generic sans-serif can resolve to a face with no
55
+ // Latin-1 accents (macOS). Each family below carries them, and an absent one
56
+ // is skipped (macOS, Windows, then the common Linux faces)
57
+ const OG_FAMILIES = 'Helvetica, Arial, "DejaVu Sans", "Liberation Sans", "Noto Sans", sans-serif'
58
+ export const ogFont = (px: number, bold = false): string => `${bold ? 'bold ' : ''}${px}px ${OG_FAMILIES}`
59
+
54
60
  // shrink px until text fits maxW (og cards must never clip the tagline)
55
61
  const fitFont = (ctx: SKRSContext2D, text: string, px: number, maxW: number, bold = false): void => {
56
62
  for (; px > 12; px -= 2) {
57
- ctx.font = `${bold ? 'bold ' : ''}${px}px sans-serif`
63
+ ctx.font = ogFont(px, bold)
58
64
  if (ctx.measureText(text).width <= maxW) return
59
65
  }
60
66
  }
@@ -50,7 +50,7 @@ async function rollOnce(root: string, io: AssetsIo, kit: ResolvedKit, resolveCom
50
50
  for (let i = 0; i < count; i++) {
51
51
  const seed = randomSeed()
52
52
  try {
53
- await generateRoll(f!, { prompt, seed, destDir: dir, context }, resolveComfy)
53
+ await generateRoll(f!, { prompt, seed, destDir: dir, context, rollFormat: kit.rollFormat }, resolveComfy)
54
54
  io.print(`ok: ${assetId} v${seed}`)
55
55
  } catch (e) {
56
56
  failed++
@@ -196,7 +196,7 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
196
196
  const gen = kind.generate
197
197
  if (gen.backend !== 'comfy') continue
198
198
  try {
199
- const lw = workflowFor(wfs, { workflow: gen.workflow, assetId: kind.assets[0]?.id })
199
+ const lw = workflowFor(wfs, { workflow: gen.workflow, kind: kind.id, assetId: kind.assets[0]?.id })
200
200
  if (gen.vars != null) {
201
201
  for (const [, d] of lw.wf.entries()) d.reset()
202
202
  applyVarValues(lw.wf, gen.vars, `kind '${kind.id}'`)
@@ -215,7 +215,7 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
215
215
  for (const f of allAssets(kit).filter((x) => x.kind.generate.backend === 'comfy')) {
216
216
  let lw: LoadedWorkflow
217
217
  try {
218
- lw = workflowFor(wfs, { workflow: (f.kind.generate as ComfyGen).workflow, assetId: f.asset.id })
218
+ lw = workflowFor(wfs, { workflow: (f.kind.generate as ComfyGen).workflow, kind: f.kind.id, assetId: f.asset.id })
219
219
  } catch {
220
220
  continue // an unresolvable workflow already FAILed on its kind above
221
221
  }
@@ -110,7 +110,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
110
110
  for (const f of allAssets(kit).filter((x) => x.kind.generate.backend === 'comfy')) {
111
111
  try {
112
112
  const gen = f.kind.generate as { workflow?: string }
113
- const lw = workflowFor(workflows, { workflow: gen.workflow, assetId: f.asset.id })
113
+ const lw = workflowFor(workflows, { workflow: gen.workflow, kind: f.kind.id, assetId: f.asset.id })
114
114
  // the KIND's draft, shared by every asset of that kind
115
115
  if (ensureAssetDraft(lw, f.kind.id, await promptOf(f))) console.log(`ok: draft '${f.kind.id}' seeded → .comfy-ts/drafts/${lw.id}/${f.kind.id}.json`)
116
116
  } catch (e) {
@@ -127,7 +127,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
127
127
  return { workflows, heal: kit.comfy.heal }
128
128
  }
129
129
  const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress, context?: ParentContext) =>
130
- generateRoll(found, { prompt, seed, destDir, onProgress, context }, resolveComfy))
130
+ generateRoll(found, { prompt, seed, destDir, onProgress, context, rollFormat: kit.rollFormat }, resolveComfy))
131
131
 
132
132
  const queue = new StudioQueue(async (job, seed, onProgress) => {
133
133
  const found = findAsset(kit, job.assetId)
@@ -388,7 +388,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
388
388
  const rgen = running?.kind.generate
389
389
  if (workflows != null && rgen?.backend === 'comfy') {
390
390
  try {
391
- await workflowFor(workflows, { workflow: rgen.workflow, assetId: running!.asset.id }).wf.host.interrupt()
391
+ await workflowFor(workflows, { workflow: rgen.workflow, kind: running!.kind.id, assetId: running!.asset.id }).wf.host.interrupt()
392
392
  } catch (e) {
393
393
  queue.pushError(`interrupt: ${(e as Error).message} — current roll will finish, its job is still dropped`)
394
394
  }
package/src/cli/cmd_Ci.ts CHANGED
@@ -20,7 +20,8 @@ export class CiCommand extends Command {
20
20
  ['gate without the repair pass (checks can still write)', 'sk ci --no-fix'],
21
21
  ['leave repo config files alone', 'sk ci --no-fix-rules'],
22
22
  ['stop at the first failure', 'sk ci --bail'],
23
- ['one check + its dep closure', 'sk ci --only typecheck'],
23
+ ['one check + its dep closure, the tree left alone', 'sk ci --only typecheck'],
24
+ ['one check, repairing the mechanical slots first', 'sk ci --only typecheck --fix'],
24
25
  ['every check declaring one group', 'sk ci --only wiki'],
25
26
  ['run everything, take nothing from the cache', 'sk ci --fresh'],
26
27
  ['include an opt in check', 'sk ci --with up:itch'],
@@ -31,12 +32,12 @@ export class CiCommand extends Command {
31
32
 
32
33
  bail = Option.Boolean('--bail', false, { description: 'stop at the first failure' })
33
34
  json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
34
- fix = Option.Boolean('--fix', true, { description: 'repair the mechanical slots before gating. Default on. --no-fix skips the repair pass, the checks can still write. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
35
+ fix = Option.Boolean('--fix', { description: 'repair the mechanical slots before gating. Default on for a whole gate and off for a --only run, which leaves the tree alone unless --fix is named. --no-fix skips the repair pass, the checks can still write. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
35
36
  fixRules = Option.Boolean('--fix-rules', true, { description: 'apply the structural autofixes of the built-in checks too. Default on. --no-fix-rules leaves repo config alone' })
36
37
  frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining checks: SK005 never touches package.json, and generated paths are never untracked. STATUS.md is still refreshed on disk, but it is gitignored, so a run leaves the tree clean' })
37
38
  skip = Option.Array('--skip', [], { description: 'skip a check group (repeatable)' })
38
39
  withIds = Option.Array('--with', [], { description: 'include a check the quick gate leaves out (repeatable)' })
39
- only = Option.Array('--only', [], { description: 'run only this check + its dep closure (repeatable). A group name runs every check declaring that group' })
40
+ only = Option.Array('--only', [], { description: 'run only this check + its dep closure (repeatable). A group name runs every check declaring that group. No repair pass unless --fix is named' })
40
41
  fresh = Option.Boolean('--fresh', false, { description: 'run every check: no answer comes from the cache, and what this run finds is stored fresh' })
41
42
  full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
42
43
 
@@ -68,7 +69,7 @@ export class CiCommand extends Command {
68
69
  return [
69
70
  ...(this.bail ? ['--bail'] : []),
70
71
  ...(this.json ? ['--json'] : []),
71
- ...(this.fix ? [] : ['--no-fix']),
72
+ ...(this.fix === true ? ['--fix'] : this.fix === false ? ['--no-fix'] : []),
72
73
  ...(this.fixRules ? [] : ['--no-fix-rules']),
73
74
  ...(this.frozen ? ['--frozen'] : []),
74
75
  ...this.skip.flatMap((v) => ['--skip', v]),
@@ -413,7 +413,7 @@ export function buildCollections(root: string, c: RepoConfig, kit: ResolvedKit |
413
413
  contractsCollection(root, c),
414
414
  asksCollection(root, c),
415
415
  credsCollection(root, c, { verdicts: () => verdictsOf(checks) }),
416
- mediaKitCollection(root, kit),
416
+ mediaKitCollection(root, c.serve?.assets === false ? null : kit),
417
417
  socialPostsCollection(root),
418
418
  reflectionsCollection(root),
419
419
  testsCollection(root, c),
@@ -248,6 +248,12 @@ export function testsRan(lastLine: string): number | null {
248
248
  return m == null ? null : Number(m[1])
249
249
  }
250
250
 
251
+ /** the command the suite runner says it ran, read off the same headline
252
+ * (`…, as \`bun test --parallel=8\` (added --parallel=8)`, judge-suites ranAs) */
253
+ export function testsCommand(lastLine: string): string | null {
254
+ return /, (as `[^`]+`(?: \(added [^)]+\))?)/.exec(lastLine)?.[1] ?? null
255
+ }
256
+
251
257
  export type Selection = {
252
258
  /** what this run measures */
253
259
  selected: RunnableCheck[]
@@ -580,9 +586,12 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
580
586
  const ran = u.status === 'pass' || u.status === 'warn' ? testsRan(u.lastLine) : null
581
587
  const counted = ran != null && head != null && head.kind === 'ok' && (head.text === u.id || head.text.startsWith(`${u.id} (`))
582
588
  const id = counted ? `${u.id} ${ran} test${ran === 1 ? '' : 's'}` : u.id
589
+ // and the command the suite runner ran, which can differ from the script's body
590
+ const how = counted ? testsCommand(u.lastLine) : null
591
+ const as = how == null ? '' : ` ${how}`
583
592
  const composed = u.synthetic && !u.replayed && head != null
584
- ? [{ ...head, text: `${id} ${ranStamp(u.ms)}${u.tail}` }, ...u.lines.slice(1)]
585
- : counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}` }, ...u.lines.slice(1)] : u.lines
593
+ ? [{ ...head, text: `${id} ${ranStamp(u.ms)}${u.tail}${as}` }, ...u.lines.slice(1)]
594
+ : counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}${as}` }, ...u.lines.slice(1)] : u.lines
586
595
  // every line says where its answer came from: a replay carries its
587
596
  // cached stamp already, so one that ran here says so and what it cost
588
597
  const shown = u.synthetic || u.replayed || u.attached || u.startedAt === 0 || composed[0] == null
@@ -798,11 +807,11 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
798
807
  // attached wait runs nothing, so it holds none
799
808
  const slot = pool == null ? null : await takeSlot(pool, { repo: o.config.name, check: u.id }, {
800
809
  abort: () => interrupted || bailed,
801
- onWait: (held) => {
810
+ onWait: (held, why) => {
802
811
  if (slotWaitSaid) return
803
812
  slotWaitSaid = true
804
813
  const who = held.slice(0, 3).map((h) => `${h.repo}/${h.check} pid ${h.pid}`).join(', ')
805
- emit({ kind: 'ok', text: `waiting for a machine slot: ${held.length} of ${pool.size} held${who === '' ? '' : ` (${who}${held.length > 3 ? ', …' : ''})`}` })
814
+ emit({ kind: 'ok', text: `waiting for a machine slot: ${held.length} held, ${why}${who === '' ? '' : ` (${who}${held.length > 3 ? ', …' : ''})`}` })
806
815
  },
807
816
  })
808
817
  if (pool != null && slot == null) { u.status = 'skip'; u.note = bailed ? 'bailed' : 'interrupted'; u.lines = []; u.unmeasured = true; return }
@@ -158,13 +158,28 @@ function unloadable(path: string, message: string): CheckDef {
158
158
  }
159
159
  }
160
160
 
161
+ /** a built-in that does not load, as a repo that only CONSUMES the toolkit
162
+ * sees it: one warning, never a red gate, since only the toolkit's own repo
163
+ * can fix the file, and its gate stays red on it */
164
+ function brokenBuiltin(path: string, message: string): CheckDef {
165
+ return {
166
+ ...unloadable(path, message),
167
+ blocking: false,
168
+ run: (c) => c.warn(`${basename(path)} does not load, so this gate skips it: ${message.replace(`${path}: `, '')}`, 'the toolkit repo owns the file, and its own gate fails on it'),
169
+ }
170
+ }
171
+
172
+ const BUILTIN_OWNER = 'build/shipkit/built-in-checks.md'
173
+
161
174
  /** every check this repo can run: its own files, plus the toolkit's. Throws
162
175
  * on a registration nobody can act on (a duplicate id, a repo check squatting
163
176
  * the reserved prefix, a built-in with no owner page), so a broken
164
- * declaration dies at load rather than half way through a gate. */
165
- export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
177
+ * declaration dies at load rather than half way through a gate. A broken
178
+ * built-in is that only in the toolkit's own repo: elsewhere it warns.
179
+ * `builtinDir` is the toolkit's checks dir, a parameter for tests only */
180
+ export async function discoverChecks(root: string, builtinDir: string = BUILTIN_CHECK_DIR): Promise<DiscoveredCheck[]> {
166
181
  const builtins: string[] = []
167
- walk(BUILTIN_CHECK_DIR, builtins)
182
+ walk(builtinDir, builtins)
168
183
  const found: string[] = []
169
184
  walk(root, found)
170
185
  const units = repoUnitFiles(root)
@@ -173,6 +188,9 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
173
188
  // because a consumer reaches the same files through a linked node_modules
174
189
  const real = (f: string): string => { try { return realpathSync(f) } catch { return f } }
175
190
  const shipped = new Set(builtins.map(real))
191
+ // its own source, never an install of it
192
+ const inRoot = relative(real(root), real(builtinDir))
193
+ const hostsToolkit = !inRoot.startsWith('..') && !inRoot.split(/[\\/]/).includes('node_modules')
176
194
  const all = found.filter((f) => !shipped.has(real(f))).sort()
177
195
  builtins.sort()
178
196
  const names = fallbackNames(root, all)
@@ -185,23 +203,34 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
185
203
  for (const f of [...builtins, ...own, ...units]) {
186
204
  const isBuiltin = builtins.includes(f)
187
205
  let def: CheckDef | null
206
+ let loadError: string | null = null
188
207
  try {
189
208
  def = await load(f)
190
209
  } catch (e) {
191
210
  // a file that does not load is ONE red row, never a dead inventory:
192
211
  // every other check still runs, and this one says why in every run
193
- def = unloadable(f, (e as Error).message)
212
+ loadError = (e as Error).message
213
+ def = unloadable(f, loadError)
194
214
  }
195
215
  if (def == null) continue
196
216
  const rel = isBuiltin ? null : relative(root, f).replace(/\\/g, '/')
197
217
  const fallback = isBuiltin
198
218
  ? (BUILTIN_ID_RE.exec(base(f))?.[1] ?? base(f))
199
219
  : f.endsWith(UNIT_SUFFIX) ? unitBase(f) : names.get(f)!
200
- const id = def.id ?? fallback
220
+ let id = def.id ?? fallback
201
221
  if (isBuiltin) {
202
- if (id !== fallback) throw new Error(`${f}: declares id '${id}' but its file name says '${fallback}' — a built-in id is its file name`)
203
- if ((def.owner ?? '') === '') throw new Error(`${f}: a built-in check names the page that OWNS the invariant`)
204
- if (RETIRED_CHECK_IDS[id] != null) throw new Error(`${f}: id '${id}' is retired (${RETIRED_CHECK_IDS[id]}), and an id is never reused`)
222
+ const refused = loadError != null ? null
223
+ : id !== fallback ? `${f}: declares id '${id}' but its file name says '${fallback}' — a built-in id is its file name`
224
+ : (def.owner ?? '') === '' ? `${f}: a built-in check names the page that OWNS the invariant`
225
+ : RETIRED_CHECK_IDS[id] != null ? `${f}: id '${id}' is retired (${RETIRED_CHECK_IDS[id]}), and an id is never reused`
226
+ : null
227
+ // the toolkit's own repo dies on a refused built-in, a consumer
228
+ // only warns: it cannot fix the file, and must not lose its gate
229
+ if (refused != null && hostsToolkit) throw new Error(refused)
230
+ if (refused != null || loadError != null) {
231
+ def = hostsToolkit ? { ...def, owner: BUILTIN_OWNER } : { ...brokenBuiltin(f, refused ?? loadError!), owner: BUILTIN_OWNER }
232
+ id = fallback
233
+ }
205
234
  } else if (id.startsWith('SK')) {
206
235
  throw new Error(`${rel}: id '${id}' uses the reserved SK prefix — a repo check picks its own (e.g. RV${id.slice(-3)}) or plain words`)
207
236
  }
@@ -0,0 +1,5 @@
1
+ // does a gate run its repair pass (core/run-fixers.ts). A leaf, so the hub's
2
+ // run lanes read the same answer the gate acts on without loading the gate.
3
+
4
+ /** `fix` named wins. Unnamed: a whole gate repairs, a narrowed run does not */
5
+ export const fixPassRuns = (o: { fix?: boolean; onlyIds?: readonly string[] }): boolean => o.fix ?? (o.onlyIds ?? []).length === 0
@@ -20,6 +20,7 @@ import type { CheckCtx } from './check-ctx.ts'
20
20
  import { checkUnitReader, type UnitReader } from '../release/unit-resolve.ts'
21
21
  import { parseJsonc } from '../ts/jsonc.ts'
22
22
  import { resolveTypecheckProjects } from '../ts/tsconfig-resolve.ts'
23
+ import { gateBuildInfoArgs } from '../ts/typecheck-cache.ts'
23
24
  import { parseBunTestScript, suiteFlags } from '../suite/judge-suites.ts'
24
25
  import { declaredTestUnits, siblingUnits, testCheckId, unitIgnores, unitTestBody, unitTsconfig, type TestUnitDecl } from '../suite/test-units.ts'
25
26
  import { describeFirst, type TaskDef } from '../check.ts'
@@ -194,7 +195,7 @@ function builtinFloorTask(id: string, root: string, io: FloorReader): { task: Ta
194
195
  const projects = resolveTypecheckProjects(tsconfig, io, root)
195
196
  plain = projects.projects.length === 1 && projects.projects[0] === tsconfig && projects.missing.length === 0
196
197
  } catch { plain = false }
197
- if (plain) return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
198
+ if (plain) return { task: { id, cmd: [tsc, '--noEmit', ...gateBuildInfoArgs(tsconfig)] }, source: 'built-in (tsc)' }
198
199
  const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/typecheck-cli.ts'))
199
200
  return { task: { id, cmd: ['bun', cli, tsc, tsconfig] }, source: 'built-in (tsc)' }
200
201
  }
@@ -58,14 +58,23 @@ export function probeGitRoot(at: string): Probe {
58
58
  return p
59
59
  }
60
60
 
61
- /** the variables that let git find a repo with no `.git` in reach */
61
+ /** the variables that let git find a repo with no `.git` in reach. They name
62
+ * ONE repo for the whole process (git sets GIT_DIR for every hook in a linked
63
+ * worktree), so they only answer for a directory with no `.git` of its own:
64
+ * any other directory is asked with them stripped, or the hook's repo would
65
+ * claim the house and every repo the process looks at */
62
66
  const LOCATING_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR']
67
+ const locatingEnvSet = (): boolean => LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')
68
+ const discoveryEnv = (): Record<string, string | undefined> => {
69
+ const env = { ...process.env }
70
+ for (const k of LOCATING_ENV) delete env[k]
71
+ return env
72
+ }
63
73
 
64
- /** git's discovery walks up looking for `.git`. With none at or above and no
65
- * variable pointing elsewhere, its answer is already known, and a gate or a
66
- * test suite that probes fresh temp dirs would spawn a process per dir for it */
74
+ /** git's discovery walks up looking for `.git`. With none at or above, its
75
+ * answer is already known, and a gate or a test suite that probes fresh temp
76
+ * dirs would spawn a process per dir for it */
67
77
  function nothingInReach(dir: string): boolean {
68
- if (LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')) return false
69
78
  let cur: string
70
79
  try {
71
80
  cur = realpathSync(dir)
@@ -81,12 +90,11 @@ function nothingInReach(dir: string): boolean {
81
90
  }
82
91
 
83
92
  /** the common case answered from the disk: `.git` is a plain directory right
84
- * at `dir`, which git describes as exactly that. Anything unusual (a locating
85
- * variable, a gitfile or a symlinked `.git`, a shared common dir, a config
86
- * that relocates the work tree, marks it bare or includes another file, a
87
- * `.git` owned by someone else) is left to git */
93
+ * at `dir`, which git describes as exactly that. Anything unusual (a gitfile
94
+ * or a symlinked `.git`, a shared common dir, a config that relocates the
95
+ * work tree, marks it bare or includes another file, a `.git` owned by
96
+ * someone else) is left to git */
88
97
  function plainRoot(dir: string): GitRoot | null {
89
- if (LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')) return null
90
98
  try {
91
99
  const top = realpathSync(dir)
92
100
  const gitDir = join(top, '.git')
@@ -102,8 +110,9 @@ function plainRoot(dir: string): GitRoot | null {
102
110
  }
103
111
 
104
112
  function ask(dir: string): Probe {
105
- if (nothingInReach(dir)) return { root: null, error: 'fatal: not a git repository (or any of the parent directories): .git' }
106
- const plain = plainRoot(dir)
113
+ const none = nothingInReach(dir)
114
+ if (none && !locatingEnvSet()) return { root: null, error: 'fatal: not a git repository (or any of the parent directories): .git' }
115
+ const plain = none ? null : plainRoot(dir)
107
116
  if (plain != null) return { root: plain, error: '' }
108
117
  let p: ReturnType<typeof Bun.spawnSync>
109
118
  try {
@@ -111,6 +120,7 @@ function ask(dir: string): Probe {
111
120
  cwd: dir,
112
121
  stdout: 'pipe',
113
122
  stderr: 'pipe',
123
+ env: none ? { ...process.env } : discoveryEnv(),
114
124
  })
115
125
  } catch (e) {
116
126
  return { root: null, error: e instanceof Error ? e.message : String(e) }
@@ -119,7 +119,9 @@ export function useOneLib(root: string = LIB_ROOT): void {
119
119
  build.onLoad({ filter: /\/(node_modules\/rvlib-shipkit|install\/cache\/rvlib-shipkit@[^/]*)\// }, (a) => {
120
120
  const loader = LOADERS[extname(a.path)] ?? 'ts'
121
121
  const sub = foreignLibPath(a.path, root)
122
- if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader }
122
+ // the host copy itself lives under node_modules when it is an
123
+ // install: its path can carry importFresh's `?v=` stamp
124
+ if (sub == null) return { contents: readFileSync(a.path.replace(/\?.*$/, ''), 'utf8'), loader }
123
125
  return reexport(join(root, sub))
124
126
  })
125
127
  for (const name of SHARED) {
@@ -131,7 +133,7 @@ export function useOneLib(root: string = LIB_ROOT): void {
131
133
  const skip = [...new Set([host, real(host)])].map((h) => `(?!${esc(h)}/)`).join('')
132
134
  build.onLoad({ filter: new RegExp(`^${skip}.*/node_modules/${esc(name)}/`) }, (a) => {
133
135
  const sub = foreignSharedPath(a.path, name, host)
134
- if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader: LOADERS[extname(a.path)] ?? 'js' }
136
+ if (sub == null) return { contents: readFileSync(a.path.replace(/\?.*$/, ''), 'utf8'), loader: LOADERS[extname(a.path)] ?? 'js' }
135
137
  return reexport(sharedTarget(a.path, sub, name, host))
136
138
  })
137
139
  }