rvlib-shipkit 0.35.7 → 0.36.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 +24 -1
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Fleet.ts +116 -0
- package/src/cli/cmd_Prose.ts +65 -0
- package/src/cli/cmd_Smoke.ts +42 -0
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli/cmd_Units.ts +82 -0
- package/src/cli.ts +8 -0
- package/src/core/check-suite.ts +28 -5
- package/src/core/run-check.ts +29 -7
- package/src/core/run-ci.ts +24 -5
- package/src/core/task-runner.ts +7 -1
- package/src/core/write-status.ts +5 -1
- package/src/fleet/scan-fleet.ts +142 -0
- package/src/git-bootstrap.ts +11 -5
- package/src/ledger/gate-units.ts +80 -27
- package/src/ledger/store.ts +7 -2
- package/src/ledger/unit-table.ts +105 -0
- package/src/prose/run-prose.ts +146 -0
- package/src/prose/ste.ts +436 -0
- package/src/repo-config.ts +35 -1
- package/src/rules/SK014-prose-ste.ts +50 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/rules/untrack-generated.ts +4 -1
- package/src/smoke/run-smoke.ts +403 -0
package/src/core/run-ci.ts
CHANGED
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
import { allTasks, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
10
10
|
import { reportToMotherhome } from './motherhome.ts'
|
|
11
|
-
import { collectChecks } from './run-check.ts'
|
|
11
|
+
import { checkSideIds, collectChecks } from './run-check.ts'
|
|
12
12
|
import { runFixPass } from './run-fixers.ts'
|
|
13
13
|
import { formatLine } from './wire.ts'
|
|
14
14
|
import { runTasks, selectTasks, type TaskResult } from './task-runner.ts'
|
|
15
15
|
import { writeStatus } from './write-status.ts'
|
|
16
16
|
import { withContractsScope } from '../contracts/contracts-once.ts'
|
|
17
|
-
import { envTier, FULL_TESTS_ENV, gateUnits, type TestTier } from '../ledger/gate-units.ts'
|
|
17
|
+
import { envTier, FULL_TESTS_ENV, gateInventory, gateUnits, tieredTasks, type TestTier } from '../ledger/gate-units.ts'
|
|
18
|
+
import { UNIT_TABLE_FILE, writeUnitTable } from '../ledger/unit-table.ts'
|
|
18
19
|
import { Ledger, stampReplayed, type UnitOutcome } from '../ledger/ledger.ts'
|
|
19
20
|
|
|
20
21
|
export type CiOpts = {
|
|
@@ -178,7 +179,11 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
178
179
|
// the run entirely, which is also what makes its dependents correct: the
|
|
179
180
|
// runner treats a filtered dep as satisfied, and only a GREEN is ever
|
|
180
181
|
// stored, so a dependent can never start on the back of a red nobody saw.
|
|
181
|
-
|
|
182
|
+
// --only addresses tasks, rules and checks alike, so the task runner is
|
|
183
|
+
// told which ids belong to the other two surfaces rather than answering
|
|
184
|
+
// `unknown task id` for a check it was never asked to run
|
|
185
|
+
const otherIds = checkSideIds(root, c)
|
|
186
|
+
const selectable = new Set(selectTasks({ tasks, logDir: '', skipGroups: o.skipGroups ?? [], withIds: o.withIds ?? [], onlyIds: o.onlyIds ?? [], otherIds }).selected.map((t) => t.id))
|
|
182
187
|
const replayedTasks = new Map<string, UnitOutcome>()
|
|
183
188
|
const toRun = tasks
|
|
184
189
|
.filter((t) => {
|
|
@@ -219,6 +224,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
219
224
|
skipGroups: o.skipGroups ?? [],
|
|
220
225
|
withIds,
|
|
221
226
|
onlyIds,
|
|
227
|
+
otherIds,
|
|
222
228
|
print: sink,
|
|
223
229
|
onTaskFinish: o.onTaskFinish,
|
|
224
230
|
})
|
|
@@ -254,7 +260,11 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
254
260
|
// checks that do want it still pay for exactly one survey between them.
|
|
255
261
|
const checks = o.skipChecks === true
|
|
256
262
|
? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
|
|
257
|
-
|
|
263
|
+
// `gate = false` opts a check out of the EVERYDAY gate. failOnAdvisory
|
|
264
|
+
// is release.requireHealthy, a repo saying it wants a missing cred to
|
|
265
|
+
// stop its publish, so it re-arms them: the opt-out must not silently
|
|
266
|
+
// drop the one request that asks for exactly these probes.
|
|
267
|
+
: await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds, fresh: o.fresh, ledger, reportHome: false, gateOnly: o.failOnAdvisory !== true })
|
|
258
268
|
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
259
269
|
for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
|
|
260
270
|
for (const u of checks.undeclared) {
|
|
@@ -285,8 +295,17 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
285
295
|
if (o.status !== false) {
|
|
286
296
|
// scope is named in the file: ci proves the gate, not repo health, and
|
|
287
297
|
// a bare GREEN here would claim clear-to-ship on a repo with red checks
|
|
288
|
-
const st = writeStatus(root, {
|
|
298
|
+
const st = writeStatus(root, {
|
|
299
|
+
name: c.name,
|
|
300
|
+
...ciScope(o),
|
|
301
|
+
lines: collected,
|
|
302
|
+
exitCode: code,
|
|
303
|
+
sidecar: { file: UNIT_TABLE_FILE, says: 'Every unit this repo can run, and whether it blocks you' },
|
|
304
|
+
})
|
|
289
305
|
if (!st.written) print(`WARN ${st.warn}`)
|
|
306
|
+
const units = writeUnitTable(root, { name: c.name, rows: gateInventory(root, c, tieredTasks(c, root, tier)) })
|
|
307
|
+
if (!units.written) print(`WARN ${units.warn}`)
|
|
308
|
+
|
|
290
309
|
}
|
|
291
310
|
if (o.json === true)
|
|
292
311
|
print(JSON.stringify({ ok: summary.ok, interrupted: summary.interrupted, disabled, tasks: summary.results.map((r) => ({ ...r, lines: undefined })) }))
|
package/src/core/task-runner.ts
CHANGED
|
@@ -78,6 +78,10 @@ export type RunOpts = {
|
|
|
78
78
|
skipGroups?: string[]
|
|
79
79
|
onlyIds?: string[]
|
|
80
80
|
withIds?: string[]
|
|
81
|
+
/** ids `--only` may name that are NOT tasks: discovered checks and rules.
|
|
82
|
+
* The flag addresses all three surfaces, so a check name must not come
|
|
83
|
+
* back as `unknown task id` from the one surface that does not own it. */
|
|
84
|
+
otherIds?: string[]
|
|
81
85
|
/** print sink — tests capture, production defaults to console.log */
|
|
82
86
|
print?: (line: string) => void
|
|
83
87
|
color?: boolean
|
|
@@ -115,7 +119,9 @@ export function selectTasks(o: RunOpts): { selected: TaskDef[]; errors: string[]
|
|
|
115
119
|
if ((t.cmd == null) === (t.logic == null)) errors.push(`task ${t.id}: exactly one of cmd/logic required`)
|
|
116
120
|
for (const d of t.deps ?? []) if (!ids.has(d)) errors.push(`task ${t.id}: unknown dep '${d}'`)
|
|
117
121
|
}
|
|
118
|
-
|
|
122
|
+
const elsewhere = new Set((o.otherIds ?? []).map((i) => i.toLowerCase()))
|
|
123
|
+
for (const id of [...(o.onlyIds ?? []), ...(o.withIds ?? [])])
|
|
124
|
+
if (!ids.has(id) && !elsewhere.has(id.toLowerCase())) errors.push(`unknown task id '${id}'`)
|
|
119
125
|
// cycle check — a dep cycle must die loud at select time, not hang the loop
|
|
120
126
|
const state = new Map<string, 0 | 1>()
|
|
121
127
|
const byId = new Map(o.tasks.map((t) => [t.id, t]))
|
package/src/core/write-status.ts
CHANGED
|
@@ -18,6 +18,10 @@ export type StatusInput = {
|
|
|
18
18
|
/** the run's printed lines (ANSI stripped here) */
|
|
19
19
|
lines: string[]
|
|
20
20
|
exitCode: number
|
|
21
|
+
/** a companion file this run also refreshed, named so a reader finds it.
|
|
22
|
+
* Optional because a caller that does not write one must not advertise
|
|
23
|
+
* a file that is not there. */
|
|
24
|
+
sidecar?: { file: string; says: string }
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export type StatusOutcome = { written: boolean; warn?: string }
|
|
@@ -61,7 +65,7 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
|
|
|
61
65
|
# ${s.name} status, ${stamp}
|
|
62
66
|
|
|
63
67
|
**${verdict}**
|
|
64
|
-
|
|
68
|
+
${s.sidecar == null ? '' : `\n${s.sidecar.says}: [${s.sidecar.file}](${s.sidecar.file})\n`}
|
|
65
69
|
\`\`\`
|
|
66
70
|
${clean.join('\n').trim()}
|
|
67
71
|
\`\`\`
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The fleet: every repo on this disk that depends on the toolkit, and how far
|
|
2
|
+
// behind each one is.
|
|
3
|
+
//
|
|
4
|
+
// Why a scan and not a registry: `local-projects.md` only holds repos whose
|
|
5
|
+
// `docsBase` points at a wiki, which is 4 of the 10 that actually install the
|
|
6
|
+
// libs. A repo that never reported is exactly the repo most likely to be
|
|
7
|
+
// stale, so a registry is the wrong source for this question.
|
|
8
|
+
//
|
|
9
|
+
// This module DISCOVERS and REPORTS. It never updates: rule SK005 owns the
|
|
10
|
+
// update, with the lease, the dirty-tree guard and the frozen guard that took
|
|
11
|
+
// three bugs to get right. `--update` runs each repo's own `shipkit check`,
|
|
12
|
+
// so the fix travels through that one code path.
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
|
|
16
|
+
export type Pkg = { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
17
|
+
|
|
18
|
+
export type FleetDep = {
|
|
19
|
+
name: string
|
|
20
|
+
/** the range in package.json, or `link:…` for a source-linked dep */
|
|
21
|
+
pin: string
|
|
22
|
+
/** what node_modules actually carries, null when nothing is installed */
|
|
23
|
+
installed: string | null
|
|
24
|
+
/** npm latest, null when the registry was not consulted or not reachable */
|
|
25
|
+
latest: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** a jump that changes the MAJOR, which is a porting job rather than a bump.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately NOT the semver rule. On a `0.x` package npm treats every
|
|
31
|
+
* minor as breaking, and by that rule every repo here is breaking, including
|
|
32
|
+
* the `0.24 → 0.35` shipkit jumps that rule SK005 already performs by itself
|
|
33
|
+
* every day. A flag that fires on everything is a flag nobody reads. What
|
|
34
|
+
* survives is the case a human has to own: `rvlib-pb-mobx 0.2.0 → 2.0.0`. */
|
|
35
|
+
export function isBreaking(installed: string, latest: string): boolean {
|
|
36
|
+
const major = (v: string) => Number(v.split('-')[0]!.split('.')[0]) || 0
|
|
37
|
+
return major(installed) !== major(latest)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type FleetRepo = {
|
|
41
|
+
name: string
|
|
42
|
+
dir: string
|
|
43
|
+
deps: FleetDep[]
|
|
44
|
+
/** a repo pointing at a checkout rather than a published version. Never
|
|
45
|
+
* updated: the source IS the version, and that is a deliberate choice. */
|
|
46
|
+
linked: boolean
|
|
47
|
+
/** whether this repo can run the update itself (it has a shipkit binary) */
|
|
48
|
+
hasShipkit: boolean
|
|
49
|
+
behind: FleetDep[]
|
|
50
|
+
/** the subset of `behind` whose jump crosses a breaking boundary */
|
|
51
|
+
breaking: FleetDep[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const readPkg = (dir: string): Pkg | null => {
|
|
55
|
+
const p = join(dir, 'package.json')
|
|
56
|
+
if (!existsSync(p)) return null
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(p, 'utf8')) as Pkg
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const installedVersion = (dir: string, name: string): string | null => readPkg(join(dir, 'node_modules', name))?.version ?? null
|
|
65
|
+
|
|
66
|
+
/** semver-ish compare, enough to answer "is this one behind that one". A
|
|
67
|
+
* prerelease tag sorts before its release, which is the safe direction: it
|
|
68
|
+
* reports behind rather than pretending parity. */
|
|
69
|
+
export function isBehind(installed: string, latest: string): boolean {
|
|
70
|
+
const part = (v: string) => v.split('-')[0]!.split('.').map((n) => Number(n) || 0)
|
|
71
|
+
const [a, b] = [part(installed), part(latest)]
|
|
72
|
+
for (let i = 0; i < 3; i++) {
|
|
73
|
+
if ((a[i] ?? 0) < (b[i] ?? 0)) return true
|
|
74
|
+
if ((a[i] ?? 0) > (b[i] ?? 0)) return false
|
|
75
|
+
}
|
|
76
|
+
return installed.includes('-') && !latest.includes('-')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function scanFleet(root: string, o: { prefix?: string } = {}): FleetRepo[] {
|
|
80
|
+
const prefix = o.prefix ?? 'rvlib-'
|
|
81
|
+
const out: FleetRepo[] = []
|
|
82
|
+
let entries: string[]
|
|
83
|
+
try {
|
|
84
|
+
entries = readdirSync(root)
|
|
85
|
+
} catch {
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
for (const e of entries.sort()) {
|
|
89
|
+
if (e.startsWith('.')) continue
|
|
90
|
+
const dir = join(root, e)
|
|
91
|
+
try {
|
|
92
|
+
if (!statSync(dir).isDirectory()) continue
|
|
93
|
+
} catch {
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
const pkg = readPkg(dir)
|
|
97
|
+
if (pkg == null) continue
|
|
98
|
+
const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
|
99
|
+
const names = Object.keys(all).filter((n) => n.startsWith(prefix))
|
|
100
|
+
if (names.length === 0) continue
|
|
101
|
+
const deps: FleetDep[] = names.sort().map((name) => ({ name, pin: all[name]!, installed: installedVersion(dir, name), latest: null }))
|
|
102
|
+
out.push({
|
|
103
|
+
name: e,
|
|
104
|
+
dir,
|
|
105
|
+
deps,
|
|
106
|
+
linked: deps.some((d) => d.pin.startsWith('link:') || d.pin.startsWith('file:')),
|
|
107
|
+
hasShipkit: existsSync(join(dir, 'node_modules', 'rvlib-shipkit', 'src', 'cli.ts')),
|
|
108
|
+
behind: [],
|
|
109
|
+
breaking: [],
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** npm latest per PACKAGE, fetched once and shared across every repo that
|
|
116
|
+
* depends on it. Ten repos on one lib is one request, not ten. */
|
|
117
|
+
export async function resolveLatest(repos: FleetRepo[], fetchLatest: (name: string) => Promise<string | null>): Promise<Map<string, string | null>> {
|
|
118
|
+
const names = new Set<string>()
|
|
119
|
+
for (const r of repos) for (const d of r.deps) if (!d.pin.startsWith('link:') && !d.pin.startsWith('file:')) names.add(d.name)
|
|
120
|
+
const latest = new Map<string, string | null>()
|
|
121
|
+
await Promise.all(
|
|
122
|
+
[...names].map(async (n) => {
|
|
123
|
+
try {
|
|
124
|
+
latest.set(n, await fetchLatest(n))
|
|
125
|
+
} catch {
|
|
126
|
+
latest.set(n, null)
|
|
127
|
+
}
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
for (const r of repos) {
|
|
131
|
+
for (const d of r.deps) d.latest = latest.get(d.name) ?? null
|
|
132
|
+
r.behind = r.deps.filter((d) => d.installed != null && d.latest != null && isBehind(d.installed, d.latest))
|
|
133
|
+
r.breaking = r.behind.filter((d) => isBreaking(d.installed!, d.latest!))
|
|
134
|
+
}
|
|
135
|
+
return latest
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function npmLatest(name: string): Promise<string | null> {
|
|
139
|
+
const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(10_000) })
|
|
140
|
+
if (!res.ok) return null
|
|
141
|
+
return ((await res.json()) as Pkg).version ?? null
|
|
142
|
+
}
|
package/src/git-bootstrap.ts
CHANGED
|
@@ -101,7 +101,7 @@ const samePath = (a: string, b: string): boolean => {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
type RepoView = { nameWithOwner?: string; isPrivate?: boolean;
|
|
104
|
+
type RepoView = { nameWithOwner?: string; isPrivate?: boolean; sshUrl?: string }
|
|
105
105
|
|
|
106
106
|
/** phase one: this dir IS a git work tree root. false means the caller must
|
|
107
107
|
* not publish anything (a subdirectory of another repo, or git refused), and
|
|
@@ -194,7 +194,7 @@ export async function publishRepo(root: string, opts: PublishOpts = {}): Promise
|
|
|
194
194
|
const login = firstGhLine(user.out)
|
|
195
195
|
if (!NAME_RE.test(login)) return void p(`WARN no remote wired: gh did not answer with a login (${login})`)
|
|
196
196
|
const target = `${login}/${name}`
|
|
197
|
-
const viewArgs = ['repo', 'view', target, '--json', 'nameWithOwner,isPrivate,
|
|
197
|
+
const viewArgs = ['repo', 'view', target, '--json', 'nameWithOwner,isPrivate,sshUrl']
|
|
198
198
|
|
|
199
199
|
let view = await gh(viewArgs)
|
|
200
200
|
let created = false
|
|
@@ -217,9 +217,15 @@ export async function publishRepo(root: string, opts: PublishOpts = {}): Promise
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
const info = carveGhJson<RepoView>(view.out)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
// ALWAYS the ssh url gh gave, never the https one and never `gh config
|
|
221
|
+
// get git_protocol`: an https origin re-asks for a credential on every
|
|
222
|
+
// push, and a machine answering `https` there wires a remote that reports
|
|
223
|
+
// success and fails at the first push. An answer carrying no sshUrl is
|
|
224
|
+
// unreadable, not an invitation to build one: this module knows no host,
|
|
225
|
+
// and a guessed `github.com` remote against an enterprise gh is the same
|
|
226
|
+
// silent failure one host over
|
|
227
|
+
const url = info?.sshUrl
|
|
228
|
+
if (url == null) return void p(`WARN no remote wired: gh answered without an ssh url (${firstGhLine(view.out)})`)
|
|
223
229
|
|
|
224
230
|
const add = await git(['remote', 'add', 'origin', url])
|
|
225
231
|
if (add.code !== 0) return void p(`WARN no remote wired: git remote add failed (${firstGhLine(add.out)})`)
|
package/src/ledger/gate-units.ts
CHANGED
|
@@ -40,44 +40,97 @@ export function tieredTasks(c: RepoConfig, root: string, tier: TestTier): TaskDe
|
|
|
40
40
|
* built from the unmodified list would let a quick run replay into a full one
|
|
41
41
|
* under one identity. */
|
|
42
42
|
export function gateUnits(root: string, c: RepoConfig, tasks?: TaskDef[]): UnitDecl[] {
|
|
43
|
-
|
|
43
|
+
return gateInventory(root, c, tasks).map((r) => r.decl)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** one gate unit with everything a reader asks about it, not only what the
|
|
47
|
+
* key layer asks. The cache half stays in `decl` so `UnitDecl` never grows
|
|
48
|
+
* fields the key layer must then decide to ignore. */
|
|
49
|
+
export type UnitRow = {
|
|
50
|
+
decl: UnitDecl
|
|
51
|
+
/** the display name an operator reads (a rule's title, else the id) */
|
|
52
|
+
title: string
|
|
53
|
+
/** does `shipkit ci` run it? false = PROACTIVE: `shipkit check` reports it,
|
|
54
|
+
* the gate never does, and it may sit red for months without anything
|
|
55
|
+
* being wrong with the change in front of you. */
|
|
56
|
+
gate: boolean
|
|
57
|
+
/** can a failure stop you? null = the unit never said AND the repo set no
|
|
58
|
+
* `checkDefaults.blocking`, which the check runner treats as fatal. */
|
|
59
|
+
blocking: boolean | null
|
|
60
|
+
/** where the answer is owned: a rule's wiki page, a check's file, a task's
|
|
61
|
+
* command. Null when the unit names none. */
|
|
62
|
+
owner: string | null
|
|
63
|
+
/** the group a task was filed under, for the few that declare one */
|
|
64
|
+
group: string | null
|
|
65
|
+
/** excluded from a default run unless named with `--with` */
|
|
66
|
+
optIn: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** every unit the gate knows, with its metadata. `gateUnits` is this list
|
|
70
|
+
* narrowed to the cache declaration, so the three registries are walked in
|
|
71
|
+
* exactly one place and a surface can never enumerate a different set than
|
|
72
|
+
* the one the key layer keyed. */
|
|
73
|
+
export function gateInventory(root: string, c: RepoConfig, tasks?: TaskDef[]): UnitRow[] {
|
|
74
|
+
const out: UnitRow[] = []
|
|
44
75
|
for (const t of tasks ?? allTasks(c, root).tasks) {
|
|
45
76
|
out.push({
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
77
|
+
decl: {
|
|
78
|
+
id: t.id,
|
|
79
|
+
kind: 'task',
|
|
80
|
+
inputs: t.inputs,
|
|
81
|
+
ttl: t.ttl,
|
|
82
|
+
never: t.never,
|
|
83
|
+
argv: t.cmd,
|
|
84
|
+
extra: t.env == null ? undefined : JSON.stringify(Object.entries(t.env).sort()),
|
|
85
|
+
},
|
|
86
|
+
title: t.id,
|
|
87
|
+
// an optIn task is off the default run, which is the same shape as
|
|
88
|
+
// a check that opted out of the gate
|
|
89
|
+
gate: t.optIn !== true,
|
|
90
|
+
// a task IS the build: it has no non-blocking mode to declare
|
|
91
|
+
blocking: true,
|
|
92
|
+
owner: t.cmd == null ? null : t.cmd.join(' '),
|
|
93
|
+
group: t.group ?? null,
|
|
94
|
+
optIn: t.optIn === true,
|
|
55
95
|
})
|
|
56
96
|
}
|
|
57
97
|
for (const r of [...RULES, ...(c.rules ?? [])]) {
|
|
58
98
|
out.push({
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
99
|
+
decl: {
|
|
100
|
+
id: r.id,
|
|
101
|
+
kind: 'rule',
|
|
102
|
+
inputs: r.inputs,
|
|
103
|
+
code: r.code,
|
|
104
|
+
ttl: r.ttl,
|
|
105
|
+
never: r.never,
|
|
106
|
+
extra: r.cacheKey?.({ root, config: c }),
|
|
107
|
+
},
|
|
108
|
+
title: r.title,
|
|
109
|
+
gate: true,
|
|
110
|
+
blocking: r.blocking,
|
|
111
|
+
owner: r.owner,
|
|
112
|
+
group: null,
|
|
113
|
+
optIn: false,
|
|
66
114
|
})
|
|
67
115
|
}
|
|
68
116
|
for (const d of discoverChecks(root)) {
|
|
69
117
|
const rel = relative(root, d.path).replace(/\\/g, '/')
|
|
70
118
|
out.push({
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
119
|
+
decl: {
|
|
120
|
+
id: d.name,
|
|
121
|
+
kind: 'check',
|
|
122
|
+
inputs: d.inputs ?? undefined,
|
|
123
|
+
ttl: d.ttl ?? undefined,
|
|
124
|
+
never: d.never ?? undefined,
|
|
125
|
+
code: [rel],
|
|
126
|
+
argv: ['bun', rel],
|
|
127
|
+
},
|
|
128
|
+
title: d.name,
|
|
129
|
+
gate: d.gate,
|
|
130
|
+
blocking: d.blocking ?? c.checkDefaults?.blocking ?? null,
|
|
131
|
+
owner: rel,
|
|
132
|
+
group: null,
|
|
133
|
+
optIn: false,
|
|
81
134
|
})
|
|
82
135
|
}
|
|
83
136
|
return out
|
package/src/ledger/store.ts
CHANGED
|
@@ -44,6 +44,11 @@ export type Stored<T> = {
|
|
|
44
44
|
key: string
|
|
45
45
|
/** when it was written, ms since epoch */
|
|
46
46
|
at: number
|
|
47
|
+
/** how long THIS entry stays valid, when that is shorter than the unit's
|
|
48
|
+
* declared TTL. A measured negative writes one, so a red about the world
|
|
49
|
+
* clears soon after a human fixes the world, while a clean answer keeps
|
|
50
|
+
* for the full declared window. Absent means the declared TTL. */
|
|
51
|
+
keeps?: number
|
|
47
52
|
payload: T
|
|
48
53
|
}
|
|
49
54
|
|
|
@@ -87,10 +92,10 @@ export function readStored<T>(path: string, key: string): Stored<T> | null {
|
|
|
87
92
|
} catch { return null }
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
export function writeStored<T>(path: string, key: string, payload: T, at: number): void {
|
|
95
|
+
export function writeStored<T>(path: string, key: string, payload: T, at: number, keeps?: number): void {
|
|
91
96
|
try {
|
|
92
97
|
mkdirSync(dirname(path), { recursive: true })
|
|
93
|
-
atomicWrite(path, JSON.stringify({ v: STORE_VERSION, key, at, payload }))
|
|
98
|
+
atomicWrite(path, JSON.stringify({ v: STORE_VERSION, key, at, keeps, payload }))
|
|
94
99
|
} catch {
|
|
95
100
|
// a store that cannot be written is slow, never wrong: the answer was
|
|
96
101
|
// already printed, and the next run measures again
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// THE inventory render — every gate unit as one markdown row, so the question
|
|
2
|
+
// "what runs here, does it block me, and how often does it really run" has a
|
|
3
|
+
// surface that is read rather than reconstructed from three commands.
|
|
4
|
+
//
|
|
5
|
+
// Deliberately carries NO live run state: no timestamps, no key hashes, no
|
|
6
|
+
// last verdict. Everything here is what a unit DECLARED about itself, so two
|
|
7
|
+
// runs a week apart over an unchanged repo write identical bytes and the file
|
|
8
|
+
// never churns. It is still generated, gitignored AND untracked like STATUS.md
|
|
9
|
+
// (rules/untrack-generated.ts), so it is read on disk, never in git history.
|
|
10
|
+
// Live keys are `shipkit keys`, live verdicts are STATUS.md.
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
import { atomicWrite, withFileLock } from '../core/locked-write.ts'
|
|
13
|
+
import { classify } from './git-key.ts'
|
|
14
|
+
import type { UnitRow } from './gate-units.ts'
|
|
15
|
+
|
|
16
|
+
/** the sidecar every gate run refreshes beside STATUS.md */
|
|
17
|
+
export const UNIT_TABLE_FILE = 'STATUS.full.md'
|
|
18
|
+
|
|
19
|
+
/** a cell can never break the table it sits in */
|
|
20
|
+
const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/\n/g, ' ')
|
|
21
|
+
|
|
22
|
+
export type UnitTableInput = {
|
|
23
|
+
/** repo name, for the headline */
|
|
24
|
+
name: string
|
|
25
|
+
rows: UnitRow[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** what a unit's cache declaration means for how often it actually runs */
|
|
29
|
+
function cacheOf(r: UnitRow): { population: string; ttl: string; reads: string; why: string } {
|
|
30
|
+
const c = classify(r.decl)
|
|
31
|
+
const inputs = r.decl.inputs ?? []
|
|
32
|
+
if (c.error != null) return { population: 'BROKEN', ttl: '', reads: '', why: c.error }
|
|
33
|
+
if (c.population === 'content') return { population: 'content', ttl: '', reads: inputs.join(', '), why: '' }
|
|
34
|
+
// no `reads` for these two: the cache column already says "world"/"never",
|
|
35
|
+
// and restating it on every such row is a column of noise
|
|
36
|
+
if (c.population === 'world') return { population: 'world', ttl: r.decl.ttl ?? '', reads: '', why: '' }
|
|
37
|
+
if (c.decided === true) return { population: 'never', ttl: '', reads: '', why: r.decl.never ?? '' }
|
|
38
|
+
return { population: 'UNDECLARED', ttl: '', reads: '', why: 'nobody has declared what this reads, so it re-runs every single time' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderUnitTable(input: UnitTableInput): string {
|
|
42
|
+
const { name, rows } = input
|
|
43
|
+
const counts = { task: 0, rule: 0, check: 0 }
|
|
44
|
+
for (const r of rows) counts[r.decl.kind]++
|
|
45
|
+
|
|
46
|
+
const body: string[] = []
|
|
47
|
+
body.push(`<!-- generated by \`shipkit units\`, refreshed by every gate run, gitignored (SK004) -->`)
|
|
48
|
+
body.push(`# ${name} units — ${rows.length} total`)
|
|
49
|
+
body.push('')
|
|
50
|
+
body.push(`Everything \`shipkit ci\` and \`shipkit check\` know how to run: ${counts.task} task(s), ${counts.rule} rule(s), ${counts.check} discovered check(s).`)
|
|
51
|
+
body.push('')
|
|
52
|
+
body.push('- **runs in** — `gate` means `shipkit ci` runs it, so a red one says THIS change is broken. `proactive` means only `shipkit check` runs it: it reports the world (a box that is off, a cred nobody minted, a page nobody refreshed), it can sit red for months with nothing wrong, and it never stands between you and a commit.')
|
|
53
|
+
body.push('- **on failure** — `blocks` stops the run. `warns` prints and carries on. `UNDECLARED` is a defect, not a setting: the unit never said and the repo set no default, so the runner treats it as fatal by guess.')
|
|
54
|
+
body.push('- **cache** — `content` replays while the files it reads are untouched. `world` replays for its TTL, and a measured RED replays too, capped at one hour so the run after a fix sees the fix. `never` re-runs every time and somebody wrote down why. `UNDECLARED` re-runs every time and nobody has looked yet, which is the burn-down (`shipkit keys --burn-down`). `SHIPKIT_FRESH=1` busts all of it.')
|
|
55
|
+
const proactive = rows.filter((r) => !r.gate && !r.optIn).length
|
|
56
|
+
const warns = rows.filter((r) => r.blocking === false).length
|
|
57
|
+
const neverSaid = rows.filter((r) => r.blocking == null).length
|
|
58
|
+
const undeclared = rows.filter((r) => cacheOf(r).population === 'UNDECLARED')
|
|
59
|
+
body.push('')
|
|
60
|
+
// the most useful line in the file, so it sits ABOVE the 68 rows rather
|
|
61
|
+
// than under them
|
|
62
|
+
body.push(
|
|
63
|
+
`**${rows.length - proactive} in the gate, ${proactive} proactive · ${warns} warn instead of blocking${neverSaid > 0 ? `, ${neverSaid} never said` : ''} · ${undeclared.length} uncacheable with nobody having looked.**`,
|
|
64
|
+
)
|
|
65
|
+
body.push('')
|
|
66
|
+
body.push('| unit | kind | runs in | on failure | cache | ttl | reads | owner |')
|
|
67
|
+
body.push('|---|---|---|---|---|---|---|---|')
|
|
68
|
+
for (const r of rows) {
|
|
69
|
+
const c = cacheOf(r)
|
|
70
|
+
const blocking = r.blocking == null ? '**UNDECLARED**' : r.blocking ? 'blocks' : 'warns'
|
|
71
|
+
const runsIn = r.optIn ? 'opt-in' : r.gate ? 'gate' : 'proactive'
|
|
72
|
+
const population = c.population === 'UNDECLARED' ? '**UNDECLARED**' : c.population
|
|
73
|
+
body.push(
|
|
74
|
+
`| \`${cell(r.decl.id)}\` | ${r.decl.kind} | ${runsIn} | ${blocking} | ${population} | ${cell(c.ttl)} | ${cell(c.reads)} | ${r.owner == null ? '' : `\`${cell(r.owner)}\``} |`,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
body.push('')
|
|
78
|
+
|
|
79
|
+
const decided = rows.filter((r) => cacheOf(r).population === 'never')
|
|
80
|
+
if (undeclared.length > 0) {
|
|
81
|
+
body.push(`## ${undeclared.length} unit(s) nothing can cache yet`)
|
|
82
|
+
body.push('')
|
|
83
|
+
body.push('Each of these runs on every single pass. Declaring `inputs` (it reads files), `ttl` (it reads the world) or `never` (nothing can describe it) is the fix, and the last one counts: a written reason is a decision, not a hole.')
|
|
84
|
+
body.push('')
|
|
85
|
+
for (const r of undeclared) body.push(`- \`${cell(r.decl.id)}\` (${r.decl.kind})${r.owner == null ? '' : ` — \`${cell(r.owner)}\``}`)
|
|
86
|
+
body.push('')
|
|
87
|
+
}
|
|
88
|
+
if (decided.length > 0) {
|
|
89
|
+
body.push(`## ${decided.length} unit(s) decided uncacheable`)
|
|
90
|
+
body.push('')
|
|
91
|
+
for (const r of decided) body.push(`- \`${cell(r.decl.id)}\` — ${cell(r.decl.never ?? '')}`)
|
|
92
|
+
body.push('')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return body.join('\n')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** refresh the sidecar beside STATUS.md. Lock-guarded exactly like STATUS.md:
|
|
99
|
+
* several runners in one checkout is the normal case, and the loser skipping
|
|
100
|
+
* a render it would have written identically costs nothing. */
|
|
101
|
+
export function writeUnitTable(root: string, input: UnitTableInput): { written: boolean; warn?: string } {
|
|
102
|
+
const target = join(root, UNIT_TABLE_FILE)
|
|
103
|
+
const r = withFileLock(target, () => { atomicWrite(target, `${renderUnitTable(input).trimEnd()}\n`) })
|
|
104
|
+
return r.ok ? { written: true } : { written: false, warn: `${UNIT_TABLE_FILE} locked by another runner — skipped this write` }
|
|
105
|
+
}
|