rvlib-shipkit 1.5.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 +46 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +12 -8
- package/src/assets/server/studio-server.ts +14 -2
- package/src/check.ts +5 -8
- package/src/checks/SK001-package-json-shape.check.ts +5 -4
- package/src/checks/SK003-claude-md-shape.check.ts +2 -2
- 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_Assets.ts +10 -6
- package/src/cli/cmd_CacheAudit.ts +43 -0
- package/src/cli/cmd_CrossRepoActivity.ts +2 -1
- package/src/cli/cmd_GlobalProcesses.ts +4 -3
- package/src/cli/cmd_Home.ts +3 -2
- package/src/cli/cmd_Keys.ts +12 -1
- package/src/cli/cmd_Overview.ts +1 -1
- package/src/cli/cmd_Serve.ts +2 -1
- package/src/cli/cmd_TraceAudit.ts +62 -0
- package/src/cli.ts +4 -0
- package/src/collections/collection-types.ts +3 -0
- 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 +1 -1
- package/src/core/check-ctx.ts +14 -5
- package/src/core/check-runner.ts +6 -3
- 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/one-lib.ts +59 -4
- package/src/core/read-set.ts +97 -9
- package/src/core/run-ci.ts +6 -1
- package/src/core/run-fixers.ts +8 -2
- package/src/core/trace-audit.ts +120 -0
- package/src/home/gui-app.ts +24 -27
- package/src/home/home-start.ts +11 -5
- package/src/home/open-app.ts +2 -2
- package/src/home/via-hub.ts +14 -0
- package/src/ledger/db.ts +2 -13
- package/src/ledger/gate-checks.ts +21 -3
- package/src/ledger/git-key.ts +148 -30
- package/src/ledger/git-scan.ts +5 -2
- package/src/ledger/ledger.ts +131 -16
- package/src/ledger/store.ts +14 -0
- package/src/ledger/toolchain-identity.ts +210 -25
- package/src/problems/extract.ts +3 -0
- package/src/repo-config.ts +18 -1
- package/src/serve/hub-app/admin-svc.ts +36 -3
- package/src/serve/hub-app/admin-view.tsx +33 -1
- package/src/serve/hub-app/col-card.tsx +1 -44
- package/src/serve/hub-app/col-tables-view.tsx +71 -5
- package/src/serve/hub-app/col-tables.ts +27 -0
- package/src/serve/hub-app/home-st.ts +1 -1
- package/src/serve/hub-app/home-strip.css +5 -9
- package/src/serve/hub-app/home-strip.tsx +4 -17
- package/src/serve/hub-app/hub-app.tsx +18 -4
- package/src/serve/hub-app/hub.css +24 -20
- package/src/serve/hub-app/keys.ts +10 -2
- package/src/serve/hub-app/processes-view.tsx +16 -9
- 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 +10 -0
- package/src/serve/hub-app/repo-sections.ts +31 -17
- package/src/serve/hub-app/repo-st.ts +24 -5
- package/src/serve/hub-app/sidebar.tsx +5 -5
- package/src/serve/hub-app/tree-build.ts +2 -0
- package/src/serve/hub-app/tree-row.tsx +18 -5
- package/src/serve/hub-app/tree-svc.ts +27 -7
- package/src/serve/hub-runtime.ts +4 -2
- package/src/serve/project-gutter-route.ts +22 -1
- 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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// sk trace-audit — which gitignored paths each gate task reads that its key
|
|
2
|
+
// cannot see, found by running the task once under the tracer, and with
|
|
3
|
+
// --fix written into repo.config.ts (core/trace-audit.ts).
|
|
4
|
+
import { Command, Option } from 'clipanion'
|
|
5
|
+
import { loadRepoConfig } from '../repo-config.ts'
|
|
6
|
+
import { applyAudit, traceAudit } from '../core/trace-audit.ts'
|
|
7
|
+
import { describeByTask } from '../core/audit-report.ts'
|
|
8
|
+
import { setTraceEnabled } from '../trace/tracer.ts'
|
|
9
|
+
|
|
10
|
+
export class TraceAuditCommand extends Command {
|
|
11
|
+
static override paths = [['trace-audit']]
|
|
12
|
+
|
|
13
|
+
static override usage = Command.Usage({
|
|
14
|
+
category: 'Health',
|
|
15
|
+
description: 'gitignored paths a gate task reads that its key misses, found by tracing it once',
|
|
16
|
+
details: 'Runs each gate task once under the tracer (on by default: --disable for this machine, or SHIPKIT_TRACE=0 for one run) and lists the gitignored paths it read that neither the tracked tree nor repo.config ignoredInputs covers. The answer is kept for about a day under the git common dir, where every worktree reads it, and each finding joins the key of the task that read it at once. --fix writes the findings into the managed block of repo.config.ts, per task, without tracing again. Exit 1 when a key misses something.',
|
|
17
|
+
examples: [
|
|
18
|
+
['audit, reusing a recent answer', 'sk trace-audit'],
|
|
19
|
+
['trace again now', 'sk trace-audit --fresh'],
|
|
20
|
+
['write the proposals into repo.config.ts', 'sk trace-audit --fix'],
|
|
21
|
+
['machine-readable', 'sk trace-audit --json'],
|
|
22
|
+
['stop tracing on this machine', 'sk trace-audit --disable'],
|
|
23
|
+
['trace again, the default', 'sk trace-audit --enable'],
|
|
24
|
+
],
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
fresh = Option.Boolean('--fresh', false, { description: 'trace again even when a recent answer is kept' })
|
|
28
|
+
fix = Option.Boolean('--fix', false, { description: 'write the kept proposals into repo.config.ts ignoredInputs' })
|
|
29
|
+
json = Option.Boolean('--json', false, { description: 'machine-readable report' })
|
|
30
|
+
enable = Option.Boolean('--enable', false, { description: 'turn tracing back on for this machine, the default: the daemon\'s health pass audits about daily (SK031)' })
|
|
31
|
+
disable = Option.Boolean('--disable', false, { description: 'stop tracing on this machine: nothing audits here until --enable' })
|
|
32
|
+
|
|
33
|
+
async execute(): Promise<number> {
|
|
34
|
+
const root = process.cwd()
|
|
35
|
+
const out = (l: string): void => { this.context.stdout.write(`${l}\n`) }
|
|
36
|
+
if (this.enable || this.disable) {
|
|
37
|
+
setTraceEnabled(this.enable)
|
|
38
|
+
out(this.enable ? 'ok: tracing is on for this machine: the health pass audits each repo about daily (SK031)' : 'ok: tracing is off for this machine')
|
|
39
|
+
return 0
|
|
40
|
+
}
|
|
41
|
+
if (this.fix) {
|
|
42
|
+
let r: Awaited<ReturnType<typeof applyAudit>>
|
|
43
|
+
// a config the block writer refuses (a hand edit, a key declared
|
|
44
|
+
// outside the markers) is an answer for a human, never a crash
|
|
45
|
+
try { r = await applyAudit(root) } catch (e) { out(`FAIL: ${(e as Error).message}`); return 1 }
|
|
46
|
+
if (r.note != null) out(`skip: ${r.note}`)
|
|
47
|
+
else out(Object.keys(r.added).length === 0 ? 'ok: repo.config.ts ignoredInputs already covers every path the audit found' : `ok: repo.config.ts ignoredInputs gained ${describeByTask(r.added)}`)
|
|
48
|
+
return 0
|
|
49
|
+
}
|
|
50
|
+
const r = await traceAudit(root, await loadRepoConfig(root), { fresh: this.fresh })
|
|
51
|
+
const missing = r.tasks.filter((t) => t.uncovered.length > 0)
|
|
52
|
+
if (this.json) { out(JSON.stringify(r)); return missing.length > 0 ? 1 : 0 }
|
|
53
|
+
if (r.skipped != null) { out(`skip: nothing traced: ${r.skipped}`); return 0 }
|
|
54
|
+
for (const t of r.tasks) {
|
|
55
|
+
if (t.status === 'unaudited') out(`WARN ${t.id} could not be audited: ${t.why}`)
|
|
56
|
+
else if (t.uncovered.length > 0) out(`FAIL: ${t.id} reads ${t.uncovered.length} gitignored path(s) its key misses: ${t.uncovered.slice(0, 5).join(', ')}${t.uncovered.length > 5 ? ', …' : ''}`)
|
|
57
|
+
else out(`ok: ${t.id}: its key covers every gitignored path it read`)
|
|
58
|
+
}
|
|
59
|
+
if (missing.length > 0) out(` fix: sk trace-audit --fix adds ${describeByTask(Object.fromEntries(missing.map((t) => [t.id, t.propose])))} to repo.config.ts ignoredInputs`)
|
|
60
|
+
return missing.length > 0 ? 1 : 0
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -30,6 +30,8 @@ import { ReleaseCommand } from './cli/cmd_Release.ts'
|
|
|
30
30
|
import { ReleasesCommand } from './cli/cmd_Releases.ts'
|
|
31
31
|
import { ChecksCommand, UnitsAliasCommand } from './cli/cmd_Checks.ts'
|
|
32
32
|
import { WhenCommand } from './cli/cmd_When.ts'
|
|
33
|
+
import { CacheAuditCommand } from './cli/cmd_CacheAudit.ts'
|
|
34
|
+
import { TraceAuditCommand } from './cli/cmd_TraceAudit.ts'
|
|
33
35
|
import { ServeCommand } from './cli/cmd_Serve.ts'
|
|
34
36
|
import { SmokeCommand } from './cli/cmd_Smoke.ts'
|
|
35
37
|
import { StatusCommand } from './cli/cmd_Status.ts'
|
|
@@ -100,6 +102,8 @@ export function buildCli(): Cli {
|
|
|
100
102
|
cli.register(OverviewCommand)
|
|
101
103
|
cli.register(ChecksCommand)
|
|
102
104
|
cli.register(WhenCommand)
|
|
105
|
+
cli.register(CacheAuditCommand)
|
|
106
|
+
cli.register(TraceAuditCommand)
|
|
103
107
|
cli.register(UnitsAliasCommand)
|
|
104
108
|
cli.register(CommandsCommand)
|
|
105
109
|
cli.register(ConfigKeysCommand)
|
|
@@ -228,6 +228,9 @@ export type Instance = {
|
|
|
228
228
|
* kind, a secret present or missing, a post its venue, an asset its roll
|
|
229
229
|
* count. Absent = this kind has nothing to say there */
|
|
230
230
|
slot?: string
|
|
231
|
+
/** the group it sits in on the tree, under its section: an asset's kit
|
|
232
|
+
* category. Absent = directly under the section */
|
|
233
|
+
group?: string
|
|
231
234
|
type: ResourceType
|
|
232
235
|
/** a slash path. Where a truth file exists this IS its repo-relative
|
|
233
236
|
* path; otherwise a VIRTUAL path under a root named for the type, and
|
|
@@ -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 {
|
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
|
@@ -442,6 +442,8 @@ type Live = RunnableCheck & CheckResult & {
|
|
|
442
442
|
/** what an in process body read, null when it reached the world or ran
|
|
443
443
|
* as a subprocess */
|
|
444
444
|
reads: ReadSet | null
|
|
445
|
+
/** why its answer cannot be stored on its reads, in its own words */
|
|
446
|
+
untracked: string | null
|
|
445
447
|
/** it printed nothing of its own, so its one line is the headline below */
|
|
446
448
|
synthetic: boolean
|
|
447
449
|
/** what the headline carries after the id: a failure's last line, or the
|
|
@@ -498,7 +500,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
498
500
|
const runDir = openRunDir(o.logDir)
|
|
499
501
|
const checks: Live[] = sel.selected.map((u) => ({
|
|
500
502
|
...u,
|
|
501
|
-
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: [],
|
|
502
504
|
logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
|
|
503
505
|
}))
|
|
504
506
|
const selectedIds = new Set(checks.map((u) => u.id))
|
|
@@ -629,6 +631,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
629
631
|
// id, every line is tagged, and a failure carries a fix pointer
|
|
630
632
|
await runCheckBody({ id: u.id, title: u.title, owner: u.owner, run: u.body.run, fix: u.fix }, t)
|
|
631
633
|
u.reads = t.untracked == null ? t.reads.snapshot() : null
|
|
634
|
+
u.untracked = t.untracked
|
|
632
635
|
u.lines = t.lines
|
|
633
636
|
u.problems = findings(u, t.lines)
|
|
634
637
|
u.unmeasured = t.unmeasured
|
|
@@ -785,7 +788,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
785
788
|
await measureAndKeep(u)
|
|
786
789
|
return { value: answerOf(u), share: !killed.has(u.id) && !u.unmeasured }
|
|
787
790
|
})
|
|
788
|
-
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) }
|
|
789
792
|
else if (r.kind === 'aborted') { u.status = 'skip'; u.note = bailed ? 'bailed' : 'interrupted'; u.lines = [] }
|
|
790
793
|
else if (r.kind === 'overlapped') emit({ kind: 'warn', text: `${u.id} ran again beside another run: ${r.warn}` })
|
|
791
794
|
}
|
|
@@ -828,7 +831,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
828
831
|
})
|
|
829
832
|
}
|
|
830
833
|
if (o.ledger != null && keep)
|
|
831
|
-
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)
|
|
832
835
|
}
|
|
833
836
|
|
|
834
837
|
const start = (u: Live): void => {
|
|
@@ -201,9 +201,9 @@ function builtinFloorTask(id: string, root: string, io: FloorReader): { task: Ta
|
|
|
201
201
|
return `no package.json '${id}' script`
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
// NOTHING here declares a read set on a repo's behalf
|
|
205
|
-
//
|
|
206
|
-
//
|
|
204
|
+
// NOTHING here declares a read set on a repo's behalf. The composer keys a
|
|
205
|
+
// floor slot with no `inputs` on the whole work tree plus the repo's
|
|
206
|
+
// `ignoredInputs` (ledger/gate-checks.ts), and the traced audit fills those.
|
|
207
207
|
//
|
|
208
208
|
// The tempting shortcut is `['.']`, on the reasoning that the whole tree cannot
|
|
209
209
|
// under declare. It can. `['.']` is the TRACKED tree: git-scan force-adds only a
|
|
@@ -214,10 +214,8 @@ function builtinFloorTask(id: string, root: string, io: FloorReader): { task: Ta
|
|
|
214
214
|
// gate print `ok: test (replayed)` over a suite that exits 1
|
|
215
215
|
// (tests/floor-cache-safety.test.ts holds the repro).
|
|
216
216
|
//
|
|
217
|
-
//
|
|
218
|
-
// `defaults
|
|
219
|
-
// `shipkit init` scaffolds the line, `shipkit keys --burn-down` names what is
|
|
220
|
-
// still undeclared, and the gate prints the cost of it once per run.
|
|
217
|
+
// A narrower key is bought by a REPO saying what its own tasks read, in
|
|
218
|
+
// `defaults`.
|
|
221
219
|
|
|
222
220
|
export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): FloorResolution {
|
|
223
221
|
const pkg = io.text('package.json')
|
package/src/core/git-root.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'fs'
|
|
9
9
|
import { dirname, join, resolve } from 'path'
|
|
10
10
|
import { gitBin } from './git-bin.ts'
|
|
11
|
+
import { LAYOUT } from './layout.ts'
|
|
11
12
|
|
|
12
13
|
export type GitRoot = {
|
|
13
14
|
/** absolute git dir of this work tree (per worktree) */
|
|
@@ -130,3 +131,15 @@ export const gitRoot = (dir: string): GitRoot | null => probeGitRoot(dir).root
|
|
|
130
131
|
export function isLinkedWorktree(root: string): boolean {
|
|
131
132
|
try { return statSync(join(root, '.git')).isFile() } catch { return false }
|
|
132
133
|
}
|
|
134
|
+
|
|
135
|
+
/** the one dir everything this repo remembers hangs off.
|
|
136
|
+
*
|
|
137
|
+
* `--git-common-dir` rather than `--git-dir`: a linked worktree has its own
|
|
138
|
+
* git dir but shares the common one, and that sharing is safe because every
|
|
139
|
+
* key component is content or a version string. A tree that is not a git
|
|
140
|
+
* repo at all still gets one, under `.shipkit/cache/`, because a scaffold in
|
|
141
|
+
* progress must not crash on it. */
|
|
142
|
+
export function shipkitRoot(root: string): string {
|
|
143
|
+
const dir = gitRoot(root)?.commonDir
|
|
144
|
+
return dir == null ? join(root, LAYOUT.cache) : join(dir, 'shipkit')
|
|
145
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// THE IGNORED INPUTS BLOCK in `repo.config.ts`: the one place the traced
|
|
2
|
+
// audit (core/trace-audit.ts) writes the gitignored paths each task reads.
|
|
3
|
+
//
|
|
4
|
+
// Its own markers, apart from the gate block (core/when-block.ts), so a repo
|
|
5
|
+
// that writes `off` or `tests` by hand, with its comments, keeps them exactly
|
|
6
|
+
// as they are. The same paranoia as the gate block:
|
|
7
|
+
// 1. `ignoredInputs` declared OUTSIDE the markers is refused, because a
|
|
8
|
+
// second declaration is a duplicate key rather than a merge
|
|
9
|
+
// 2. a block that no longer renders what the config loads was hand edited,
|
|
10
|
+
// and is refused rather than overwritten
|
|
11
|
+
// 3. the result is read back through the loader the cli uses, and the
|
|
12
|
+
// previous bytes go back when it does not round trip
|
|
13
|
+
import { existsSync, readFileSync } from 'fs'
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
import { atomicWrite } from './locked-write.ts'
|
|
16
|
+
import { loadRepoConfig } from '../repo-config.ts'
|
|
17
|
+
import type { IgnoredInputs } from './audit-report.ts'
|
|
18
|
+
|
|
19
|
+
export const IGNORED_BEGIN = '// >>> shipkit ignored inputs — managed block, the traced audit writes it, edit outside the markers'
|
|
20
|
+
export const IGNORED_END = '// <<< shipkit ignored inputs'
|
|
21
|
+
|
|
22
|
+
const isBegin = (l: string): boolean => l.trim().startsWith('// >>> shipkit ignored inputs')
|
|
23
|
+
const isEnd = (l: string): boolean => l.trim().startsWith('// <<< shipkit ignored inputs')
|
|
24
|
+
const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
25
|
+
const key = (s: string): string => (/^[A-Za-z_$][\w$]*$/.test(s) ? s : q(s))
|
|
26
|
+
|
|
27
|
+
/** the same map, tasks and paths sorted, empty tasks dropped */
|
|
28
|
+
export function normalizeIgnored(ii: IgnoredInputs): IgnoredInputs {
|
|
29
|
+
const out: IgnoredInputs = {}
|
|
30
|
+
for (const t of Object.keys(ii).sort()) if ((ii[t] ?? []).length > 0) out[t] = [...new Set(ii[t])].sort()
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** the bytes between the markers, markers included */
|
|
35
|
+
export function renderIgnoredBlock(ii: IgnoredInputs, pad: string): string[] {
|
|
36
|
+
const n = normalizeIgnored(ii)
|
|
37
|
+
return [
|
|
38
|
+
`${pad}${IGNORED_BEGIN}`,
|
|
39
|
+
`${pad}ignoredInputs: {`,
|
|
40
|
+
...Object.entries(n).flatMap(([t, ps]) => [`${pad}${pad}${key(t)}: [`, ...ps.map((p) => `${pad}${pad}${pad}${q(p)},`), `${pad}${pad}],`]),
|
|
41
|
+
`${pad}},`,
|
|
42
|
+
`${pad}${IGNORED_END}`,
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function indentOf(lines: string[]): string {
|
|
47
|
+
for (const l of lines) {
|
|
48
|
+
const m = /^([ \t]+)\S/.exec(l)
|
|
49
|
+
if (m != null) return m[1]!
|
|
50
|
+
}
|
|
51
|
+
return ' '
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const validIgnoredInput = (p: unknown): p is string => typeof p === 'string' && p !== '' && !p.startsWith('/') && !p.split('/').includes('..')
|
|
55
|
+
|
|
56
|
+
/** add `add` (task id → paths) to the repo's ignoredInputs, through the
|
|
57
|
+
* managed block; the map after the write */
|
|
58
|
+
export async function addIgnoredInputs(root: string, add: IgnoredInputs): Promise<IgnoredInputs> {
|
|
59
|
+
const file = join(root, 'repo.config.ts')
|
|
60
|
+
if (!existsSync(file)) throw new Error(`no repo.config.ts in ${root} — run: shipkit init`)
|
|
61
|
+
for (const p of Object.values(add).flat()) if (!validIgnoredInput(p)) throw new Error(`ignoredInputs '${String(p)}' must be a repo-relative path inside the repo`)
|
|
62
|
+
const text = readFileSync(file, 'utf8')
|
|
63
|
+
const lines = text.split('\n')
|
|
64
|
+
const c = await loadRepoConfig(root, { fresh: true })
|
|
65
|
+
const have = c.ignoredInputs ?? {}
|
|
66
|
+
const b = lines.findIndex(isBegin)
|
|
67
|
+
const e = lines.findIndex(isEnd)
|
|
68
|
+
if (b === -1 || e === -1 || e < b) {
|
|
69
|
+
if (Object.keys(have).length > 0) throw new Error('repo.config.ts declares `ignoredInputs` outside the managed block — move it between the markers first, or the audit would write a duplicate key')
|
|
70
|
+
} else {
|
|
71
|
+
const pad = /^([ \t]*)/.exec(lines[b]!)?.[1] ?? ''
|
|
72
|
+
if (lines.slice(b, e + 1).join('\n') !== renderIgnoredBlock(have, pad).join('\n'))
|
|
73
|
+
throw new Error('the shipkit ignored inputs block in repo.config.ts no longer matches what the config loads, so it was hand edited — reconcile it by hand, then run the audit fix again')
|
|
74
|
+
}
|
|
75
|
+
const merged: IgnoredInputs = { ...have }
|
|
76
|
+
for (const [t, ps] of Object.entries(add)) merged[t] = [...(merged[t] ?? []), ...ps]
|
|
77
|
+
const next = normalizeIgnored(merged)
|
|
78
|
+
let out: string[]
|
|
79
|
+
if (b !== -1 && e > b) out = [...lines.slice(0, b), ...renderIgnoredBlock(next, /^([ \t]*)/.exec(lines[b]!)?.[1] ?? ''), ...lines.slice(e + 1)]
|
|
80
|
+
else {
|
|
81
|
+
const anchor = lines.findIndex((l) => /defineRepo\(\s*\{\s*$/.test(l))
|
|
82
|
+
if (anchor === -1) throw new Error(`repo.config.ts has no \`defineRepo({\` line to write into — paste this block inside the config object by hand:\n${renderIgnoredBlock(next, indentOf(lines)).join('\n')}`)
|
|
83
|
+
out = [...lines.slice(0, anchor + 1), ...renderIgnoredBlock(next, indentOf(lines)), ...lines.slice(anchor + 1)]
|
|
84
|
+
}
|
|
85
|
+
atomicWrite(file, out.join('\n'))
|
|
86
|
+
try {
|
|
87
|
+
const after = await loadRepoConfig(root, { fresh: true })
|
|
88
|
+
if (JSON.stringify(normalizeIgnored(after.ignoredInputs ?? {})) !== JSON.stringify(next)) throw new Error(`the loader read ignoredInputs ${JSON.stringify(after.ignoredInputs ?? {})}`)
|
|
89
|
+
} catch (err) {
|
|
90
|
+
atomicWrite(file, text)
|
|
91
|
+
throw new Error(`repo.config.ts was left as it was: ${(err as Error).message}`)
|
|
92
|
+
}
|
|
93
|
+
return next
|
|
94
|
+
}
|