rvlib-shipkit 1.8.0 → 2.0.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 +66 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +31 -14
- package/src/assets/gen/generate-roll.ts +20 -7
- package/src/assets/kit-config.ts +9 -0
- package/src/assets/kits/identity-kit.ts +7 -1
- package/src/assets/run-assets.ts +3 -3
- package/src/assets/server/studio-server.ts +3 -3
- package/src/check.ts +5 -2
- package/src/checks/SK001-package-json-shape.check.ts +47 -12
- package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
- package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
- package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
- package/src/checks/SK029-explicit-check-files.check.ts +25 -8
- package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
- package/src/cli/cmd_Ci.ts +6 -5
- package/src/cli/cmd_Commands.ts +21 -5
- package/src/cli/cmd_Hooks.ts +6 -2
- package/src/cli/cmd_Init.ts +1 -1
- package/src/cli/cmd_Serve.ts +1 -2
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli.ts +2 -4
- package/src/collections/providers.ts +1 -1
- package/src/core/budget.ts +6 -8
- package/src/core/bun-builtins.ts +36 -0
- package/src/core/bun-tool-cli.ts +23 -0
- package/src/core/canonical-scripts.ts +6 -4
- package/src/core/check-ctx.ts +104 -10
- package/src/core/check-file-names.ts +2 -3
- package/src/core/check-pass.ts +17 -1
- package/src/core/check-runner.ts +32 -10
- package/src/core/config-rewrite.ts +27 -6
- package/src/core/discover-checks.ts +64 -15
- package/src/core/fix-scope.ts +5 -0
- package/src/core/floor-resolve.ts +18 -8
- package/src/core/git-root.ts +22 -12
- package/src/core/job-run.ts +2 -2
- package/src/core/layout.ts +4 -1
- package/src/core/legacy-shapes.ts +40 -0
- package/src/core/one-lib.ts +5 -3
- package/src/core/one-way-in-policy.ts +106 -13
- package/src/core/pre-commit-discovery.ts +106 -0
- package/src/core/retired-checks.ts +2 -0
- package/src/core/run-ci.ts +23 -16
- package/src/core/run-fix.ts +9 -0
- package/src/core/run-fixers.ts +91 -35
- package/src/core/runs-matrix.ts +7 -0
- package/src/core/slots.ts +36 -6
- package/src/core/vendored-read.ts +39 -0
- package/src/core/vendored.ts +82 -0
- package/src/core/waivers.ts +125 -0
- package/src/core/when-block.ts +25 -185
- package/src/core/when.ts +30 -13
- package/src/core/wire.ts +17 -1
- package/src/git-bootstrap.ts +1 -1
- package/src/home/attach.ts +3 -5
- package/src/home/gui-app/ShipkitGui.swift +96 -0
- package/src/home/insights.ts +6 -1
- package/src/home/pm-config.ts +2 -2
- package/src/home/resources.ts +3 -0
- package/src/home/run-daemon.ts +2 -2
- package/src/home/run-unit-action.ts +2 -2
- package/src/hooks/commit-message.ts +2 -14
- package/src/hooks/hook-gates.ts +38 -0
- package/src/hooks/leak-guard.ts +8 -3
- package/src/hooks/one-way-in-guard.ts +8 -2
- package/src/hooks/root-identity.ts +9 -6
- package/src/hooks/run-hooks.ts +72 -41
- package/src/hooks/staged.ts +49 -0
- package/src/hooks/tree-shrink.ts +41 -0
- package/src/init-repo.ts +0 -42
- package/src/ledger/check-table.ts +1 -1
- package/src/ledger/gate-checks.ts +45 -9
- package/src/ledger/ledger.ts +6 -1
- package/src/ledger/repo-view.ts +14 -4
- package/src/ledger/toolchain-identity.ts +4 -2
- package/src/mv/mv-md.ts +6 -1
- package/src/plan/planner.ts +21 -4
- package/src/plan/repo-source.ts +7 -3
- package/src/problems/extract.ts +27 -5
- package/src/problems/problem-line.ts +2 -3
- package/src/release/run-release.ts +30 -16
- package/src/release/unit-gate.ts +12 -5
- package/src/repo-config.ts +51 -27
- package/src/scaffold/files/bunfig.toml +11 -0
- package/src/scaffold/files/vscode-settings.json +1 -2
- package/src/scaffold/jsonc-spans.ts +16 -0
- package/src/scaffold/project-scaffold.ts +43 -77
- package/src/serve/hub-app/check-view.tsx +4 -4
- package/src/serve/hub-app/check-words.ts +1 -0
- package/src/serve/hub-app/checks-svc.ts +1 -2
- package/src/serve/hub-app/home.html +0 -1
- package/src/serve/hub-app/hub-types.ts +0 -2
- package/src/serve/hub-app/index.html +0 -1
- package/src/serve/hub-app/project-list.css +1 -2
- package/src/serve/hub-app/tree-build.ts +3 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-port.ts +3 -3
- package/src/serve/hub-runtime.ts +3 -5
- package/src/serve/project-gutter.ts +5 -2
- package/src/serve/run-lanes.ts +7 -3
- package/src/serve/serve-hub.ts +1 -16
- package/src/serve/tracked-watch.ts +1 -1
- package/src/smoke/run-smoke.ts +13 -6
- package/src/start-repo.ts +15 -3
- package/src/suite/judge-suites.ts +81 -32
- package/src/suite/select.ts +2 -0
- package/src/suite/suite-cli.ts +27 -16
- package/src/suite/suite-cpu-preload.ts +7 -11
- package/src/suite/test-units.ts +7 -4
- package/src/ts/config.ts +12 -5
- package/src/ts/imports.ts +3 -1
- package/src/ts/typecheck-cache.ts +11 -0
- package/src/ts/typecheck-cli.ts +2 -1
- package/src/tui/build-steps-panel.ts +1 -1
- package/src/wiki/scan-md.ts +16 -9
- package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
- package/src/checks/SK014-prose-ste.check.ts +0 -78
- package/src/cli/cmd_Prose.ts +0 -74
- package/src/prose/prose-view.ts +0 -90
- package/src/prose/run-prose.ts +0 -187
- package/src/prose/ste.ts +0 -552
- package/src/scaffold/files/vscode-tasks.json +0 -24
- package/src/serve/hub-app/prose-hits-st.ts +0 -125
- package/src/serve/hub-app/prose-hits-view.tsx +0 -176
- package/src/serve/hub-app/prose-hits.css +0 -21
|
@@ -9,6 +9,7 @@ import type { ProcessRow, RowDot } from '../../home/process-rows.ts'
|
|
|
9
9
|
import { checkVerdictWords, plural } from '../../core/outcome-words.ts'
|
|
10
10
|
import { runsWords } from '../../core/runs-matrix.ts'
|
|
11
11
|
import { checkName } from './check-words.ts'
|
|
12
|
+
import type { WhenAt } from './hub-types.ts'
|
|
12
13
|
import { durationTone, fmtMs, type DurTone } from './dur.ts'
|
|
13
14
|
import type { HashSel } from './hash-route.ts'
|
|
14
15
|
import { SECTION_LETTER, sectionLabel, type SectionId } from './keys.ts'
|
|
@@ -234,8 +235,8 @@ export function flattenTree(nodes: TreeNode[], expanded: Set<string>, depth = 0)
|
|
|
234
235
|
/** the line at the top of the checks page: how much of this repo runs on
|
|
235
236
|
* every gate, and how much does not. Counts CHECKS only, because nothing
|
|
236
237
|
* else declares a place to run */
|
|
237
|
-
export function whenCounts(resources: Resource[]): Record<
|
|
238
|
-
const out = { always: 0, release: 0, health: 0, off: 0 }
|
|
238
|
+
export function whenCounts(resources: Resource[]): Record<WhenAt, number> {
|
|
239
|
+
const out: Record<WhenAt, number> = { always: 0, release: 0, health: 0, commit: 0, off: 0 }
|
|
239
240
|
for (const r of resources) if (r.check != null) out[r.check.when]++
|
|
240
241
|
return out
|
|
241
242
|
}
|
|
@@ -95,9 +95,9 @@ export class TreeSvc {
|
|
|
95
95
|
/** the line at the top of the checks page: what runs always and what does not */
|
|
96
96
|
get whenLine(): string {
|
|
97
97
|
const c = whenCounts(this.st.cols.resources)
|
|
98
|
-
const total = c.always + c.release + c.health + c.off
|
|
98
|
+
const total = c.always + c.release + c.health + c.commit + c.off
|
|
99
99
|
if (total === 0) return ''
|
|
100
|
-
return [`${c.always} always`, `${c.release} release`, `${c.health} health`, `${c.off} off`].join(' · ')
|
|
100
|
+
return [`${c.always} always`, `${c.release} release`, `${c.health} health`, `${c.commit} commit`, `${c.off} off`].join(' · ')
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
get redCount(): number {
|
package/src/serve/hub-port.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// the hub's default port, alone in its own module ON PURPOSE.
|
|
2
2
|
//
|
|
3
3
|
// It is four lines of arithmetic that several places need BEFORE any server
|
|
4
|
-
// exists: the repo scaffold writes it into
|
|
5
|
-
//
|
|
4
|
+
// exists: the repo scaffold writes it into agent/index.md, `init` prints it,
|
|
5
|
+
// the config resolver defaults to it. Importing it from run-serve.ts drags
|
|
6
6
|
// the whole hub (react, the markdown renderer, the sanitiser, every panel)
|
|
7
7
|
// into a caller that only wanted a number, which measured 393ms of module
|
|
8
8
|
// load in the scaffold alone.
|
|
9
9
|
//
|
|
10
10
|
// A port derived from the repo NAME rather than assigned means two repos open
|
|
11
11
|
// side by side almost never collide, and the same repo always reopens on the
|
|
12
|
-
// same port, which is what
|
|
12
|
+
// same port, which is what lets a page advertise a URL before the hub runs.
|
|
13
13
|
export function defaultHubPort(name: string): number {
|
|
14
14
|
let h = 0
|
|
15
15
|
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
|
package/src/serve/hub-runtime.ts
CHANGED
|
@@ -27,8 +27,6 @@ import { wikisCollection } from '../collections/wikis.ts'
|
|
|
27
27
|
import { pingTick } from '../collections/pings.ts'
|
|
28
28
|
import { buildCollections } from '../collections/providers.ts'
|
|
29
29
|
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
30
|
-
import { CheckCtx } from '../core/check-ctx.ts'
|
|
31
|
-
import { proseView } from '../prose/prose-view.ts'
|
|
32
30
|
import { allTasks, gateInventory } from '../ledger/gate-checks.ts'
|
|
33
31
|
import { runCheck, type CheckEvent } from '../core/run-check.ts'
|
|
34
32
|
import { runCi, type CiOpts } from '../core/run-ci.ts'
|
|
@@ -158,9 +156,10 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
158
156
|
graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
|
|
159
157
|
// ONE page for every repo: the studio ALWAYS
|
|
160
158
|
// mounts — kit-less repos get an empty media section, never a second
|
|
161
|
-
// page. A broken kit degrades to the same empty kit, loudly
|
|
159
|
+
// page. A broken kit degrades to the same empty kit, loudly, and a repo
|
|
160
|
+
// whose own tool owns the picking turns the kit off here
|
|
162
161
|
let kit: ResolvedKit | null = null
|
|
163
|
-
if (existsSync(join(root, '.shipkit/assets/kit.ts'))) {
|
|
162
|
+
if (c.serve?.assets !== false && existsSync(join(root, '.shipkit/assets/kit.ts'))) {
|
|
164
163
|
try {
|
|
165
164
|
kit = await loadKit(root)
|
|
166
165
|
} catch (e) {
|
|
@@ -390,7 +389,6 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
390
389
|
const input = { facts, runs: readRuns(record), suiteRuns: readSuiteRuns(record) }
|
|
391
390
|
return check == null || check === '' ? shapeTestsRoot(input) : shapeTestCheck(check, input)
|
|
392
391
|
},
|
|
393
|
-
prose: async () => proseView(CheckCtx.open({ root, config: c }), c),
|
|
394
392
|
needs: () => projectNeeds(allCollections(), hubProblems.collector),
|
|
395
393
|
hubProblems,
|
|
396
394
|
// a job outlives this image, so only an in process run holds a reload back
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
10
10
|
import { basename, dirname, join, resolve, sep } from 'path'
|
|
11
11
|
import { fileURLToPath } from 'url'
|
|
12
|
-
import { gitRoot } from '../core/git-root.ts'
|
|
12
|
+
import { gitRoot, isLinkedWorktree } from '../core/git-root.ts'
|
|
13
13
|
import { DB_FILE } from '../ledger/db.ts'
|
|
14
14
|
import { defaultHubPort } from './hub-port.ts'
|
|
15
15
|
import { LAYOUT } from '../core/layout.ts'
|
|
@@ -52,7 +52,9 @@ export const ICON_FILES = ['.shipkit/identity/icon.png', 'public/favicon.png', '
|
|
|
52
52
|
|
|
53
53
|
export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
|
|
54
54
|
|
|
55
|
-
/** every direct child of `parent` that includes `index`, sorted by name
|
|
55
|
+
/** every direct child of `parent` that includes `index`, sorted by name. A
|
|
56
|
+
* linked worktree of one is that repo checked out again, never a repo of its
|
|
57
|
+
* own: its processes would collide with the main checkout's ids and ports */
|
|
56
58
|
export function wiredRepos(parent: string, index: string, home: string): WiredRepo[] {
|
|
57
59
|
let names: string[]
|
|
58
60
|
try {
|
|
@@ -65,6 +67,7 @@ export function wiredRepos(parent: string, index: string, home: string): WiredRe
|
|
|
65
67
|
if (name.startsWith('.')) continue
|
|
66
68
|
const path = join(parent, name)
|
|
67
69
|
if (!includeTargets(path, home).includes(index)) continue
|
|
70
|
+
if (isLinkedWorktree(path)) continue
|
|
68
71
|
out.push({ name, path, icon: ICON_FILES.find((f) => existsSync(join(path, f))) ?? null })
|
|
69
72
|
}
|
|
70
73
|
return out
|
package/src/serve/run-lanes.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// - a run that writes the tree (a repair pass) waits for every run ahead of
|
|
8
8
|
// it, and every later run waits for it
|
|
9
9
|
// - every other run starts at once: the machine slot pool each check body
|
|
10
|
-
// takes bounds the CPU, never this queue
|
|
10
|
+
// takes bounds the CPU, never this queue. It admits fewer slots on a
|
|
11
|
+
// loaded machine (core/slots.ts), so a run past it waits and says why
|
|
11
12
|
//
|
|
12
13
|
// the one constraint a reader breaks first: the stamp is taken when a run
|
|
13
14
|
// STARTS, before its body reads anything, so a tree that moves during the run
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
import { createHash } from 'crypto'
|
|
16
17
|
import { statSync } from 'fs'
|
|
17
18
|
import { join } from 'path'
|
|
19
|
+
import { fixPassRuns } from '../core/fix-scope.ts'
|
|
18
20
|
import { gitBin } from '../core/git-bin.ts'
|
|
19
21
|
|
|
20
22
|
export type LaneAsk = {
|
|
@@ -106,8 +108,10 @@ export function runKey(kind: string, opts: Record<string, unknown>): string {
|
|
|
106
108
|
return `${kind} ${JSON.stringify(sorted)}`
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
/** a run
|
|
110
|
-
|
|
111
|
+
/** a run whose repair pass runs (core/fix-scope.ts): the structural fixers
|
|
112
|
+
* and the hooks only ever run inside it */
|
|
113
|
+
export const writesTree = (opts: Record<string, unknown>): boolean =>
|
|
114
|
+
fixPassRuns({ fix: typeof opts.fix === 'boolean' ? opts.fix : undefined, onlyIds: Array.isArray(opts.onlyIds) ? (opts.onlyIds as string[]) : undefined })
|
|
111
115
|
|
|
112
116
|
/** HEAD, what git status lists, and the stat of each listed path, hashed:
|
|
113
117
|
* equal stamps mean no tracked or untracked file moved in between. Null
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -16,7 +16,6 @@ import type { CheckEvent } from '../core/run-check.ts'
|
|
|
16
16
|
import { configDirty, readGateBlock, writeGateBlock, type GateEdit } from '../core/when-block.ts'
|
|
17
17
|
import type { InvokedBy } from '../ledger/run-log.ts'
|
|
18
18
|
import type { GraphMount } from '../wiki/graph-mount.ts'
|
|
19
|
-
import type { ProseView } from '../prose/prose-view.ts'
|
|
20
19
|
import type { Timeline } from '../ledger/run-timeline.ts'
|
|
21
20
|
import type { TestCheckView, TestsRootView } from '../collections/test-time.ts'
|
|
22
21
|
import type { LiveView } from '../ledger/live-timeline.ts'
|
|
@@ -105,9 +104,6 @@ export type HubDeps = {
|
|
|
105
104
|
/** the comfy panel; null when the repo declares no workflow module, which
|
|
106
105
|
* is also when the comfy section is absent from the page */
|
|
107
106
|
comfy: ComfyDeps | null
|
|
108
|
-
/** rule SK014's hits, grouped by word, over the config on disk: the data
|
|
109
|
-
* of the SK014 page. Absent = the endpoint answers 404 */
|
|
110
|
-
prose?: () => Promise<ProseView>
|
|
111
107
|
/** re-read repo.config.ts when its bytes moved. The switch WRITES that
|
|
112
108
|
* file, so every surface built on the config asks for this first, or a
|
|
113
109
|
* toggle reads as a toggle that did nothing until the next restart */
|
|
@@ -367,7 +363,7 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
|
|
|
367
363
|
if (req.method === 'POST') {
|
|
368
364
|
try {
|
|
369
365
|
const body = (await req.json()) as GateEdit & { expect?: string }
|
|
370
|
-
const b = await writeGateBlock(d.root, { off: body.off, slow: body.slow, slowRemove: body.slowRemove
|
|
366
|
+
const b = await writeGateBlock(d.root, { off: body.off, slow: body.slow, slowRemove: body.slowRemove }, { expect: body.expect })
|
|
371
367
|
await d.refresh?.()
|
|
372
368
|
return Response.json({ ...b, dirty: configDirty(d.root) })
|
|
373
369
|
} catch (e) {
|
|
@@ -375,17 +371,6 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
|
|
|
375
371
|
}
|
|
376
372
|
}
|
|
377
373
|
}
|
|
378
|
-
if (path === '/api/prose.json' && d.prose != null) {
|
|
379
|
-
// every SK014 hit, grouped by word, noisiest first. The word
|
|
380
|
-
// lists are written through /api/checks/when like every other
|
|
381
|
-
// edit of repo.config.ts
|
|
382
|
-
await d.refresh?.()
|
|
383
|
-
try {
|
|
384
|
-
return Response.json(await d.prose())
|
|
385
|
-
} catch (e) {
|
|
386
|
-
return new Response(`the prose check could not run: ${(e as Error).message}`, { status: 500 })
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
374
|
if (path === '/api/check/run' && req.method === 'POST') {
|
|
390
375
|
const body = (await req.json().catch(() => ({}))) as { only?: string[]; gate?: boolean }
|
|
391
376
|
// NDJSON: one line per event, so the page colours a row the
|
|
@@ -48,7 +48,7 @@ export async function gitIgnored(root: string, paths: string[]): Promise<Set<str
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** a commit, a fetch or a tag moved a ref: the one write inside .git a check
|
|
51
|
-
* keyed on the refs
|
|
51
|
+
* keyed on the refs must hear. Lock files are git mid write */
|
|
52
52
|
export function isRefMove(rel: string): boolean {
|
|
53
53
|
if (rel.endsWith('.lock')) return false
|
|
54
54
|
return rel === '.git/HEAD' || rel === '.git/packed-refs' || rel.startsWith('.git/refs/')
|
package/src/smoke/run-smoke.ts
CHANGED
|
@@ -27,6 +27,10 @@ export type SmokeRoute = {
|
|
|
27
27
|
* match's href after this route and judges the landed page as its own
|
|
28
28
|
* row. How a detail page with a real id gets visited without knowing one. */
|
|
29
29
|
open?: string
|
|
30
|
+
/** the visible text a settled route needs before it counts as rendered,
|
|
31
|
+
* default 40. A canvas app (a game) draws everything into one canvas and
|
|
32
|
+
* shows no text: `{ path: '/', waitFor: 'canvas', minChars: 0 }`. */
|
|
33
|
+
minChars?: number
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export type SmokeLogin = {
|
|
@@ -63,8 +67,9 @@ export type SmokeConfig = {
|
|
|
63
67
|
const SMOKE_KEYS = new Set(['base', 'routes', 'login', 'paceMs', 'shots'])
|
|
64
68
|
const LOGIN_KEYS = new Set(['path', 'email', 'emailSecret', 'passwordSecret', 'form'])
|
|
65
69
|
const FORM_KEYS = new Set(['email', 'password', 'submit'])
|
|
66
|
-
const ROUTE_KEYS = new Set(['path', 'waitFor', 'open'])
|
|
70
|
+
const ROUTE_KEYS = new Set(['path', 'waitFor', 'open', 'minChars'])
|
|
67
71
|
export const DEFAULT_LOGIN_PATH = '/login'
|
|
72
|
+
export const DEFAULT_MIN_CHARS = 40
|
|
68
73
|
|
|
69
74
|
export const DEFAULT_PACE_MS = 3000
|
|
70
75
|
export const DEFAULT_SHOTS = `${LAYOUT.tmp}/smoke`
|
|
@@ -86,6 +91,8 @@ export function validateSmokeConfig(c: unknown): SmokeConfig {
|
|
|
86
91
|
const v = (r as Record<string, unknown>)[k]
|
|
87
92
|
if (v != null && (typeof v !== 'string' || v === '')) throw new Error(`defineRepo: smoke.routes['${path}'].${k} must be a non-empty selector`)
|
|
88
93
|
}
|
|
94
|
+
const min = (r as Record<string, unknown>)['minChars']
|
|
95
|
+
if (min != null && (typeof min !== 'number' || !Number.isInteger(min) || min < 0)) throw new Error(`defineRepo: smoke.routes['${path}'].minChars must be a whole number of characters, 0 or more`)
|
|
89
96
|
}
|
|
90
97
|
}
|
|
91
98
|
if (s['login'] != null) {
|
|
@@ -152,8 +159,8 @@ export type SmokeReport = {
|
|
|
152
159
|
* nothing threw, an empty `waitFor` on a listing is exactly how a broken
|
|
153
160
|
* migration looks, and a page that scrolls sideways is a layout defect only
|
|
154
161
|
* a real browser can see (happy-dom does no layout). */
|
|
155
|
-
export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; overflowPx: number }): RouteReport {
|
|
156
|
-
const empty = r.chars <
|
|
162
|
+
export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; minChars?: number; overflowPx: number }): RouteReport {
|
|
163
|
+
const empty = r.chars < (r.minChars ?? DEFAULT_MIN_CHARS)
|
|
157
164
|
const noMatch = r.waitFor != null && r.matches === 0
|
|
158
165
|
const sideways = r.overflowPx > 1
|
|
159
166
|
const note = empty ? 'EMPTY PAGE' : noMatch ? `NO MATCH FOR ${r.waitFor}` : sideways ? `PAGE SCROLLS SIDEWAYS +${r.overflowPx}px` : r.problems > 0 ? `${r.problems} problem(s)` : ''
|
|
@@ -368,7 +375,7 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
|
|
|
368
375
|
}
|
|
369
376
|
|
|
370
377
|
/** visit one url, settle it, judge it, screenshot it */
|
|
371
|
-
const visit = async (label: string, url: string, waitFor?: string): Promise<RouteReport> => {
|
|
378
|
+
const visit = async (label: string, url: string, waitFor?: string, minChars?: number): Promise<RouteReport> => {
|
|
372
379
|
// let the previous page's requests drain out of the concurrency
|
|
373
380
|
// window BEFORE the label moves, or its late failures land on this row
|
|
374
381
|
if (paceMs > 0) await page.waitForTimeout(paceMs)
|
|
@@ -387,11 +394,11 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
|
|
|
387
394
|
return Math.max(de.scrollWidth - de.clientWidth, doc.body.scrollWidth - doc.body.clientWidth)
|
|
388
395
|
}).catch(() => 0)
|
|
389
396
|
await page.screenshot({ path: join(shots, `${routeSlug(label)}.png`), fullPage: true })
|
|
390
|
-
return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, overflowPx })
|
|
397
|
+
return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, minChars, overflowPx })
|
|
391
398
|
}
|
|
392
399
|
|
|
393
400
|
for (const route of routes) {
|
|
394
|
-
reports.push(await visit(route.path, route.path, route.waitFor))
|
|
401
|
+
reports.push(await visit(route.path, route.path, route.waitFor, route.minChars))
|
|
395
402
|
if (route.open == null) continue
|
|
396
403
|
// a detail page with a real id: the first link the selector matches
|
|
397
404
|
const href = await page.locator(route.open).first().getAttribute('href').catch(() => null)
|
package/src/start-repo.ts
CHANGED
|
@@ -13,19 +13,24 @@ import { join } from 'path'
|
|
|
13
13
|
import { ensureGitRepo, ensureIgnoreBaseline, firstGhLine, isScratch, publishRepo, runner, type Run } from './git-bootstrap.ts'
|
|
14
14
|
import { initRepo } from './init-repo.ts'
|
|
15
15
|
import { AGENT_LINE } from './checks/SK003-claude-md-shape.check.ts'
|
|
16
|
-
import { scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
|
|
16
|
+
import { adoptHouse, scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
|
|
17
17
|
import { defaultHubPort } from './serve/hub-port.ts'
|
|
18
18
|
|
|
19
19
|
/** the one template: `shipkit start <name>` is the only thing anyone types,
|
|
20
|
-
* and a repo without the
|
|
20
|
+
* and a repo without the house wiki in its context is not a started repo.
|
|
21
21
|
* Base context first, the repo's own router last, so repo docs override.
|
|
22
22
|
* A public repo swaps line 1 for the gitignored `@./CLAUDE.local.md`,
|
|
23
23
|
* which is why that line satisfies the first include (SK003) */
|
|
24
|
+
// sk-disable-next-line RV003 -- the start template writes the house include line itself
|
|
24
25
|
export const HOUSE_INCLUDE = ['@~/dev/corvion/wiki/index.md', '@./agent/index.md']
|
|
25
26
|
export const HOUSE_ALSO_OK = ['@./CLAUDE.local.md']
|
|
26
27
|
/** the day-job checkout: never wired, never given an include */
|
|
28
|
+
// sk-disable-next-line RV003 -- start refuses the day-job checkout by its name
|
|
27
29
|
export const HOUSE_SKIP = ['monoloco']
|
|
28
30
|
|
|
31
|
+
/** a repo shipkit never touches: no CLAUDE.md, no git, no scaffold, no push */
|
|
32
|
+
export const isSkipped = (name: string, skip: readonly string[] = HOUSE_SKIP): boolean => skip.includes(name)
|
|
33
|
+
|
|
29
34
|
/** where the first house line points, `~` resolved against `home` */
|
|
30
35
|
export const housePath = (home: string): string => {
|
|
31
36
|
const p = HOUSE_INCLUDE[0]!.replace(/^@/, '')
|
|
@@ -72,7 +77,7 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
72
77
|
const house = opts.include ?? (houseOnDisk ? HOUSE_INCLUDE : [])
|
|
73
78
|
if (opts.include == null && !houseOnDisk) console.error(`skip: no house wiki at ${housePath(home)} — CLAUDE.md gets the agent route only, and no .claude/ policy files`)
|
|
74
79
|
const alt = opts.alsoOk ?? HOUSE_ALSO_OK
|
|
75
|
-
const skipped = (opts.skip
|
|
80
|
+
const skipped = isSkipped(name, opts.skip)
|
|
76
81
|
if (skipped && house.length > 0) console.error(`skip: ${name} is include-exempt — CLAUDE.md untouched`)
|
|
77
82
|
// start is the from-anywhere verb, and a STARTED repo comes out READY:
|
|
78
83
|
// any dir with no commit history gets a minimal package.json, a first
|
|
@@ -146,6 +151,13 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
146
151
|
else console.error(`WARN dependency install failed: ${firstGhLine(r.out)}`)
|
|
147
152
|
}
|
|
148
153
|
}
|
|
154
|
+
// a repo with history that a house adopts: its own files win, and the
|
|
155
|
+
// house half of the day-one shape lands beside them, so the include
|
|
156
|
+
// lines below route at an agent wiki that exists
|
|
157
|
+
if (wired && !startable && house.length > 0) {
|
|
158
|
+
try { adoptHouse(dir, { name, print }) }
|
|
159
|
+
catch (e) { console.error(`WARN could not seed the house files in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
160
|
+
}
|
|
149
161
|
let unscaffolded = false
|
|
150
162
|
if (!existsSync(join(dir, 'repo.config.ts'))) {
|
|
151
163
|
if (existsSync(join(dir, 'package.json'))) {
|
|
@@ -18,7 +18,7 @@ export type SerialSuite = {
|
|
|
18
18
|
why: string
|
|
19
19
|
}
|
|
20
20
|
export type TestsConfig = {
|
|
21
|
-
/** per suite time in the quick tier (default 2000). A suite whose wall time goes over it under the parallel run is run again alone, and fails when the smaller of its CPU and its wall passes this, grown with the load share by up to half, or when its wall passes twice this, an allowance that grows with the square of the load share and has no end on a saturated machine. A suite whose CPU inside the parallel run, children included,
|
|
21
|
+
/** per suite time in the quick tier (default 2000). A suite whose wall time goes over it under the parallel run is run again alone, and fails when the smaller of its CPU and its wall passes this, grown with the load share by up to half, or when its wall passes twice this, an allowance that grows with the square of the load share and has no end on a saturated machine. A suite whose own CPU inside the parallel run, children included, is inside that CPU allowance skips that run, unless its wall is past the wait allowance or it ended with a child not yet reaped. What earlier suites of its worker burned is never charged to it. The full tier fails nothing on time */
|
|
22
22
|
budgetMs?: number
|
|
23
23
|
/** suites that break the budget on purpose: the full tier runs them, the quick tier does not */
|
|
24
24
|
slow?: SlowSuite[]
|
|
@@ -41,7 +41,8 @@ export const DEFAULT_BUDGET_MS = 2000
|
|
|
41
41
|
* 5s default while the preload said otherwise. */
|
|
42
42
|
export const FULL_TIMEOUT_MS = 120_000
|
|
43
43
|
/** the wall ceiling of one `bun test` phase. bun's per test timeout never
|
|
44
|
-
* fires on a worker stuck in a SYNCHRONOUS call,
|
|
44
|
+
* fires on a worker stuck in a SYNCHRONOUS call, or in `expect(p).resolves`
|
|
45
|
+
* or `.rejects` on a promise that never settles, and bun's spawnSync has been
|
|
45
46
|
* seen spinning on a child that already exited (planPhases avoids the known
|
|
46
47
|
* trigger), so without this a hang lasts until something outside kills it. The quick
|
|
47
48
|
* tier answers in seconds, so two minutes is far from any real run. */
|
|
@@ -55,6 +56,10 @@ const OWN_FLAGS = ['--budget-ms', '--slow', '--serial', '--declared-in', '--ceil
|
|
|
55
56
|
|
|
56
57
|
export const normSuitePath = (file: string): string => posix.normalize(file.replace(/\\/g, '/')).replace(/^\.\//, '')
|
|
57
58
|
|
|
59
|
+
/** bun never discovers a file under a dot dir (`.shipkit/repros/` relies on
|
|
60
|
+
* it), so neither does the gate */
|
|
61
|
+
export const inDotDir = (file: string): boolean => normSuitePath(file).split('/').slice(0, -1).some((d) => d.startsWith('.') && d !== '.' && d !== '..')
|
|
62
|
+
|
|
58
63
|
/** the arguments after `bun test`, or null when a shell would interpret the script
|
|
59
64
|
* (cd, env prefix, pipes, quotes, substitution): that body stays the repo's own */
|
|
60
65
|
export type BunfigTest = { root: string | null; ignores: string[]; error: string | null }
|
|
@@ -182,14 +187,16 @@ export function poolWorkers(p: { cores: number; size: number; held: number }): n
|
|
|
182
187
|
|
|
183
188
|
type Timed = { file: string; ms: number }
|
|
184
189
|
|
|
185
|
-
export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full' }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
|
|
190
|
+
export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full'; red?: string[] }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
|
|
186
191
|
const budget = p.tests.budgetMs ?? DEFAULT_BUDGET_MS
|
|
187
192
|
const slow = new Set((p.tests.slow ?? []).map((s) => normSuitePath(s.file)))
|
|
193
|
+
const red = new Set((p.red ?? []).map(normSuitePath))
|
|
188
194
|
const timed = Object.entries(p.timings).map(([file, ms]) => ({ file: normSuitePath(file), ms })).sort((a, b) => b.ms - a.ms)
|
|
189
195
|
return {
|
|
190
196
|
// the budget guards the tier every edit waits on. The full tier is
|
|
191
|
-
// where slow suites belong, and it only reports
|
|
192
|
-
|
|
197
|
+
// where slow suites belong, and it only reports. A red suite took
|
|
198
|
+
// the time of its failure (a timeout, a crash), which its red explains
|
|
199
|
+
over: p.tier === 'quick' ? timed.filter((t) => t.ms > budget && !slow.has(t.file) && !red.has(t.file)) : [],
|
|
193
200
|
// half the budget, so a declaration does not flap on timing noise
|
|
194
201
|
noLongerSlow: timed.filter((t) => slow.has(t.file) && t.ms < budget / 2),
|
|
195
202
|
slowest: timed.slice(0, 3),
|
|
@@ -253,6 +260,20 @@ export function confirmOver(suspects: Timed[], alone: Record<string, AloneRun>,
|
|
|
253
260
|
return { over: judged.filter((c) => c.over != null), cleared: judged.filter((c) => c.over == null) }
|
|
254
261
|
}
|
|
255
262
|
|
|
263
|
+
/** a lone run over the budget on CPU runs once more. Under load the
|
|
264
|
+
* scheduler can spill a lone run onto the efficiency cores, where the same
|
|
265
|
+
* retired instructions cost up to 4x the CPU time (measured on Apple
|
|
266
|
+
* silicon), and a spill only ever raises the figure. A wait or a wall
|
|
267
|
+
* verdict is not CPU, so it keeps its one run */
|
|
268
|
+
export const retryAlone = (t: Timed, run: AloneRun, budgetMs: number, load: MachineLoad): boolean =>
|
|
269
|
+
confirmOver([t], { [t.file]: run }, budgetMs, load).over[0]?.over === 'cpu'
|
|
270
|
+
|
|
271
|
+
/** the run charged less, as confirmOver charges it: the smaller of CPU and wall */
|
|
272
|
+
export const smallerRun = (a: AloneRun, b: AloneRun): AloneRun => {
|
|
273
|
+
const charge = (r: AloneRun): number => Math.min(r.cpuMs ?? r.wallMs, r.wallMs)
|
|
274
|
+
return charge(b) < charge(a) ? b : a
|
|
275
|
+
}
|
|
276
|
+
|
|
256
277
|
const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
|
|
257
278
|
|
|
258
279
|
/** the line a confirmed suite fails with */
|
|
@@ -265,49 +286,46 @@ export const overWords = (c: Confirmed, load: MachineLoad, budgetMs: number = DE
|
|
|
265
286
|
* (suite-cpu-preload.ts writes it, readPhaseCpu reads it) */
|
|
266
287
|
export const SUITE_CPU_ENV = 'SHIPKIT_SUITE_CPU'
|
|
267
288
|
|
|
268
|
-
/** one suite's figures from the parallel phase: its own CPU, children
|
|
269
|
-
* included, and all its worker burned before it began (suite-cpu-preload.ts
|
|
270
|
-
* says why the sum bounds a lone run) */
|
|
271
|
-
export type PhaseCpu = { cpuMs: number; priorMs: number }
|
|
272
|
-
|
|
273
289
|
/** what the workers wrote, keyed like the timings: relative to the run's cwd.
|
|
274
|
-
* `ran` is every suite that finished in the phase, `cpu` the
|
|
275
|
-
* figure can be trusted. A file bun ran
|
|
276
|
-
* none at all once one of its runs could
|
|
277
|
-
|
|
290
|
+
* `ran` is every suite that finished in the phase, `cpu` the own CPU of the
|
|
291
|
+
* ones whose figure can be trusted (suite-cpu-preload.ts). A file bun ran
|
|
292
|
+
* twice keeps its larger figure, and none at all once one of its runs could
|
|
293
|
+
* not be trusted */
|
|
294
|
+
export function readPhaseCpu(text: string, cwd: string): { ran: string[]; cpu: Record<string, number> } {
|
|
278
295
|
const ran = new Set<string>()
|
|
279
296
|
const unsure = new Set<string>()
|
|
280
|
-
const cpu: Record<string,
|
|
297
|
+
const cpu: Record<string, number> = {}
|
|
281
298
|
const base = cwd.replace(/\\/g, '/')
|
|
282
299
|
for (const line of text.split('\n')) {
|
|
283
|
-
const [file, ms
|
|
284
|
-
if (file == null || file === '' || ms == null
|
|
300
|
+
const [file, ms] = line.split('\t')
|
|
301
|
+
if (file == null || file === '' || ms == null) continue
|
|
285
302
|
const rel = normSuitePath(posix.relative(base, file.replace(/\\/g, '/')))
|
|
286
|
-
if (ms === '-'
|
|
287
|
-
if (!/^\d+$/.test(ms)
|
|
303
|
+
if (ms === '-') { ran.add(rel); unsure.add(rel); continue }
|
|
304
|
+
if (!/^\d+$/.test(ms)) continue
|
|
288
305
|
ran.add(rel)
|
|
289
|
-
|
|
290
|
-
const had = cpu[rel]
|
|
291
|
-
if (had == null || next.cpuMs + next.priorMs > had.cpuMs + had.priorMs) cpu[rel] = next
|
|
306
|
+
cpu[rel] = Math.max(cpu[rel] ?? 0, Number(ms))
|
|
292
307
|
}
|
|
293
308
|
for (const f of unsure) delete cpu[f]
|
|
294
309
|
return { ran: [...ran].sort(), cpu }
|
|
295
310
|
}
|
|
296
311
|
|
|
297
|
-
/** which suspects still need their alone run. A
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
|
|
312
|
+
/** which suspects still need their alone run. A suspect is judged on its OWN
|
|
313
|
+
* CPU in the parallel phase, charged and allowed as a lone run is
|
|
314
|
+
* (confirmOver): the load it suffered there only raised that figure, so one
|
|
315
|
+
* inside the allowance is cleared. What the earlier files of its worker
|
|
316
|
+
* burned is never its cost, a module they loaded or a memo they filled
|
|
317
|
+
* included: the first suite of a worker that needs shared work pays for it.
|
|
318
|
+
* A suspect with no figure (a child not yet reaped) or over its allowance
|
|
319
|
+
* runs alone, and only the alone run can see a WAIT, so a suite whose wall
|
|
320
|
+
* is past its wait allowance still gets it */
|
|
321
|
+
export function aloneRunsNeeded(suspects: Timed[], parallelCpu: Record<string, number>, budgetMs: number, load: MachineLoad): { rerun: Timed[]; cleared: Confirmed[] } {
|
|
305
322
|
const rerun: Timed[] = []
|
|
306
323
|
const cleared: Confirmed[] = []
|
|
307
324
|
for (const t of suspects) {
|
|
308
|
-
const
|
|
325
|
+
const cpuMs = parallelCpu[t.file]
|
|
309
326
|
const mayWait = t.ms > waitAllowance(budgetMs, load)
|
|
310
|
-
|
|
327
|
+
const charged = cpuMs == null ? null : Math.min(cpuMs, t.ms)
|
|
328
|
+
if (charged != null && charged <= cpuAllowance(budgetMs, load) && !mayWait) cleared.push({ file: t.file, ms: charged, wallMs: t.ms, cpuMs: cpuMs ?? null, over: null })
|
|
311
329
|
else rerun.push(t)
|
|
312
330
|
}
|
|
313
331
|
return { rerun, cleared }
|
|
@@ -358,6 +376,37 @@ export function failLines(output: string): Map<string, string> {
|
|
|
358
376
|
|
|
359
377
|
const clip = (s: string): string => (s.length > 300 ? `${s.slice(0, 299)}…` : s)
|
|
360
378
|
|
|
379
|
+
/** the clause the headline carries for the gate line: the command phase one
|
|
380
|
+
* ran, and the flags the runner added to the script's own. Per file ignores
|
|
381
|
+
* stay out, the slow and serial counts already say what they leave out */
|
|
382
|
+
export function ranAs(phase: string[], rawArgs: string[]): string {
|
|
383
|
+
const shown = phase.filter((a) => !a.startsWith('--path-ignore-patterns'))
|
|
384
|
+
const added = shown.filter((a) => !rawArgs.includes(a))
|
|
385
|
+
return `as \`${['bun', 'test', ...shown].join(' ')}\`${added.length > 0 ? ` (added ${added.join(' ')})` : ''}`
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** the suites bun lists under `Interrupted while still running:` when a
|
|
389
|
+
* phase is killed, one indented `<file> (<n>s)` line each */
|
|
390
|
+
export function interruptedSuites(output: string): string[] {
|
|
391
|
+
const out: string[] = []
|
|
392
|
+
let inBlock = false
|
|
393
|
+
for (const line of output.split('\n')) {
|
|
394
|
+
if (/^Interrupted while still running:\s*$/.test(line)) { inBlock = true; continue }
|
|
395
|
+
if (!inBlock) continue
|
|
396
|
+
const m = /^\s+(\S+)\s+\(\d+(?:\.\d+)?m?s\)\s*$/.exec(line)
|
|
397
|
+
if (m == null) { inBlock = false; continue }
|
|
398
|
+
out.push(normSuitePath(m[1]!))
|
|
399
|
+
}
|
|
400
|
+
return out
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** the line a phase killed at its ceiling fails with. Measured on bun 1.4.3:
|
|
404
|
+
* a plain await of a promise that never settles fails at the per test
|
|
405
|
+
* timeout, while `expect(p).resolves` or `.rejects` on one blocks the worker
|
|
406
|
+
* past it, as a synchronous loop does */
|
|
407
|
+
export const stallWords = (name: string, ceilingMs: number, files: string[]): string =>
|
|
408
|
+
`${name} ran past its ${secs(ceilingMs)} ceiling and was killed${files.length > 0 ? ` with ${files.join(', ')} still running` : ''}: a test blocked its worker past the per test timeout, either a synchronous loop or expect(...).resolves or .rejects on a promise that never settles`
|
|
409
|
+
|
|
361
410
|
/** the suites bun names as a crashed parallel worker */
|
|
362
411
|
export function crashedWorkers(output: string): { file: string; signal: string }[] {
|
|
363
412
|
return [...output.matchAll(/^\s*✗\s+(\S+)\s+\(worker crashed: (SIG[A-Z0-9]+)\)\s*$/gm)].map((m) => ({ file: normSuitePath(m[1]!), signal: m[2]! }))
|
package/src/suite/select.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { blobOid, listingHash } from '../core/read-set.ts'
|
|
|
31
31
|
import { shipkitRoot } from '../ledger/db.ts'
|
|
32
32
|
import { extractSpecs, resolveSpec } from '../ts/source-graph.ts'
|
|
33
33
|
import { type RvtConfig } from '../ts/config.ts'
|
|
34
|
+
import { inDotDir } from './judge-suites.ts'
|
|
34
35
|
|
|
35
36
|
export const SELECT_VERSION = 5
|
|
36
37
|
|
|
@@ -489,6 +490,7 @@ export function suiteFiles(top: string, cwdRel: string, testRoot: string | null,
|
|
|
489
490
|
for (const f of listed) {
|
|
490
491
|
if (f === '' || !isTest(f) || !existsSync(join(top, f))) continue
|
|
491
492
|
const rel = cwdRel === '' || cwdRel === '.' ? f : f.slice(cwdRel.length + 1)
|
|
493
|
+
if (inDotDir(rel)) continue
|
|
492
494
|
if (res.some((re) => re.test(rel))) continue
|
|
493
495
|
out.push(rel)
|
|
494
496
|
}
|