rvlib-shipkit 0.38.0 → 1.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 +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// THE PROCESS IMAGE: who wrote a slot, a lease or an attach answer is a pid
|
|
2
|
+
// PLUS a nonce drawn once per program start. `bun --watch` restarts a program
|
|
3
|
+
// in the same pid (execve), so a pid alone outlives the run that took the
|
|
4
|
+
// record. One registry file per pid, `<pid>.json` under
|
|
5
|
+
// ~/.cache/shipkit/instances/, says which image owns that pid now and which
|
|
6
|
+
// check children it has in flight.
|
|
7
|
+
//
|
|
8
|
+
// The one constraint a reader breaks first: only a pid that is gone or runs
|
|
9
|
+
// ANOTHER image is a dead holder. A silent beat never is: the beat is a main
|
|
10
|
+
// thread timer, and a live holder busy in one long synchronous step stops
|
|
11
|
+
// beating while it still holds the phase. A record with NO nonce, or a pid
|
|
12
|
+
// with no registry file, falls back to the pid rule: an older shipkit writes
|
|
13
|
+
// no nonce, and a missing file must never read as a dead holder, or two
|
|
14
|
+
// processes run one phase at once.
|
|
15
|
+
import { randomBytes } from 'crypto'
|
|
16
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
17
|
+
import { homedir } from 'os'
|
|
18
|
+
import { join } from 'path'
|
|
19
|
+
import { pidAlive } from '../ledger/live-runs.ts'
|
|
20
|
+
import { descendantPids, psSnapshot } from './process-tree.ts'
|
|
21
|
+
|
|
22
|
+
export const INSTANCES_DIR_ENV = 'SHIPKIT_INSTANCES_DIR'
|
|
23
|
+
/** how often a running image stamps its registry file, so the housekeeping
|
|
24
|
+
* below never drops the file of an image that still runs */
|
|
25
|
+
export const INSTANCE_BEAT_MS = 5_000
|
|
26
|
+
/** a registry file nothing stamped for this long is dropped, whatever its pid */
|
|
27
|
+
const KEEP_MS = 60 * 60 * 1000
|
|
28
|
+
/** a long lived image (the hub) sweeps this often, not only at start */
|
|
29
|
+
const SWEEP_EVERY_MS = 60 * 60 * 1000
|
|
30
|
+
/** a `<pid>.json.tmp-*` this old is a write that died before its rename */
|
|
31
|
+
const TMP_KEEP_MS = 60_000
|
|
32
|
+
|
|
33
|
+
export type InstanceId = { pid: number; nonce: string }
|
|
34
|
+
type Registry = InstanceId & { beat: number; started: number; children: number[] }
|
|
35
|
+
|
|
36
|
+
// on globalThis: two copies of the lib in one process are one image, and two
|
|
37
|
+
// nonces for one pid would each call the other's records dead. The nonce key
|
|
38
|
+
// holds a bare string because an older copy reads it as one.
|
|
39
|
+
const g = globalThis as unknown as Record<symbol, unknown>
|
|
40
|
+
const NONCE = (g[Symbol.for('shipkit.instance.nonce')] ??= randomBytes(6).toString('hex')) as string
|
|
41
|
+
|
|
42
|
+
// one image writes one registry file, so every copy shares the children it
|
|
43
|
+
// lists and the timer that beats it: a copy of its own writes only its own
|
|
44
|
+
// children over the other's
|
|
45
|
+
type ImageState = {
|
|
46
|
+
started: number
|
|
47
|
+
children: Set<number>
|
|
48
|
+
beating: { dir: string; timer: ReturnType<typeof setInterval> } | null
|
|
49
|
+
swept: number
|
|
50
|
+
exitHook: boolean
|
|
51
|
+
}
|
|
52
|
+
const state = (g[Symbol.for('shipkit.instance.state')] ??= {
|
|
53
|
+
started: Date.now(),
|
|
54
|
+
children: new Set<number>(),
|
|
55
|
+
beating: null,
|
|
56
|
+
swept: 0,
|
|
57
|
+
exitHook: false,
|
|
58
|
+
} satisfies ImageState) as ImageState
|
|
59
|
+
|
|
60
|
+
export const thisInstance = (): InstanceId => ({ pid: process.pid, nonce: NONCE })
|
|
61
|
+
|
|
62
|
+
export const instancesDir = (): string => {
|
|
63
|
+
const env = process.env[INSTANCES_DIR_ENV]
|
|
64
|
+
return env != null && env !== '' ? env : join(homedir(), '.cache', 'shipkit', 'instances')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const readRegistry = (dir: string, pid: number): Registry | null => {
|
|
68
|
+
try {
|
|
69
|
+
const r = JSON.parse(readFileSync(join(dir, `${pid}.json`), 'utf8')) as Partial<Registry>
|
|
70
|
+
if (typeof r.nonce !== 'string' || typeof r.beat !== 'number') return null
|
|
71
|
+
return { pid, nonce: r.nonce, beat: r.beat, started: Number(r.started ?? r.beat), children: Array.isArray(r.children) ? r.children.filter((c) => Number.isInteger(c)) : [] }
|
|
72
|
+
} catch { return null }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** why a holder is not a running image, or null while it is one */
|
|
76
|
+
export function instanceGone(h: { pid: number; nonce?: string }): string | null {
|
|
77
|
+
if (!pidAlive(h.pid)) return `pid ${h.pid} is not running`
|
|
78
|
+
if (h.nonce == null) return null
|
|
79
|
+
if (h.pid === process.pid) return h.nonce === NONCE ? null : `pid ${h.pid} restarted in place (this process is a later image of it)`
|
|
80
|
+
const r = readRegistry(instancesDir(), h.pid)
|
|
81
|
+
return r == null || r.nonce === h.nonce ? null : `pid ${h.pid} restarted in place (another image of it runs now)`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// this image's registry file: written on first use, stamped while it runs,
|
|
86
|
+
// removed at exit, and listing the check children in flight so a later image
|
|
87
|
+
// of the pid can reap them
|
|
88
|
+
|
|
89
|
+
const write = (dir: string): void => {
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(dir, { recursive: true })
|
|
92
|
+
const path = join(dir, `${process.pid}.json`)
|
|
93
|
+
const tmp = `${path}.tmp-${NONCE}`
|
|
94
|
+
writeFileSync(tmp, JSON.stringify({ pid: process.pid, nonce: NONCE, beat: Date.now(), started: state.started, children: [...state.children] } satisfies Registry))
|
|
95
|
+
renameSync(tmp, path)
|
|
96
|
+
} catch { /* a registry nobody can write costs liveness precision, never a run */ }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sweepRegistry = (dir: string, now: number): void => {
|
|
100
|
+
state.swept = now
|
|
101
|
+
try {
|
|
102
|
+
for (const f of readdirSync(dir)) {
|
|
103
|
+
const p = join(dir, f)
|
|
104
|
+
if (/^\d+\.json\.tmp-/.test(f)) {
|
|
105
|
+
try { if (now - statSync(p).mtimeMs > TMP_KEEP_MS) rmSync(p, { force: true }) } catch { /* raced: gone either way */ }
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
const m = /^(\d+)\.json$/.exec(f)
|
|
109
|
+
if (m == null) continue
|
|
110
|
+
const r = readRegistry(dir, Number(m[1]))
|
|
111
|
+
// a pid that is gone says nothing a liveness read needs
|
|
112
|
+
if (r == null || !pidAlive(r.pid) || now - r.beat > KEEP_MS) rmSync(p, { force: true })
|
|
113
|
+
}
|
|
114
|
+
} catch { /* housekeeping never fails a run */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** the file goes with the image, and only while it is still this image's:
|
|
118
|
+
* a later image of the pid owns what it wrote */
|
|
119
|
+
const dropOwnRecord = (): void => {
|
|
120
|
+
if (state.beating == null) return
|
|
121
|
+
if (readRegistry(state.beating.dir, process.pid)?.nonce === NONCE) rmSync(join(state.beating.dir, `${process.pid}.json`), { force: true })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** publish this image as the owner of its pid, and keep stamping it. Every
|
|
125
|
+
* record writer calls it first, so a record's nonce is always one the
|
|
126
|
+
* registry has seen. Idempotent; the dir is read on each call so a test that
|
|
127
|
+
* points the env elsewhere gets its own file */
|
|
128
|
+
export function announceInstance(): InstanceId {
|
|
129
|
+
const dir = instancesDir()
|
|
130
|
+
if (state.beating?.dir !== dir) {
|
|
131
|
+
if (state.beating != null) clearInterval(state.beating.timer)
|
|
132
|
+
sweepRegistry(dir, Date.now())
|
|
133
|
+
write(dir)
|
|
134
|
+
const timer = setInterval(() => {
|
|
135
|
+
write(dir)
|
|
136
|
+
if (Date.now() - state.swept > SWEEP_EVERY_MS) sweepRegistry(dir, Date.now())
|
|
137
|
+
}, INSTANCE_BEAT_MS)
|
|
138
|
+
timer.unref()
|
|
139
|
+
state.beating = { dir, timer }
|
|
140
|
+
}
|
|
141
|
+
if (!state.exitHook) {
|
|
142
|
+
state.exitHook = true
|
|
143
|
+
process.on('exit', dropOwnRecord)
|
|
144
|
+
}
|
|
145
|
+
return thisInstance()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** a child a check spawned, listed while it runs */
|
|
149
|
+
export function trackChild(pid: number): void {
|
|
150
|
+
state.children.add(pid)
|
|
151
|
+
announceInstance()
|
|
152
|
+
if (state.beating != null) write(state.beating.dir)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function untrackChild(pid: number): void {
|
|
156
|
+
if (!state.children.delete(pid)) return
|
|
157
|
+
if (state.beating != null) write(state.beating.dir)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** what a new image of a restarted pid finds of the previous one, and reaps */
|
|
161
|
+
export type Reclaimed = { previous: string | null; killed: number[] }
|
|
162
|
+
|
|
163
|
+
/** called by a long lived process (the hub) at start, BEFORE it runs
|
|
164
|
+
* anything: the previous image of this pid is gone, so every check child it
|
|
165
|
+
* listed that is still OUR child is killed with its whole tree. A child
|
|
166
|
+
* this image spawns for itself (the comfy panel) is never listed, so it is
|
|
167
|
+
* left to be adopted. Then this image takes the pid over. */
|
|
168
|
+
export function reclaimPreviousImage(o: { snapshot?: string } = {}): Reclaimed {
|
|
169
|
+
const dir = instancesDir()
|
|
170
|
+
const prev = readRegistry(dir, process.pid)
|
|
171
|
+
const out: Reclaimed = { previous: null, killed: [] }
|
|
172
|
+
if (prev != null && prev.nonce !== NONCE) {
|
|
173
|
+
out.previous = prev.nonce
|
|
174
|
+
const snapshot = o.snapshot ?? psSnapshot()
|
|
175
|
+
const ours = new Set(directChildren(snapshot))
|
|
176
|
+
for (const kid of prev.children) {
|
|
177
|
+
// a listed pid that is no longer our child was reused by someone else
|
|
178
|
+
if (!ours.has(kid)) continue
|
|
179
|
+
for (const pid of [...descendantPids(kid, snapshot), kid]) {
|
|
180
|
+
try { process.kill(pid, 'SIGKILL'); out.killed.push(pid) } catch { /* already gone */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
announceInstance()
|
|
185
|
+
return out
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const directChildren = (snapshot: string): number[] => {
|
|
189
|
+
const out: number[] = []
|
|
190
|
+
for (const line of snapshot.split('\n')) {
|
|
191
|
+
const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line)
|
|
192
|
+
if (m != null && Number(m[2]) === process.pid) out.push(Number(m[1]))
|
|
193
|
+
}
|
|
194
|
+
return out
|
|
195
|
+
}
|
package/src/core/jobs.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// DETACHED GATES: `shipkit ci --detach` starts an ordinary gate in the
|
|
2
|
+
// background and hands back a run id, `shipkit status [id]` reads it back.
|
|
3
|
+
//
|
|
4
|
+
// The background process OWNS NOTHING. It is the same gate a terminal runs,
|
|
5
|
+
// its answers land in the same ledger and its cost in the same run record;
|
|
6
|
+
// this module only remembers where its output went and how it ended, in
|
|
7
|
+
// `.shipkit/tmp/shipkit-jobs/<id>/`: `job.json` (who, what, when), `out.log` (its
|
|
8
|
+
// wire lines as it prints them) and `exit.json` (the verdict, once).
|
|
9
|
+
import { spawn } from 'child_process'
|
|
10
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
import { LAYOUT } from './layout.ts'
|
|
13
|
+
import { atomicWrite } from './locked-write.ts'
|
|
14
|
+
import { fmtStamp } from './wire.ts'
|
|
15
|
+
|
|
16
|
+
/** status exit code while the run has no verdict yet */
|
|
17
|
+
export const STILL_RUNNING = 3
|
|
18
|
+
|
|
19
|
+
type Job = { id: string; pid: number; args: string[]; startedAt: number }
|
|
20
|
+
type Exit = { code: number; finishedAt: number }
|
|
21
|
+
|
|
22
|
+
const JOBS = `${LAYOUT.tmp}/shipkit-jobs`
|
|
23
|
+
/** how long a finished run stays readable */
|
|
24
|
+
const KEEP_MS = 7 * 24 * 60 * 60 * 1000
|
|
25
|
+
|
|
26
|
+
const ciLine = (args: string[]): string => ['shipkit ci', ...args].join(' ')
|
|
27
|
+
|
|
28
|
+
export const jobDir =(root: string, id: string): string => join(root, JOBS, id)
|
|
29
|
+
|
|
30
|
+
const readJson = <T>(p: string): T | null => {
|
|
31
|
+
try { return JSON.parse(readFileSync(p, 'utf8')) as T } catch { return null }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const alive = (pid: number): boolean => {
|
|
35
|
+
try { process.kill(pid, 0); return true } catch (e) { return (e as { code?: string }).code === 'EPERM' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** start `shipkit ci <args> --job <id>` detached from this process and its
|
|
39
|
+
* terminal, and return at once */
|
|
40
|
+
export function startDetached(root: string, o: { cli: string; args: string[] }): { id: string; pid: number; lines: string[] } {
|
|
41
|
+
sweepJobs(root)
|
|
42
|
+
const id = `ci-${Date.now().toString(36)}-${process.pid.toString(36)}`
|
|
43
|
+
const dir = jobDir(root, id)
|
|
44
|
+
mkdirSync(dir, { recursive: true })
|
|
45
|
+
const out = openSync(join(dir, 'out.log'), 'a')
|
|
46
|
+
const child = spawn(process.execPath, [o.cli, 'ci', ...o.args, '--job', id], { cwd: root, detached: true, stdio: ['ignore', out, out], env: process.env })
|
|
47
|
+
closeSync(out)
|
|
48
|
+
child.unref()
|
|
49
|
+
const pid = child.pid ?? 0
|
|
50
|
+
atomicWrite(join(dir, 'job.json'), JSON.stringify({ id, pid, args: o.args, startedAt: Date.now() } satisfies Job))
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
pid,
|
|
54
|
+
lines: [
|
|
55
|
+
`ok: gate started in the background as ${id} (pid ${pid}): ${ciLine(o.args)}`,
|
|
56
|
+
` fix: shipkit status ${id} prints it, and exits ${STILL_RUNNING} until it has a verdict`,
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** the detached gate records how it ended. Called by the gate itself. */
|
|
62
|
+
export function recordExit(root: string, id: string, code: number): void {
|
|
63
|
+
const dir = jobDir(root, id)
|
|
64
|
+
if (!existsSync(dir)) return
|
|
65
|
+
atomicWrite(join(dir, 'exit.json'), JSON.stringify({ code, finishedAt: Date.now() } satisfies Exit))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function latestJob(root: string): string | null {
|
|
69
|
+
let best: Job | null = null
|
|
70
|
+
try {
|
|
71
|
+
for (const name of readdirSync(join(root, JOBS))) {
|
|
72
|
+
const j = readJson<Job>(join(root, JOBS, name, 'job.json'))
|
|
73
|
+
if (j != null && (best == null || j.startedAt > best.startedAt)) best = j
|
|
74
|
+
}
|
|
75
|
+
} catch { return null }
|
|
76
|
+
return best?.id ?? null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** the run's wire lines so far, then one line on where it stands. The code is
|
|
80
|
+
* the gate's own exit code once it has one, STILL_RUNNING before, 1 for a
|
|
81
|
+
* run that died without a verdict, 2 for an id nobody started here. */
|
|
82
|
+
export function jobStatus(root: string, id?: string): { code: number; lines: string[] } {
|
|
83
|
+
const want = id ?? latestJob(root)
|
|
84
|
+
if (want == null) return { code: 2, lines: ['FAIL: no detached run in this checkout yet', ' fix: shipkit ci --detach starts one'] }
|
|
85
|
+
const dir = jobDir(root, want)
|
|
86
|
+
const job = readJson<Job>(join(dir, 'job.json'))
|
|
87
|
+
if (job == null) return { code: 2, lines: [`FAIL: no detached run '${want}' in this checkout`, ` fix: shipkit status (no id) shows the latest one`] }
|
|
88
|
+
const lines = [`ok: run ${job.id}: ${ciLine(job.args)} (pid ${job.pid}, started ${fmtStamp(job.startedAt)})`]
|
|
89
|
+
let out = ''
|
|
90
|
+
try { out = readFileSync(join(dir, 'out.log'), 'utf8') } catch { /* nothing printed yet */ }
|
|
91
|
+
lines.push(...out.split('\n').filter((l) => l !== ''))
|
|
92
|
+
const exit = (): Exit | null => readJson<Exit>(join(dir, 'exit.json'))
|
|
93
|
+
let e = exit()
|
|
94
|
+
if (e == null && alive(job.pid)) {
|
|
95
|
+
lines.push(`skip: run ${job.id} still running (${((Date.now() - job.startedAt) / 1000).toFixed(1)}s so far), status exits ${STILL_RUNNING} until it has a verdict`)
|
|
96
|
+
return { code: STILL_RUNNING, lines }
|
|
97
|
+
}
|
|
98
|
+
// it may have written its verdict between the two reads
|
|
99
|
+
e ??= exit()
|
|
100
|
+
if (e == null) return { code: 1, lines: [...lines, `FAIL: run ${job.id} died without a verdict (pid ${job.pid} is gone)`, ` fix: ${ciLine(job.args)}`] }
|
|
101
|
+
const took = `${((e.finishedAt - job.startedAt) / 1000).toFixed(1)}s`
|
|
102
|
+
lines.push(e.code === 0 ? `ok: run ${job.id} finished green in ${took}` : `FAIL: run ${job.id} finished red (exit ${e.code}) in ${took}`)
|
|
103
|
+
return { code: e.code, lines }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** finished runs older than a week go; a running one never does */
|
|
107
|
+
function sweepJobs(root: string): void {
|
|
108
|
+
const base = join(root, JOBS)
|
|
109
|
+
try {
|
|
110
|
+
for (const name of readdirSync(base)) {
|
|
111
|
+
const dir = join(base, name)
|
|
112
|
+
const j = readJson<Job>(join(dir, 'job.json'))
|
|
113
|
+
try { if (statSync(dir).mtimeMs > Date.now() - KEEP_MS) continue } catch { continue }
|
|
114
|
+
if (j != null && alive(j.pid)) continue
|
|
115
|
+
rmSync(dir, { recursive: true, force: true })
|
|
116
|
+
}
|
|
117
|
+
} catch { /* no jobs yet: nothing to sweep */ }
|
|
118
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Where shipkit keeps things inside a repo: ONE folder, `.shipkit/`.
|
|
2
|
+
//
|
|
3
|
+
// Every path a reader or writer needs comes from this table, so a move is one
|
|
4
|
+
// edit here plus the migration that carries existing repos along
|
|
5
|
+
// (core/migrate-layout.ts). A repo root holds the project, never the toolkit.
|
|
6
|
+
//
|
|
7
|
+
// Git policy lives in the SK004 block: `.shipkit/*` is ignored, and only the
|
|
8
|
+
// TRACKED children below are re-included. A folder nobody listed is local by
|
|
9
|
+
// default, which is the contract's default too.
|
|
10
|
+
|
|
11
|
+
export const SHIPKIT_DIR = '.shipkit'
|
|
12
|
+
|
|
13
|
+
const sk = (sub: string): string => `${SHIPKIT_DIR}/${sub}`
|
|
14
|
+
|
|
15
|
+
export const LAYOUT = {
|
|
16
|
+
// tracked: authored state
|
|
17
|
+
asks: sk('asks'),
|
|
18
|
+
hooks: sk('hooks'),
|
|
19
|
+
identity: sk('identity'),
|
|
20
|
+
assets: sk('assets'),
|
|
21
|
+
comfy: sk('comfy'),
|
|
22
|
+
// local: drafts, private notes, venue listings until they go live
|
|
23
|
+
private: sk('private'),
|
|
24
|
+
reflections: sk('reflections'),
|
|
25
|
+
social: sk('social'),
|
|
26
|
+
journal: sk('journal'),
|
|
27
|
+
domain: sk('domain'),
|
|
28
|
+
itch: sk('itch'),
|
|
29
|
+
steam: sk('steam'),
|
|
30
|
+
discord: sk('discord'),
|
|
31
|
+
// a review with no output file named by its caller (YYYYMMDD-HHMMSS-<name>.md)
|
|
32
|
+
reviews: sk('reviews'),
|
|
33
|
+
// scratch: plans, logs, leases, detached runs, screenshots
|
|
34
|
+
tmp: sk('tmp'),
|
|
35
|
+
// generated on every run
|
|
36
|
+
status: sk('STATUS.md'),
|
|
37
|
+
statusFull: sk('STATUS.full.md'),
|
|
38
|
+
cache: sk('cache'),
|
|
39
|
+
} as const
|
|
40
|
+
|
|
41
|
+
/** the children the SK004 block re-includes: everything else under `.shipkit/` stays local */
|
|
42
|
+
export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy] as const
|
|
43
|
+
|
|
44
|
+
/** the machine-global private folder, under $HOME: one keyword list and one
|
|
45
|
+
* allowlist per machine, never copied into a repo */
|
|
46
|
+
export const MACHINE_PRIVATE = LAYOUT.private
|
|
47
|
+
|
|
48
|
+
/** where a gate run writes its per task logs unless repo.config says `logDir` */
|
|
49
|
+
export const CI_LOG_DIR = `${LAYOUT.tmp}/shipkit-ci`
|
|
50
|
+
|
|
51
|
+
/** the leak guard's per-repo keyword list */
|
|
52
|
+
export const KEYWORD_FILE = `${LAYOUT.private}/banned-keywords.txt`
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// THE MIGRATION, as code: what a repo still declares in a shape the check
|
|
2
|
+
// file replaced, and the mechanical rewrite of each one. Check SK016 reports
|
|
3
|
+
// this list and applies these rewrites.
|
|
4
|
+
//
|
|
5
|
+
// Every shape here is READ through 0.40 and refused at the first release
|
|
6
|
+
// after `shipkit fleet` shows no repo below it, so a repo keeps working while
|
|
7
|
+
// it carries them and learns what to write instead on every run.
|
|
8
|
+
//
|
|
9
|
+
// What is mechanical, and what is not, is the whole design of this module. A
|
|
10
|
+
// `checkDefaults` value, a `ruleExempt` entry and a task that is DATA are
|
|
11
|
+
// rewritten without guessing. A legacy check script, a `rules: []` entry and
|
|
12
|
+
// a task whose body is `logic` are not: a script prints and exits in a
|
|
13
|
+
// process of its own, which a check file never does (SK020), and a function
|
|
14
|
+
// closes over the lexical scope of the file it was written in. Those are
|
|
15
|
+
// LISTED by name with what they become, never half rewritten.
|
|
16
|
+
//
|
|
17
|
+
// It reads ONLY through the check context it is handed, so SK016 is cached
|
|
18
|
+
// on the listing of check files and their sources.
|
|
19
|
+
import { basename, join, relative, resolve } from 'path'
|
|
20
|
+
import { fallbackNames, isCheckFilePath, readLegacyCheck, type LegacyCheck } from './check-file-names.ts'
|
|
21
|
+
import type { ScanCtx } from './check-ctx.ts'
|
|
22
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
23
|
+
import { descriptionProblem, type TaskDef } from '../check.ts'
|
|
24
|
+
|
|
25
|
+
/** the narrow module every rewritten file imports `check` from, and the ONE
|
|
26
|
+
* spelling: the barrel costs 400ms of module load and discovery imports
|
|
27
|
+
* every one of these. A repo that INSTALLS the toolkit resolves it through
|
|
28
|
+
* node_modules, and a repo that hosts the source resolves the same bare
|
|
29
|
+
* specifier through its tsconfig `paths`, which is also what keeps one copy
|
|
30
|
+
* of every shared peer reachable. A nested package carrying its own tsconfig
|
|
31
|
+
* repeats that one mapping. */
|
|
32
|
+
export const CHECK_MODULE = 'rvlib-shipkit/src/check.ts'
|
|
33
|
+
|
|
34
|
+
/** the opening and closing lines of a rewritten check file. A repo that
|
|
35
|
+
* reaches the toolkit only through the global launcher resolves nothing, so
|
|
36
|
+
* it gets the plain object: discovery re-validates it through `check()`
|
|
37
|
+
* either way, and an import it cannot resolve takes every check down. */
|
|
38
|
+
export function checkWrapper(root: string): { head: string[]; open: string; close: string } {
|
|
39
|
+
try {
|
|
40
|
+
Bun.resolveSync(CHECK_MODULE, root)
|
|
41
|
+
return { head: [`import { check } from '${CHECK_MODULE}'`, ''], open: 'export default check({', close: '})' }
|
|
42
|
+
} catch {
|
|
43
|
+
return { head: [], open: 'export default {', close: '}' }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type LegacyCheckFile = {
|
|
48
|
+
/** repo-relative path of the legacy check script */
|
|
49
|
+
rel: string
|
|
50
|
+
/** what the static reader read out of it, verbatim */
|
|
51
|
+
read: LegacyCheck
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type LegacyTask = {
|
|
55
|
+
id: string
|
|
56
|
+
/** a task that is pure DATA (`cmd` and its decorations) is rewritten into
|
|
57
|
+
* a file. One whose body is `logic` is a function, so it is hand work */
|
|
58
|
+
data: boolean
|
|
59
|
+
optIn: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type LegacyShapes = {
|
|
63
|
+
checkFiles: LegacyCheckFile[]
|
|
64
|
+
/** ids declared through `repo.config.ts rules: []` */
|
|
65
|
+
rules: string[]
|
|
66
|
+
tasks: LegacyTask[]
|
|
67
|
+
/** the repo sets `checkDefaults`, which lets a file forget `blocking` */
|
|
68
|
+
checkDefaults: boolean
|
|
69
|
+
/** ids exempted through `ruleExempt`, the spelling `when` replaced */
|
|
70
|
+
ruleExempt: string[]
|
|
71
|
+
/** check files still opening with the TODO description the migration
|
|
72
|
+
* wrote. Not a legacy shape, so not in `count`: the file loads and
|
|
73
|
+
* runs, and only its author can write the sentence */
|
|
74
|
+
placeholders: string[]
|
|
75
|
+
/** every shape, counted once */
|
|
76
|
+
count: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** every check file the repo carries, as discovery would find it, with its
|
|
80
|
+
* source. Listed through `c.list`, so it is what git sees: a gitignored
|
|
81
|
+
* check file is no declaration of this repo */
|
|
82
|
+
export function repoCheckSources(d: Pick<ScanCtx, 'root' | 'list' | 'read'>): { rel: string; abs: string; src: string | null }[] {
|
|
83
|
+
return d.list(`**/*.check.ts`)
|
|
84
|
+
.filter(isCheckFilePath)
|
|
85
|
+
.map((rel) => ({ rel, abs: join(d.root, rel), src: d.read(rel) }))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function legacyShapes(d: Pick<ScanCtx, 'root' | 'list' | 'read'>, c: RepoConfig): LegacyShapes {
|
|
89
|
+
const files = repoCheckSources(d)
|
|
90
|
+
const names = fallbackNames(d.root, files.map((f) => f.abs))
|
|
91
|
+
const checkFiles: LegacyCheckFile[] = []
|
|
92
|
+
for (const f of files) {
|
|
93
|
+
const read = f.src == null ? null : readLegacyCheck(f.abs, names.get(f.abs)!, f.src)
|
|
94
|
+
if (read != null) checkFiles.push({ rel: f.rel, read })
|
|
95
|
+
}
|
|
96
|
+
const rules = (c.rules ?? []).map((r) => r.id)
|
|
97
|
+
const tasks = (c.tasks ?? []).map((t): LegacyTask => ({ id: t.id, data: t.logic == null && t.cmd != null, optIn: t.optIn === true }))
|
|
98
|
+
const ruleExempt = Object.keys(c.ruleExempt ?? {})
|
|
99
|
+
const checkDefaults = c.checkDefaults != null
|
|
100
|
+
const placeholders = files.filter((f) => f.src?.includes(PLACEHOLDER_MARK) === true).map((f) => f.rel)
|
|
101
|
+
return {
|
|
102
|
+
placeholders,
|
|
103
|
+
checkFiles,
|
|
104
|
+
rules,
|
|
105
|
+
tasks,
|
|
106
|
+
checkDefaults,
|
|
107
|
+
ruleExempt,
|
|
108
|
+
count: checkFiles.length + rules.length + tasks.length + (checkDefaults ? 1 : 0) + ruleExempt.length,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── what a check script becomes ──────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/** a TS string literal, escapes and all */
|
|
115
|
+
const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
116
|
+
|
|
117
|
+
/** what a check file says when no line was given for it. It passes the shape,
|
|
118
|
+
* so the repo keeps running, and it carries the marker SK016 reports, so a
|
|
119
|
+
* human writes the line. */
|
|
120
|
+
export const descriptionPlaceholder = (): string => `${PLACEHOLDER_MARK}, in one line`
|
|
121
|
+
|
|
122
|
+
/** the words every placeholder carries, and what SK016 looks for */
|
|
123
|
+
export const PLACEHOLDER_MARK = 'TODO: state what must be true'
|
|
124
|
+
|
|
125
|
+
/** the hand port of one check script, named for its author. A script prints
|
|
126
|
+
* and exits in a process of its own, and a check file never does (SK020):
|
|
127
|
+
* its prints become calls on `c` inside `run`, which only a reader of that
|
|
128
|
+
* body can write. The declarations it carries are spelled out so the port
|
|
129
|
+
* drops none of them. */
|
|
130
|
+
export function scriptPort(read: LegacyCheck, fallbackBlocking: boolean | undefined): string {
|
|
131
|
+
const blocking = read.blocking ?? fallbackBlocking
|
|
132
|
+
const fields = [
|
|
133
|
+
blocking == null ? 'blocking: <true | false, it declares none>' : `blocking: ${blocking}`,
|
|
134
|
+
...(read.inputs != null ? [`inputs: [${read.inputs.map(q).join(', ')}]`] : []),
|
|
135
|
+
...(read.ttl != null ? [`ttl: ${q(read.ttl)}`] : []),
|
|
136
|
+
...(read.never != null ? [`never: ${q(read.never)}`] : []),
|
|
137
|
+
...(read.gate ? [] : [`when: 'health'`]),
|
|
138
|
+
]
|
|
139
|
+
return `a check script, run as its own process: port it by hand into \`export default check({ description, ${fields.join(', ')}, run: (c) => … })\`, each print a call on c (c.ok, c.warn, c.fail)`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** the check file a task's argv runs through bun, `bun <file>` or `bun run
|
|
143
|
+
* <file>`, relative, `./` prefixed or absolute, resolved against the task's
|
|
144
|
+
* cwd. `rest` is what the argv passes after the file: a task that passes its
|
|
145
|
+
* own arguments asks a different question, so it is never folded. */
|
|
146
|
+
export function checkFileRunBy(t: Pick<TaskDef, 'cmd' | 'cwd'>, root: string): { rel: string; rest: string[] } | null {
|
|
147
|
+
const cmd = t.cmd
|
|
148
|
+
if (cmd == null || cmd.length < 2 || basename(cmd[0]!) !== 'bun') return null
|
|
149
|
+
const at = cmd[1] === 'run' ? 2 : 1
|
|
150
|
+
const arg = cmd[at]
|
|
151
|
+
if (arg == null || !arg.endsWith('.check.ts')) return null
|
|
152
|
+
const rel = relative(root, resolve(root, t.cwd ?? '.', arg)).replace(/\\/g, '/')
|
|
153
|
+
if (rel.startsWith('..')) return null
|
|
154
|
+
return { rel, rest: cmd.slice(at + 1) }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** a task that is DATA, as its own check file. `build: true` is what a task
|
|
158
|
+
* always was: the gate runs it and the health pass leaves it out. */
|
|
159
|
+
export function taskCheckSource(t: TaskDef, root: string): string {
|
|
160
|
+
const own = t.description != null && descriptionProblem(t.description) == null ? t.description.trim() : null
|
|
161
|
+
const fields = [`id: ${q(t.id)},`, `description: ${q(own ?? descriptionPlaceholder())},`, 'blocking: true,', 'build: true,', `cmd: [${t.cmd!.map(q).join(', ')}],`]
|
|
162
|
+
if (t.deps != null && t.deps.length > 0) fields.push(`deps: [${t.deps.map(q).join(', ')}],`)
|
|
163
|
+
if (t.inputs != null) fields.push(`inputs: [${t.inputs.map(q).join(', ')}],`)
|
|
164
|
+
if (t.ttl != null) fields.push(`ttl: ${q(t.ttl)},`)
|
|
165
|
+
if (t.never != null) fields.push(`never: ${q(t.never)},`)
|
|
166
|
+
if (t.group != null) fields.push(`group: ${q(t.group)},`)
|
|
167
|
+
if (t.timeoutMs != null) fields.push(`timeoutMs: ${t.timeoutMs},`)
|
|
168
|
+
if (t.env != null) fields.push(`env: { ${Object.entries(t.env).map(([k, v]) => `${q(k)}: ${q(v)}`).join(', ')} },`)
|
|
169
|
+
if (t.cwd != null) fields.push(`cwd: ${q(t.cwd)},`)
|
|
170
|
+
if (t.optIn === true) fields.push(`when: 'health',`)
|
|
171
|
+
const w = checkWrapper(root)
|
|
172
|
+
return [...w.head, w.open, ...fields.map((f) => ` ${f}`), w.close, ''].join('\n')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** a file name for a check whose id carries characters a path should not */
|
|
176
|
+
export const checkFileName = (id: string): string => `${id.replace(/[^A-Za-z0-9._-]+/g, '-')}.check.ts`
|
|
177
|
+
|
|
178
|
+
// ── editing repo.config.ts ───────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/** a statement that starts on `i`, ending where its brackets balance and the
|
|
181
|
+
* line is not obviously continued. Deliberately simple: anything it cannot
|
|
182
|
+
* read lands as a hand pass rather than as a half rewritten config. */
|
|
183
|
+
function statementEnd(lines: string[], i: number): number {
|
|
184
|
+
let depth = 0
|
|
185
|
+
for (let j = i; j < lines.length; j++) {
|
|
186
|
+
for (const ch of lines[j]!) {
|
|
187
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++
|
|
188
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth--
|
|
189
|
+
}
|
|
190
|
+
if (depth <= 0) return j
|
|
191
|
+
}
|
|
192
|
+
return lines.length - 1
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** the config text without one top level key, its trailing comma and the
|
|
196
|
+
* comment block written above it. Null when the key is not there, or when it
|
|
197
|
+
* appears more than once: a machine that picks one of two is guessing. */
|
|
198
|
+
export function dropConfigKey(text: string, key: string): string | null {
|
|
199
|
+
const lines = text.split('\n')
|
|
200
|
+
const at = lines.map((l, i) => (new RegExp(`^\\s*${key}\\s*:`).test(l) ? i : -1)).filter((i) => i !== -1)
|
|
201
|
+
if (at.length !== 1) return null
|
|
202
|
+
const start = at[0]!
|
|
203
|
+
const end = statementEnd(lines, start)
|
|
204
|
+
let from = start
|
|
205
|
+
while (from > 0 && lines[from - 1]!.trim().startsWith('//')) from--
|
|
206
|
+
return [...lines.slice(0, from), ...lines.slice(end + 1)].join('\n')
|
|
207
|
+
}
|