rvlib-shipkit 1.4.0 → 1.6.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 +114 -0
- package/README.md +17 -17
- package/package.json +3 -2
- package/src/assets/gen/comfyts-gen.ts +12 -8
- package/src/assets/server/studio-server.ts +14 -2
- package/src/check.ts +73 -103
- package/src/checks/SK001-package-json-shape.check.ts +11 -6
- package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
- package/src/checks/SK003-claude-md-shape.check.ts +7 -3
- package/src/checks/SK004-gitignore-policy.check.ts +7 -4
- package/src/checks/SK005-rvlib-versions.check.ts +53 -21
- package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
- package/src/checks/SK007-github-visibility.check.ts +4 -2
- package/src/checks/SK008-reviewer-agent.check.ts +6 -4
- package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
- package/src/checks/SK010-undeclared-unit.check.ts +4 -0
- package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
- package/src/checks/SK012-config-barrel-import.check.ts +5 -1
- package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
- package/src/checks/SK014-prose-ste.check.ts +4 -0
- package/src/checks/SK015-nested-repo.check.ts +4 -2
- package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
- package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
- package/src/checks/SK018-test-files-run.check.ts +6 -2
- package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
- package/src/checks/SK020-self-spawning-check.check.ts +4 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
- package/src/checks/SK024-identity-colors.check.ts +5 -3
- package/src/checks/SK025-release-drift.check.ts +4 -5
- package/src/checks/SK026-asks-autoclose.check.ts +4 -4
- package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
- package/src/checks/SK028-readme.check.ts +5 -1
- package/src/checks/SK029-explicit-check-files.check.ts +95 -0
- package/src/checks/SK030-cache-audit.check.ts +34 -0
- package/src/checks/SK031-trace-audit.check.ts +39 -0
- package/src/checks/SK032-ignored-inputs.check.ts +79 -0
- package/src/cli/cmd_Asks.ts +5 -5
- package/src/cli/cmd_Assets.ts +17 -13
- package/src/cli/cmd_Bump.ts +12 -11
- package/src/cli/cmd_CacheAudit.ts +43 -0
- package/src/cli/cmd_Check.ts +8 -7
- package/src/cli/cmd_Checks.ts +7 -6
- package/src/cli/cmd_Ci.ts +12 -11
- package/src/cli/cmd_Commands.ts +16 -13
- package/src/cli/cmd_ConfigKeys.ts +7 -6
- package/src/cli/cmd_Contracts.ts +3 -3
- package/src/cli/cmd_CrossRepoActivity.ts +8 -6
- package/src/cli/cmd_Fleet.ts +9 -7
- package/src/cli/cmd_GlobalProcesses.ts +120 -7
- package/src/cli/cmd_Home.ts +26 -19
- package/src/cli/cmd_Hooks.ts +6 -5
- package/src/cli/cmd_Init.ts +5 -4
- package/src/cli/cmd_Keys.ts +20 -8
- package/src/cli/cmd_Mv.ts +7 -6
- package/src/cli/cmd_Needs.ts +4 -3
- package/src/cli/cmd_Overview.ts +5 -4
- package/src/cli/cmd_Pb.ts +2 -2
- package/src/cli/cmd_Planned.ts +6 -5
- package/src/cli/cmd_Prose.ts +5 -5
- package/src/cli/cmd_Reflect.ts +7 -7
- package/src/cli/cmd_Release.ts +6 -5
- package/src/cli/cmd_Releases.ts +5 -4
- package/src/cli/cmd_Serve.ts +6 -4
- package/src/cli/cmd_Smoke.ts +5 -4
- package/src/cli/cmd_Start.ts +7 -6
- package/src/cli/cmd_Status.ts +4 -3
- package/src/cli/cmd_TraceAudit.ts +62 -0
- package/src/cli/cmd_Tui.ts +3 -2
- package/src/cli/cmd_When.ts +71 -0
- package/src/cli/cmd_Wiki.ts +4 -4
- package/src/cli.ts +11 -2
- package/src/collections/checks.ts +2 -0
- package/src/collections/collection-types.ts +12 -4
- package/src/collections/providers.ts +5 -3
- package/src/core/app-icon.ts +154 -0
- package/src/core/audit-report.ts +21 -0
- package/src/core/cache-audit.ts +72 -0
- package/src/core/canonical-scripts.ts +2 -2
- package/src/core/check-ctx.ts +14 -5
- package/src/core/check-runner.ts +43 -23
- package/src/core/config-rewrite.ts +37 -24
- package/src/core/discover-checks.ts +23 -2
- package/src/core/floor-resolve.ts +5 -7
- package/src/core/git-root.ts +13 -0
- package/src/core/ignored-block.ts +94 -0
- package/src/core/ignored-inputs.ts +47 -0
- package/src/core/jobs.ts +9 -2
- package/src/core/legacy-shapes.ts +39 -10
- package/src/core/one-lib.ts +59 -4
- package/src/core/one-way-in-policy.ts +2 -2
- package/src/core/outcome-words.ts +18 -2
- package/src/core/port-check-cli.ts +52 -0
- package/src/core/port-check-file.ts +295 -0
- package/src/core/port-check-files.ts +31 -0
- package/src/core/read-set.ts +97 -9
- package/src/core/retention.ts +0 -14
- package/src/core/run-check.ts +12 -9
- package/src/core/run-ci.ts +14 -5
- package/src/core/run-fix.ts +30 -23
- package/src/core/run-fixers.ts +48 -5
- package/src/core/runs-matrix.ts +179 -0
- package/src/core/runs.ts +242 -0
- package/src/core/trace-audit.ts +120 -0
- package/src/core/update-policy.ts +20 -0
- package/src/core/when-block.ts +29 -30
- package/src/core/when.ts +41 -103
- package/src/core/wire.ts +10 -14
- package/src/fleet/scan-fleet.ts +20 -4
- package/src/fleet/update-fleet.ts +6 -6
- package/src/home/gui-app.ts +24 -27
- package/src/home/home-launchd.ts +20 -4
- package/src/home/home-routes.ts +12 -1
- package/src/home/home-start.ts +11 -5
- package/src/home/hubs.ts +5 -5
- package/src/home/open-app.ts +2 -2
- package/src/home/pm.ts +8 -0
- package/src/home/run-pm.ts +1 -0
- package/src/home/serve-web.ts +2 -2
- package/src/home/supervise.ts +24 -1
- package/src/home/via-hub.ts +14 -0
- package/src/hooks/run-hooks.ts +48 -4
- package/src/init-repo.ts +6 -6
- package/src/ledger/db.ts +2 -13
- package/src/ledger/gate-checks.ts +133 -61
- package/src/ledger/git-key.ts +153 -31
- package/src/ledger/git-scan.ts +5 -2
- package/src/ledger/ledger.ts +136 -21
- package/src/ledger/store.ts +14 -0
- package/src/ledger/toolchain-identity.ts +248 -45
- package/src/plan/repo-source.ts +18 -39
- package/src/problems/extract.ts +3 -0
- package/src/release/run-release.ts +5 -0
- package/src/repo-config.ts +46 -54
- package/src/rules/rule.ts +2 -2
- package/src/scaffold/files/agent-coding.md +1 -1
- package/src/scaffold/files/agent-index.md +1 -1
- package/src/scaffold/files/verify-skill.md +2 -2
- package/src/serve/hub-app/activity-st.ts +5 -4
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/admin-svc.ts +36 -3
- package/src/serve/hub-app/admin-view.tsx +46 -12
- package/src/serve/hub-app/check-view.tsx +66 -46
- package/src/serve/hub-app/check-words.ts +1 -11
- package/src/serve/hub-app/checks-svc.ts +14 -35
- package/src/serve/hub-app/col-card.tsx +1 -44
- package/src/serve/hub-app/col-tables-view.tsx +84 -11
- package/src/serve/hub-app/col-tables.ts +27 -0
- package/src/serve/hub-app/cols-svc.ts +5 -4
- package/src/serve/hub-app/dock-st.ts +3 -2
- package/src/serve/hub-app/dock-view.tsx +36 -0
- package/src/serve/hub-app/dock.css +6 -0
- package/src/serve/hub-app/float-cards.tsx +30 -0
- package/src/serve/hub-app/floating.tsx +41 -4
- package/src/serve/hub-app/git-st.ts +42 -0
- package/src/serve/hub-app/git-view.tsx +61 -0
- package/src/serve/hub-app/hash-route.ts +45 -1
- package/src/serve/hub-app/header-bar.tsx +62 -72
- package/src/serve/hub-app/health-svc.ts +3 -2
- package/src/serve/hub-app/home-st.ts +14 -2
- package/src/serve/hub-app/home-strip.css +43 -12
- package/src/serve/hub-app/home-strip.tsx +115 -64
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +32 -8
- package/src/serve/hub-app/hub-types.ts +5 -2
- package/src/serve/hub-app/hub.css +89 -63
- package/src/serve/hub-app/icons.tsx +11 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/keys.ts +15 -4
- package/src/serve/hub-app/live-svc.ts +3 -2
- package/src/serve/hub-app/machine-st.ts +18 -22
- package/src/serve/hub-app/plan-view.tsx +51 -0
- package/src/serve/hub-app/planned-st.ts +9 -4
- package/src/serve/hub-app/processes-view.tsx +45 -0
- package/src/serve/hub-app/project-list.css +4 -1
- package/src/serve/hub-app/project-list.tsx +6 -3
- package/src/serve/hub-app/projects-svc.ts +25 -10
- package/src/serve/hub-app/prose-hits-st.ts +3 -2
- package/src/serve/hub-app/reach.ts +36 -0
- package/src/serve/hub-app/readme-svc.ts +3 -2
- package/src/serve/hub-app/repo-sections.ts +131 -0
- package/src/serve/hub-app/repo-st.ts +70 -14
- package/src/serve/hub-app/sidebar.tsx +17 -42
- package/src/serve/hub-app/timeline-svc.ts +3 -2
- package/src/serve/hub-app/tree-build.ts +151 -155
- package/src/serve/hub-app/tree-row.tsx +55 -12
- package/src/serve/hub-app/tree-svc.ts +97 -56
- package/src/serve/hub-app/when-view.tsx +89 -0
- package/src/serve/hub-app/when.css +17 -0
- package/src/serve/hub-runtime.ts +7 -18
- package/src/serve/project-gutter-route.ts +22 -1
- package/src/serve/run-serve.ts +1 -1
- package/src/serve/serve-hub.ts +4 -14
- package/src/suite/judge-suites.ts +6 -0
- package/src/trace/interpose.c +655 -0
- package/src/trace/read-trace.ts +485 -0
- package/src/trace/tracer.ts +282 -0
- package/src/serve/hub-app/processes-svc.ts +0 -63
- package/src/serve/hub-app/repo-sections.tsx +0 -61
- package/src/serve/repo-processes.ts +0 -89
|
@@ -321,6 +321,8 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
|
|
|
321
321
|
const base: Instance = {
|
|
322
322
|
id: `media:${f.asset.id}`,
|
|
323
323
|
text: f.asset.id,
|
|
324
|
+
label: f.asset.label ?? f.asset.id,
|
|
325
|
+
group: f.kind.category,
|
|
324
326
|
// the roll count is the one fact a row of assets is read for
|
|
325
327
|
slot: rolls.length > 0 ? `${rolls.length} roll${rolls.length === 1 ? '' : 's'}` : manual ? 'manual' : undefined,
|
|
326
328
|
type: 'asset',
|
|
@@ -343,11 +345,11 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
|
|
|
343
345
|
const said: Partial<Instance> = manual && rolls.length === 0
|
|
344
346
|
// the brief IS the paste-ready artifact ("I don't even
|
|
345
347
|
// know what to paste" — the tile hands him the prompt)
|
|
346
|
-
? { text: `${f.asset.id} — manual, nothing uploaded`,
|
|
348
|
+
? { text: `${f.asset.id} — manual, nothing uploaded`, verbs: ['provide'], artifact: f.asset.prompt != null ? { copy: f.asset.prompt } : undefined }
|
|
347
349
|
: rolls.length > 0 && !picked
|
|
348
|
-
? { text: `${f.asset.id} — ${rolls.length} roll(s), default in use, none picked`,
|
|
350
|
+
? { text: `${f.asset.id} — ${rolls.length} roll(s), default in use, none picked`, verbs: ['pick'] }
|
|
349
351
|
: stale
|
|
350
|
-
? { text: `${f.asset.id} — stale (a parent was re-picked)`,
|
|
352
|
+
? { text: `${f.asset.id} — stale (a parent was re-picked)`, verbs: ['pick'] }
|
|
351
353
|
: {}
|
|
352
354
|
out.push({ ...base, ...said, ...colour })
|
|
353
355
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// a repo's app icon, drawn from its mark: the mark's transparent margin is
|
|
2
|
+
// cut away, and what is left sits on a rounded plate in the repo's identity
|
|
3
|
+
// colour, on the macOS icon grid (an 824 plate on a 1024 canvas). A mark that
|
|
4
|
+
// already fills its box (a photo, a square tile) becomes the plate itself.
|
|
5
|
+
// The app build and the page's preview both draw through appIconPngs.
|
|
6
|
+
import { createCanvas, Image, type Canvas } from '@napi-rs/canvas'
|
|
7
|
+
import { existsSync, readFileSync } from 'fs'
|
|
8
|
+
import { join } from 'path'
|
|
9
|
+
import { identityOfName, identityOfText, type Identity } from './identity-color.ts'
|
|
10
|
+
import { configName, CONFIG_FILE } from './identity.ts'
|
|
11
|
+
import { LAYOUT } from './layout.ts'
|
|
12
|
+
|
|
13
|
+
/** the mark an app wears: the repo's own, largest first. Rasters only */
|
|
14
|
+
export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
|
|
15
|
+
|
|
16
|
+
export function appIconOf(root: string): string | null {
|
|
17
|
+
for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const ICON_PX = 1024
|
|
22
|
+
const PLATE = 824
|
|
23
|
+
const RADIUS = 185
|
|
24
|
+
/** the mark's longest side, as a share of the plate */
|
|
25
|
+
const MARK = 0.7
|
|
26
|
+
/** an alpha at or under this is margin, and is dropped: a generated image
|
|
27
|
+
* often carries faint noise over its whole canvas, which would widen the box
|
|
28
|
+
* to the full image and haze the plate */
|
|
29
|
+
const NOISE = 48
|
|
30
|
+
/** a mark whose box is this full is a tile already: it fills the plate */
|
|
31
|
+
const TILE = 0.85
|
|
32
|
+
|
|
33
|
+
export type Box = { x: number; y: number; w: number; h: number; fill: number }
|
|
34
|
+
|
|
35
|
+
/** the box of the pixels that are not margin, and how full it is; null for a
|
|
36
|
+
* mark with no such pixel */
|
|
37
|
+
export function markBox(rgba: Uint8Array | Uint8ClampedArray, w: number, h: number): Box | null {
|
|
38
|
+
let x0 = w, y0 = h, x1 = -1, y1 = -1
|
|
39
|
+
for (let y = 0; y < h; y++)
|
|
40
|
+
for (let x = 0; x < w; x++)
|
|
41
|
+
if (rgba[(y * w + x) * 4 + 3]! > NOISE) {
|
|
42
|
+
if (x < x0) x0 = x
|
|
43
|
+
if (x > x1) x1 = x
|
|
44
|
+
if (y < y0) y0 = y
|
|
45
|
+
if (y > y1) y1 = y
|
|
46
|
+
}
|
|
47
|
+
if (x1 < 0) return null
|
|
48
|
+
let solid = 0
|
|
49
|
+
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) if (rgba[(y * w + x) * 4 + 3]! > 250) solid++
|
|
50
|
+
const bw = x1 - x0 + 1
|
|
51
|
+
const bh = y1 - y0 + 1
|
|
52
|
+
return { x: x0, y: y0, w: bw, h: bh, fill: solid / (bw * bh) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const lum = (r: number, g: number, b: number): number => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
|
56
|
+
|
|
57
|
+
/** the mean lightness of the mark's own pixels, alpha weighted */
|
|
58
|
+
function markLum(rgba: Uint8Array | Uint8ClampedArray): number {
|
|
59
|
+
let sum = 0, weight = 0
|
|
60
|
+
for (let i = 0; i < rgba.length; i += 4) {
|
|
61
|
+
const a = rgba[i + 3]! / 255
|
|
62
|
+
sum += a * lum(rgba[i]!, rgba[i + 1]!, rgba[i + 2]!)
|
|
63
|
+
weight += a
|
|
64
|
+
}
|
|
65
|
+
return weight === 0 ? 0.5 : sum / weight
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const hexLum = (hex: string): number => {
|
|
69
|
+
const n = Number.parseInt(hex.slice(1), 16)
|
|
70
|
+
return lum((n >> 16) & 255, (n >> 8) & 255, n & 255)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** of the two identity colours, the one the mark stands out on most */
|
|
74
|
+
export function plateOf(id: Identity, markLightness: number): string {
|
|
75
|
+
return Math.abs(hexLum(id.colorLight) - markLightness) >= Math.abs(hexLum(id.colorDark) - markLightness) ? id.colorLight : id.colorDark
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const towardWhite = (hex: string, t: number): string => {
|
|
79
|
+
const n = Number.parseInt(hex.slice(1), 16)
|
|
80
|
+
const c = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => Math.round(v + (255 - v) * t))
|
|
81
|
+
return `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function decode(bytes: Uint8Array): Promise<Image> {
|
|
85
|
+
const img = new Image()
|
|
86
|
+
img.src = Buffer.from(bytes)
|
|
87
|
+
// the pixels land on decode: drawn before it, the image is empty
|
|
88
|
+
await img.decode().catch(() => {})
|
|
89
|
+
if (img.width === 0) throw new Error('not an image the icon renderer reads')
|
|
90
|
+
return img
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function plate(ctx: ReturnType<Canvas['getContext']>, o: number): void {
|
|
94
|
+
ctx.beginPath()
|
|
95
|
+
ctx.roundRect(o, o, PLATE, PLATE, RADIUS)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** the icon at 1024px, and the plate colour it used (null: the mark is the plate) */
|
|
99
|
+
export async function drawAppIcon(bytes: Uint8Array, id: Identity): Promise<{ canvas: Canvas; plate: string | null }> {
|
|
100
|
+
const img = await decode(bytes)
|
|
101
|
+
const src = createCanvas(img.width, img.height)
|
|
102
|
+
const sctx = src.getContext('2d')
|
|
103
|
+
sctx.drawImage(img, 0, 0)
|
|
104
|
+
const data = sctx.getImageData(0, 0, img.width, img.height)
|
|
105
|
+
const px = data.data
|
|
106
|
+
for (let i = 3; i < px.length; i += 4) if (px[i]! <= NOISE) px[i] = 0
|
|
107
|
+
sctx.putImageData(data, 0, 0)
|
|
108
|
+
const box = markBox(px, img.width, img.height) ?? { x: 0, y: 0, w: img.width, h: img.height, fill: 0 }
|
|
109
|
+
const out = createCanvas(ICON_PX, ICON_PX)
|
|
110
|
+
const ctx = out.getContext('2d')
|
|
111
|
+
ctx.imageSmoothingQuality = 'high'
|
|
112
|
+
const o = (ICON_PX - PLATE) / 2
|
|
113
|
+
if (box.fill >= TILE) {
|
|
114
|
+
// a tile: cover the plate with it, clipped to the plate's corners
|
|
115
|
+
const s = Math.max(PLATE / box.w, PLATE / box.h)
|
|
116
|
+
ctx.save()
|
|
117
|
+
plate(ctx, o)
|
|
118
|
+
ctx.clip()
|
|
119
|
+
ctx.drawImage(src, box.x, box.y, box.w, box.h, ICON_PX / 2 - (box.w * s) / 2, ICON_PX / 2 - (box.h * s) / 2, box.w * s, box.h * s)
|
|
120
|
+
ctx.restore()
|
|
121
|
+
return { canvas: out, plate: null }
|
|
122
|
+
}
|
|
123
|
+
const colour = plateOf(id, markLum(px))
|
|
124
|
+
const g = ctx.createLinearGradient(0, o, 0, o + PLATE)
|
|
125
|
+
// a lighter top edge, the way a native icon catches light
|
|
126
|
+
g.addColorStop(0, towardWhite(colour, 0.14))
|
|
127
|
+
g.addColorStop(1, colour)
|
|
128
|
+
ctx.fillStyle = g
|
|
129
|
+
plate(ctx, o)
|
|
130
|
+
ctx.fill()
|
|
131
|
+
const s = (PLATE * MARK) / Math.max(box.w, box.h)
|
|
132
|
+
ctx.drawImage(src, box.x, box.y, box.w, box.h, ICON_PX / 2 - (box.w * s) / 2, ICON_PX / 2 - (box.h * s) / 2, box.w * s, box.h * s)
|
|
133
|
+
return { canvas: out, plate: colour }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** the icon as pngs, one per size asked, drawn once */
|
|
137
|
+
export async function appIconPngs(bytes: Uint8Array, id: Identity, sizes: number[] = [ICON_PX]): Promise<Buffer[]> {
|
|
138
|
+
const { canvas } = await drawAppIcon(bytes, id)
|
|
139
|
+
return sizes.map((px) => {
|
|
140
|
+
if (px === ICON_PX) return canvas.toBuffer('image/png')
|
|
141
|
+
const small = createCanvas(px, px)
|
|
142
|
+
const ctx = small.getContext('2d')
|
|
143
|
+
ctx.imageSmoothingQuality = 'high'
|
|
144
|
+
ctx.drawImage(canvas, 0, 0, px, px)
|
|
145
|
+
return small.toBuffer('image/png')
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** the identity a repo's config declares, else the one its name hashes to */
|
|
150
|
+
export function identityOfRoot(root: string): Identity {
|
|
151
|
+
const path = join(root, CONFIG_FILE)
|
|
152
|
+
const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
153
|
+
return identityOfText(text) ?? identityOfName(configName(text, root))
|
|
154
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// THE KEPT AUDIT: what the last traced audit (core/trace-audit.ts) found, per
|
|
2
|
+
// task. It lives under the git common dir, beside the ledger, so every
|
|
3
|
+
// worktree of the repo on this machine reads the same one. The keys read it
|
|
4
|
+
// (ledger/gate-checks.ts): a finding covers the task that read it before any
|
|
5
|
+
// config write lands. The shape and the pure helpers: core/ignored-inputs.ts.
|
|
6
|
+
import { readFileSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { shipkitRoot } from './git-root.ts'
|
|
9
|
+
import type { AuditReport } from './ignored-inputs.ts'
|
|
10
|
+
|
|
11
|
+
export type { AuditReport, AuditTask, IgnoredInputs } from './ignored-inputs.ts'
|
|
12
|
+
export { auditedExtras, describeByTask, ignoredFor, pendingByTask } from './ignored-inputs.ts'
|
|
13
|
+
|
|
14
|
+
/** its name under the repo's shipkit state dir (CheckCtx.state reads it) */
|
|
15
|
+
export const AUDIT_FILE = 'trace-audit.json'
|
|
16
|
+
|
|
17
|
+
export const auditReportPath = (root: string): string => join(shipkitRoot(root), AUDIT_FILE)
|
|
18
|
+
|
|
19
|
+
export function readAuditReport(root: string): AuditReport | null {
|
|
20
|
+
try { return JSON.parse(readFileSync(auditReportPath(root), 'utf8')) as AuditReport } catch { return null }
|
|
21
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// THE CACHE AUDIT: is a cached green still what a fresh run says? A check
|
|
2
|
+
// keyed on files is only as honest as the paths it declares: a path it reads
|
|
3
|
+
// and never declared cannot move its key, so an edit there serves the old
|
|
4
|
+
// green. Nothing sees that while it happens. This re-runs a small sample of
|
|
5
|
+
// the checks the cache would answer right now, fresh, and compares.
|
|
6
|
+
//
|
|
7
|
+
// the one constraint a reader breaks first: only a green is ever stored, so
|
|
8
|
+
// a fresh FAIL under a key that replays a green is the whole finding. A
|
|
9
|
+
// fresh green where the stored one warned, or the reverse, is reported too.
|
|
10
|
+
import { composeChecks } from '../ledger/gate-checks.ts'
|
|
11
|
+
import { Ledger } from '../ledger/ledger.ts'
|
|
12
|
+
import { gateBinary } from '../ledger/toolchain-identity.ts'
|
|
13
|
+
import { populationOf } from '../problems/problem-line.ts'
|
|
14
|
+
import { runCheckPass } from './run-check.ts'
|
|
15
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
16
|
+
|
|
17
|
+
export type AuditRow = {
|
|
18
|
+
id: string
|
|
19
|
+
/** what the cache holds for the current key */
|
|
20
|
+
cached: 'pass' | 'warn'
|
|
21
|
+
/** what a fresh run said */
|
|
22
|
+
fresh: 'pass' | 'warn' | 'fail' | 'skip'
|
|
23
|
+
/** how the check is keyed: files (declared paths) or reads (recorded) */
|
|
24
|
+
keyedOn: 'files' | 'reads'
|
|
25
|
+
ms: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AuditResult = {
|
|
29
|
+
/** checks the cache would answer now, the pool the sample came from */
|
|
30
|
+
pool: number
|
|
31
|
+
rows: AuditRow[]
|
|
32
|
+
/** the rows whose fresh verdict is not the cached one */
|
|
33
|
+
stale: AuditRow[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** the sample: `n` checks of the pool, a different stable set each day, so
|
|
37
|
+
* every check is audited over time and two runs on one day agree */
|
|
38
|
+
export function sampleOf(ids: string[], n: number, day: number): string[] {
|
|
39
|
+
const rank = (id: string): bigint => BigInt(Bun.hash(`${day}:${id}`))
|
|
40
|
+
return [...ids].sort((a, b) => (rank(a) < rank(b) ? -1 : rank(a) > rank(b) ? 1 : 0)).slice(0, n)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function auditCache(root: string, config: RepoConfig, o: { sample: number; only?: string[]; now?: number }): Promise<AuditResult> {
|
|
44
|
+
const composed = await composeChecks(root, config, { tier: 'quick' })
|
|
45
|
+
const ledger = Ledger.open(root, composed.map((g) => g.row.decl), { cliEntry: gateBinary(), read: true })
|
|
46
|
+
// what the cache would answer right now, keyed on the repo's own files
|
|
47
|
+
const pool: { id: string; cached: 'pass' | 'warn'; keyedOn: 'files' | 'reads' }[] = []
|
|
48
|
+
for (const g of composed) {
|
|
49
|
+
if (g.row.runs.off != null) continue
|
|
50
|
+
const pop = populationOf(g.row.decl)
|
|
51
|
+
if (pop !== 'content' && pop !== 'reads') continue
|
|
52
|
+
if (o.only != null && !o.only.includes(g.row.decl.id)) continue
|
|
53
|
+
const hit = ledger.replay(g.row.decl.id)
|
|
54
|
+
if (hit != null) pool.push({ id: g.row.decl.id, cached: hit.status, keyedOn: pop === 'content' ? 'files' : 'reads' })
|
|
55
|
+
}
|
|
56
|
+
const day = Math.floor((o.now ?? Date.now()) / 86_400_000)
|
|
57
|
+
const picked = new Set(o.only != null ? pool.map((p) => p.id) : sampleOf(pool.map((p) => p.id), o.sample, day))
|
|
58
|
+
const rows: AuditRow[] = []
|
|
59
|
+
for (const p of pool.filter((x) => picked.has(x.id))) {
|
|
60
|
+
const t = performance.now()
|
|
61
|
+
// fresh: nothing comes from the cache. A green it finds is stored, a
|
|
62
|
+
// failure is not, which is why a stale entry is forgotten below
|
|
63
|
+
const run = await runCheckPass(root, { config, composed, audience: 'health', only: [p.id], fresh: true, frozen: true, status: false, print: () => {}, detail: () => {} })
|
|
64
|
+
const r = run.results.find((x) => x.id === p.id)
|
|
65
|
+
rows.push({ id: p.id, cached: p.cached, fresh: r?.status ?? 'skip', keyedOn: p.keyedOn, ms: Math.round(performance.now() - t) })
|
|
66
|
+
}
|
|
67
|
+
const stale = rows.filter((r) => r.fresh !== 'skip' && r.fresh !== r.cached)
|
|
68
|
+
// a fresh failure is never stored, so without this the stale green would
|
|
69
|
+
// keep being served under the same key
|
|
70
|
+
for (const r of stale) ledger.forget(r.id)
|
|
71
|
+
return { pool: pool.length, rows, stale }
|
|
72
|
+
}
|
|
@@ -22,7 +22,7 @@ export function canonicalBodies(opts: ScriptStyle = {}): Record<string, string>
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
// conditional canon (bodies stated by the vocabulary page) — keyed by the
|
|
25
|
-
// SK001 requirement name; a requirement with no canonical body
|
|
25
|
+
// SK001 requirement name; a requirement with no canonical body is
|
|
26
26
|
// deliberately absent: a fixer must not invent one
|
|
27
27
|
export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, string> {
|
|
28
28
|
return {
|
|
@@ -30,7 +30,7 @@ export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, strin
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// ONE WAY IN (build/shipkit.md): the verification verbs an agent types from
|
|
33
|
+
// ONE WAY IN (build/shipkit/one-way-in.md): the verification verbs an agent types from
|
|
34
34
|
// muscle memory ROUTE to shipkit, so `bun run test` gets the cache and the
|
|
35
35
|
// job attachment without anyone having to remember a rule. The tool body they
|
|
36
36
|
// used to hold moves to `<name>:raw`, which is what shipkit itself runs and
|
package/src/core/check-ctx.ts
CHANGED
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
// A context with no pass reports and reaches the world, and a repo read there
|
|
19
19
|
// throws rather than guess a root.
|
|
20
20
|
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
|
|
21
|
-
import { dirname, join } from 'path'
|
|
21
|
+
import { basename, dirname, isAbsolute, join } from 'path'
|
|
22
22
|
import { atomicWrite } from './locked-write.ts'
|
|
23
23
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
24
24
|
import { blobOid, dirEntries, entriesHash, isLinkAt, kindOf, listingHash, Reads, type DirEntry } from './read-set.ts'
|
|
25
25
|
import { CheckPass } from './check-pass.ts'
|
|
26
26
|
import { normalizeInput, WHOLE_TREE } from '../ledger/git-scan.ts'
|
|
27
|
+
import { shipkitRoot } from './git-root.ts'
|
|
27
28
|
import type { RepoConfig } from '../repo-config.ts'
|
|
28
29
|
import type { Scan } from '../scan.ts'
|
|
29
30
|
import type { Line } from './wire.ts'
|
|
@@ -123,17 +124,25 @@ export class CheckCtx {
|
|
|
123
124
|
if (p === WHOLE_TREE) throw new Error(`'${path}' is the repo root, not a file`)
|
|
124
125
|
return p
|
|
125
126
|
}
|
|
126
|
-
/** the text of a repo-relative file, null when there is none
|
|
127
|
+
/** the text of a repo-relative file, null when there is none. An
|
|
128
|
+
* absolute path reads a file outside the work tree (the kept audit under
|
|
129
|
+
* the git common dir), recorded by that path */
|
|
127
130
|
read(path: string): string | null {
|
|
128
131
|
return this.bytes(path)?.toString('utf8') ?? null
|
|
129
132
|
}
|
|
133
|
+
/** the text of a file under the repo's shipkit state dir (the git common
|
|
134
|
+
* dir, shared by every worktree), null when there is none. Recorded by
|
|
135
|
+
* its absolute path, like any read */
|
|
136
|
+
state(name: string): string | null {
|
|
137
|
+
return this.read(join(shipkitRoot(this.root), name))
|
|
138
|
+
}
|
|
130
139
|
/** the raw bytes of a repo-relative file (an image, a binary), null when
|
|
131
140
|
* there is none. Recorded exactly like `read` */
|
|
132
141
|
bytes(path: string): Buffer | null {
|
|
133
|
-
const p = this.rel(path)
|
|
142
|
+
const p = isAbsolute(path) ? path : this.rel(path)
|
|
134
143
|
let bytes: Buffer
|
|
135
144
|
try {
|
|
136
|
-
bytes = readFileSync(join(this.repo('read').root, p))
|
|
145
|
+
bytes = readFileSync(isAbsolute(p) ? p : join(this.repo('read').root, p))
|
|
137
146
|
} catch (e) {
|
|
138
147
|
const code = (e as { code?: string }).code
|
|
139
148
|
if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'EISDIR') throw e
|
|
@@ -245,7 +254,7 @@ export class CheckCtx {
|
|
|
245
254
|
// spawn without the Bun.spawn throw-on-missing-binary gotcha; SIGTERM at
|
|
246
255
|
// timeoutMs, SIGKILL 2s later (one code path: core/spawn-timeout.ts)
|
|
247
256
|
async exec(cmd: string[], opts?: { env?: Record<string, string | undefined>; cwd?: string; timeoutMs?: number }): Promise<{ code: number; out: string; timedOut: boolean }> {
|
|
248
|
-
this.world('
|
|
257
|
+
this.world(`\`${cmd.slice(0, 3).map((a) => basename(a)).join(' ')}\``)
|
|
249
258
|
// the environment as it stands NOW: left out, Bun hands the child the
|
|
250
259
|
// one it started with, and a variable set since (a test seam, a flag
|
|
251
260
|
// the gate set) never reaches it
|
package/src/core/check-runner.ts
CHANGED
|
@@ -40,15 +40,19 @@ import { machinePool, SLOT_HELD_ENV, takeSlot, type SlotPool } from './slots.ts'
|
|
|
40
40
|
import { budgetLine, machineLoad } from './budget.ts'
|
|
41
41
|
import { killTree, spawnWithTimeout } from './spawn-timeout.ts'
|
|
42
42
|
import { FOREVER_MS, formatLine, parseWireOutput, type Line } from './wire.ts'
|
|
43
|
-
import { offLine, type
|
|
43
|
+
import { offLine, runsIn, type ResolvedRuns, type RunContext } from './runs.ts'
|
|
44
44
|
import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
|
|
45
45
|
import { Ledger, stampCached } from '../ledger/ledger.ts'
|
|
46
|
-
import { attachedStamp } from './outcome-words.ts'
|
|
46
|
+
import { attachedStamp, ranStamp } from './outcome-words.ts'
|
|
47
47
|
import type { RunLog, RunStatus } from '../ledger/run-log.ts'
|
|
48
48
|
import { liveStateOf } from '../ledger/live-runs.ts'
|
|
49
49
|
import { CHECK_ID_ENV } from '../ledger/suite-log.ts'
|
|
50
50
|
import { rememberWorld, replayWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
|
|
51
51
|
import { extractProblems } from '../problems/extract.ts'
|
|
52
|
+
|
|
53
|
+
/** the first line of a check that ran in this run, stamped with its cost. A
|
|
54
|
+
* world answer carries its keep in a stamp already, so the cost joins it */
|
|
55
|
+
const ranHere = (l: Line, ms: number): Line => (l.stamp != null && l.stamp.cached !== true ? { ...l, stamp: { ...l.stamp, ranMs: ms } } : { ...l, text: `${l.text} ${ranStamp(ms)}` })
|
|
52
56
|
import { feedsProblemsPane, type Population, type Problem } from '../problems/problem-line.ts'
|
|
53
57
|
|
|
54
58
|
/** the ceiling a check takes when it names none. A build step's floor
|
|
@@ -82,7 +86,8 @@ export type RunnableCheck = {
|
|
|
82
86
|
blocking: boolean | null
|
|
83
87
|
/** a build step: the gate runs it, the health pass does not */
|
|
84
88
|
build: boolean
|
|
85
|
-
|
|
89
|
+
/** every context it runs in, resolved (core/runs.ts) */
|
|
90
|
+
runs: ResolvedRuns
|
|
86
91
|
deps: string[]
|
|
87
92
|
group: string | null
|
|
88
93
|
timeoutMs: number
|
|
@@ -159,8 +164,10 @@ export type RunChecksOpts = {
|
|
|
159
164
|
root: string
|
|
160
165
|
config: CheckEnv['config']
|
|
161
166
|
audience: Audience
|
|
162
|
-
/**
|
|
167
|
+
/** the full tests tier: `onFull` decides instead of `onCi` */
|
|
163
168
|
fullTier: boolean
|
|
169
|
+
/** the release preflight: `onRelease` decides, whatever the tier */
|
|
170
|
+
release?: boolean
|
|
164
171
|
/** where a `cmd` check's whole output is streamed */
|
|
165
172
|
logDir: string
|
|
166
173
|
/** the world answers' store, null = nothing is remembered by TTL */
|
|
@@ -256,10 +263,13 @@ export type Selection = {
|
|
|
256
263
|
errors: string[]
|
|
257
264
|
}
|
|
258
265
|
|
|
266
|
+
/** the axis each context reads, as a parked line names it */
|
|
267
|
+
const CTX_AXIS: Record<RunContext, string> = { preCommit: 'onPreCommit', ci: 'onCi', full: 'onFull', release: 'onRelease', health: 'onHealth' }
|
|
268
|
+
|
|
259
269
|
/** what this run asks about. The audience decides the shape of the question,
|
|
260
270
|
* `--only` and `--with` name checks past any filter, and OFF is the one word
|
|
261
271
|
* no flag lifts. */
|
|
262
|
-
export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'fullTier' | 'skipGroups' | 'onlyIds' | 'withIds' | 'onlyBlocking' | 'buildOnly' | 'exclude' | 'includeProactive'>): Selection {
|
|
272
|
+
export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'fullTier' | 'release' | 'skipGroups' | 'onlyIds' | 'withIds' | 'onlyBlocking' | 'buildOnly' | 'exclude' | 'includeProactive'>): Selection {
|
|
263
273
|
const errors: string[] = []
|
|
264
274
|
const ids = new Set(o.checks.map((u) => u.id))
|
|
265
275
|
if (ids.size !== o.checks.length) errors.push('duplicate check ids')
|
|
@@ -318,26 +328,28 @@ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'ful
|
|
|
318
328
|
if (u.blocking == null) undeclared.push(u.id)
|
|
319
329
|
selected.push(u)
|
|
320
330
|
}
|
|
331
|
+
// the one context this run asks about: every row declares whether it
|
|
332
|
+
// runs there (core/runs.ts), and nothing is guessed from its kind
|
|
333
|
+
const ctx: RunContext = o.audience === 'health' ? 'health' : o.release === true ? 'release' : o.fullTier ? 'full' : 'ci'
|
|
321
334
|
for (const u of o.checks) {
|
|
322
|
-
// IS IT THIS RUN'S BUSINESS AT ALL? The
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
// A check out of scope here is not even reported as off: it is not part
|
|
327
|
-
// of this question.
|
|
335
|
+
// IS IT THIS RUN'S BUSINESS AT ALL? The TUI's build panel asks for
|
|
336
|
+
// nothing but build steps, and a sub-unit release skips what its own
|
|
337
|
+
// gate ran. A check out of scope here is not even reported as off: it
|
|
338
|
+
// is not part of this question.
|
|
328
339
|
if (!named.has(u.id)) {
|
|
329
|
-
if (o.audience === 'health' && u.build) continue
|
|
330
340
|
if (o.buildOnly === true && !u.build) continue
|
|
331
341
|
if (excluded.has(u.id)) { parked.push({ id: u.id, text: `${u.id} (not part of this release, its own gate covers it)` }); continue }
|
|
332
342
|
}
|
|
333
343
|
// OFF is the one word that means never, past every other flag: a
|
|
334
344
|
// switch a filter can lift is a suggestion
|
|
335
|
-
if (u.
|
|
345
|
+
if (u.runs.off != null) { off.push(u); continue }
|
|
336
346
|
if (named.has(u.id)) { take(u); continue }
|
|
337
347
|
if (u.group != null && skipGroups.has(u.group)) { parked.push({ id: u.id, text: `${u.id} (--skip ${u.group})` }); continue }
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
if (
|
|
348
|
+
if (!runsIn(u.runs, ctx)) {
|
|
349
|
+
// the health pass answers its own question and lists nothing else
|
|
350
|
+
if (ctx === 'health') continue
|
|
351
|
+
// release.requireHealthy asks the gate for the health probes too
|
|
352
|
+
if (!(o.includeProactive === true && u.runs.onHealth && !u.build)) { parked.push({ id: u.id, text: `${u.id} (not ${CTX_AXIS[ctx]})` }); continue }
|
|
341
353
|
}
|
|
342
354
|
if (o.onlyBlocking === true && u.blocking !== true) { if (u.blocking == null) undeclared.push(u.id); nonBlocking.set(u.id, u); continue }
|
|
343
355
|
take(u)
|
|
@@ -430,6 +442,8 @@ type Live = RunnableCheck & CheckResult & {
|
|
|
430
442
|
/** what an in process body read, null when it reached the world or ran
|
|
431
443
|
* as a subprocess */
|
|
432
444
|
reads: ReadSet | null
|
|
445
|
+
/** why its answer cannot be stored on its reads, in its own words */
|
|
446
|
+
untracked: string | null
|
|
433
447
|
/** it printed nothing of its own, so its one line is the headline below */
|
|
434
448
|
synthetic: boolean
|
|
435
449
|
/** what the headline carries after the id: a failure's last line, or the
|
|
@@ -471,7 +485,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
471
485
|
// OFF, in ONE place: skipped by name, never run, never stored, and the
|
|
472
486
|
// reason printed where the verdict would have been
|
|
473
487
|
for (const u of [...sel.off, ...(sel.offElsewhere ?? [])]) {
|
|
474
|
-
const line: Line = { kind: 'skip', text: offLine(u.id, u.
|
|
488
|
+
const line: Line = { kind: 'skip', text: offLine(u.id, u.runs) }
|
|
475
489
|
if (sel.off.includes(u)) emit(line); else (o.detail ?? print)(formatLine(line, color))
|
|
476
490
|
report(u, { id: u.id, status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [line], problems: [], lastLine: line.text, note: 'off', logPath: null })
|
|
477
491
|
}
|
|
@@ -486,7 +500,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
486
500
|
const runDir = openRunDir(o.logDir)
|
|
487
501
|
const checks: Live[] = sel.selected.map((u) => ({
|
|
488
502
|
...u,
|
|
489
|
-
status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, synthetic: false, tail: '', extra: [],
|
|
503
|
+
status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, untracked: null, synthetic: false, tail: '', extra: [],
|
|
490
504
|
logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
|
|
491
505
|
}))
|
|
492
506
|
const selectedIds = new Set(checks.map((u) => u.id))
|
|
@@ -566,9 +580,14 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
566
580
|
const ran = u.status === 'pass' || u.status === 'warn' ? testsRan(u.lastLine) : null
|
|
567
581
|
const counted = ran != null && head != null && head.kind === 'ok' && (head.text === u.id || head.text.startsWith(`${u.id} (`))
|
|
568
582
|
const id = counted ? `${u.id} ${ran} test${ran === 1 ? '' : 's'}` : u.id
|
|
569
|
-
const
|
|
570
|
-
? [{ ...head, text: `${id}
|
|
583
|
+
const composed = u.synthetic && !u.replayed && head != null
|
|
584
|
+
? [{ ...head, text: `${id} ${ranStamp(u.ms)}${u.tail}` }, ...u.lines.slice(1)]
|
|
571
585
|
: counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}` }, ...u.lines.slice(1)] : u.lines
|
|
586
|
+
// every line says where its answer came from: a replay carries its
|
|
587
|
+
// cached stamp already, so one that ran here says so and what it cost
|
|
588
|
+
const shown = u.synthetic || u.replayed || u.attached || u.startedAt === 0 || composed[0] == null
|
|
589
|
+
? composed
|
|
590
|
+
: [ranHere(composed[0], u.ms), ...composed.slice(1)]
|
|
572
591
|
// a caller printing its own verdict wants one line per green check and
|
|
573
592
|
// only the warnings of a warned one; a failure prints whole
|
|
574
593
|
const quiet = (l: Line, i: number): boolean =>
|
|
@@ -585,7 +604,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
585
604
|
const hit = o.ledger?.replay(u.id)
|
|
586
605
|
if (hit != null) {
|
|
587
606
|
u.replayed = true
|
|
588
|
-
u.lines = stampCached(hit.lines, hit.measuredMs)
|
|
607
|
+
u.lines = stampCached(hit.lines, hit.measuredMs, hit.at, clock())
|
|
589
608
|
u.lastLine = hit.lastLine ?? ''
|
|
590
609
|
u.status = hit.status
|
|
591
610
|
// a replay stands for the verdict it replays, findings included:
|
|
@@ -612,6 +631,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
612
631
|
// id, every line is tagged, and a failure carries a fix pointer
|
|
613
632
|
await runCheckBody({ id: u.id, title: u.title, owner: u.owner, run: u.body.run, fix: u.fix }, t)
|
|
614
633
|
u.reads = t.untracked == null ? t.reads.snapshot() : null
|
|
634
|
+
u.untracked = t.untracked
|
|
615
635
|
u.lines = t.lines
|
|
616
636
|
u.problems = findings(u, t.lines)
|
|
617
637
|
u.unmeasured = t.unmeasured
|
|
@@ -768,7 +788,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
768
788
|
await measureAndKeep(u)
|
|
769
789
|
return { value: answerOf(u), share: !killed.has(u.id) && !u.unmeasured }
|
|
770
790
|
})
|
|
771
|
-
if (r.kind === 'attached') { o.ledger?.noteAttached(); adopt(u, r.value, r.pid) }
|
|
791
|
+
if (r.kind === 'attached') { o.ledger?.noteAttached(u.id); adopt(u, r.value, r.pid) }
|
|
772
792
|
else if (r.kind === 'aborted') { u.status = 'skip'; u.note = bailed ? 'bailed' : 'interrupted'; u.lines = [] }
|
|
773
793
|
else if (r.kind === 'overlapped') emit({ kind: 'warn', text: `${u.id} ran again beside another run: ${r.warn}` })
|
|
774
794
|
}
|
|
@@ -811,7 +831,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
811
831
|
})
|
|
812
832
|
}
|
|
813
833
|
if (o.ledger != null && keep)
|
|
814
|
-
o.ledger.record(u.id, { status: u.status as 'pass' | 'warn', lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads)
|
|
834
|
+
o.ledger.record(u.id, { status: u.status as 'pass' | 'warn', lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads, u.untracked)
|
|
815
835
|
}
|
|
816
836
|
|
|
817
837
|
const start = (u: Live): void => {
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// THE CONFIG HALF of the SK016
|
|
1
|
+
// THE CONFIG HALF of the SK016 and SK029 migrations, in its own process. Writing the
|
|
2
2
|
// managed block and reading the result back both LOAD repo.config.ts as a
|
|
3
3
|
// module, which no check context read can stand for, so the fixer reaches it
|
|
4
4
|
// through `c.exec` and the check file itself never imports the loader.
|
|
5
5
|
//
|
|
6
6
|
// bun config-rewrite.ts <root> '<plan json>' → one json line on stdout
|
|
7
7
|
//
|
|
8
|
-
// The order is the migration's:
|
|
9
|
-
// dropped
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// The order is the migration's: a key the managed block holds (the retired
|
|
9
|
+
// `when`) is dropped first, so the block describes the config again, then the
|
|
10
|
+
// `off` entries are written into the block, then each other key is dropped
|
|
11
|
+
// only once what leaned on it has a home. The result is read back through the
|
|
12
|
+
// loader and the previous bytes go back when it does not survive.
|
|
12
13
|
import { readFileSync } from 'fs'
|
|
13
14
|
import { join } from 'path'
|
|
14
15
|
import { atomicWrite } from './locked-write.ts'
|
|
@@ -17,10 +18,11 @@ import { writeGateBlock } from './when-block.ts'
|
|
|
17
18
|
import { loadRepoConfig } from '../repo-config.ts'
|
|
18
19
|
|
|
19
20
|
export type ConfigPlan = {
|
|
20
|
-
/** check id →
|
|
21
|
-
|
|
22
|
-
/** keys to drop, in order. `
|
|
23
|
-
|
|
21
|
+
/** check id → why it is off, written into the managed block */
|
|
22
|
+
off: Record<string, string>
|
|
23
|
+
/** keys to drop, in order. `first` drops before the block is written (a
|
|
24
|
+
* key inside it), `afterOff` only once the block took the entries */
|
|
25
|
+
drop: { key: string; why: string; phase?: 'first' | 'afterOff' }[]
|
|
24
26
|
/** the config's `name` before the rewrite: what the read back must still say */
|
|
25
27
|
name: string
|
|
26
28
|
}
|
|
@@ -35,27 +37,38 @@ export type ConfigRewrite = {
|
|
|
35
37
|
export async function rewriteConfig(root: string, plan: ConfigPlan): Promise<ConfigRewrite> {
|
|
36
38
|
const did: string[] = []
|
|
37
39
|
const hand: string[] = []
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
const path = join(root, 'repo.config.ts')
|
|
41
|
+
const before = readFileSync(path, 'utf8')
|
|
42
|
+
const drop = (text: string, d: ConfigPlan['drop'][number]): string => {
|
|
43
|
+
const next = dropConfigKey(text, d.key)
|
|
44
|
+
if (next == null) { hand.push(`repo.config.ts \`${d.key}\`: ${d.why}, and the key could not be read mechanically — delete it by hand`); return text }
|
|
45
|
+
did.push(`repo.config.ts drops \`${d.key}\``)
|
|
46
|
+
return next
|
|
47
|
+
}
|
|
48
|
+
let first = before
|
|
49
|
+
for (const d of plan.drop.filter((x) => x.phase === 'first')) first = drop(first, d)
|
|
50
|
+
if (first !== before) atomicWrite(path, first)
|
|
51
|
+
let offWritten = false
|
|
52
|
+
if (Object.keys(plan.off).length > 0) {
|
|
40
53
|
try {
|
|
41
|
-
await writeGateBlock(root, {
|
|
42
|
-
|
|
43
|
-
did.push(`${Object.keys(plan.
|
|
54
|
+
await writeGateBlock(root, { off: plan.off })
|
|
55
|
+
offWritten = true
|
|
56
|
+
did.push(`${Object.keys(plan.off).length} off entr(ies) written into the managed block`)
|
|
44
57
|
} catch (e) {
|
|
45
|
-
|
|
58
|
+
// a key dropped first held these entries: without the block they
|
|
59
|
+
// would be gone, so the file goes back as it was
|
|
60
|
+
if (first !== before) { atomicWrite(path, before); return { did: [], hand, restored: `the managed block refused off ${JSON.stringify(plan.off)}: ${String(e).slice(0, 160)}` } }
|
|
61
|
+
hand.push(`the managed block refused the rewrite (${String(e).slice(0, 160)}) — write off: ${JSON.stringify(plan.off)} into it by hand`)
|
|
46
62
|
}
|
|
47
63
|
}
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const next = dropConfigKey(text, d.key)
|
|
54
|
-
if (next == null) hand.push(`repo.config.ts \`${d.key}\`: ${d.why}, and the key could not be read mechanically — delete it by hand`)
|
|
55
|
-
else { text = next; did.push(`repo.config.ts drops \`${d.key}\``) }
|
|
64
|
+
const mid = readFileSync(path, 'utf8')
|
|
65
|
+
let text = mid
|
|
66
|
+
for (const d of plan.drop.filter((x) => x.phase !== 'first')) {
|
|
67
|
+
if (d.phase === 'afterOff' && !offWritten) continue
|
|
68
|
+
text = drop(text, d)
|
|
56
69
|
}
|
|
70
|
+
if (text !== mid) atomicWrite(path, text)
|
|
57
71
|
if (text !== before) {
|
|
58
|
-
atomicWrite(path, text)
|
|
59
72
|
try {
|
|
60
73
|
const after = await loadRepoConfig(root, { fresh: true })
|
|
61
74
|
if (after.name !== plan.name) throw new Error(`the config reads as '${String(after.name)}'`)
|