rvlib-shipkit 1.4.0 → 1.5.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 +68 -0
- package/README.md +17 -17
- package/package.json +3 -2
- package/src/check.ts +68 -95
- package/src/checks/SK001-package-json-shape.check.ts +6 -2
- package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
- package/src/checks/SK003-claude-md-shape.check.ts +5 -1
- package/src/checks/SK004-gitignore-policy.check.ts +7 -4
- package/src/checks/SK005-rvlib-versions.check.ts +53 -21
- package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
- package/src/checks/SK007-github-visibility.check.ts +4 -2
- package/src/checks/SK008-reviewer-agent.check.ts +6 -4
- package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
- package/src/checks/SK010-undeclared-unit.check.ts +4 -0
- package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
- package/src/checks/SK012-config-barrel-import.check.ts +5 -1
- package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
- package/src/checks/SK014-prose-ste.check.ts +4 -0
- package/src/checks/SK015-nested-repo.check.ts +4 -2
- package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
- package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
- package/src/checks/SK018-test-files-run.check.ts +6 -2
- package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
- package/src/checks/SK020-self-spawning-check.check.ts +4 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
- package/src/checks/SK024-identity-colors.check.ts +5 -3
- package/src/checks/SK025-release-drift.check.ts +4 -5
- package/src/checks/SK026-asks-autoclose.check.ts +4 -4
- package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
- package/src/checks/SK028-readme.check.ts +5 -1
- package/src/checks/SK029-explicit-check-files.check.ts +95 -0
- package/src/cli/cmd_Asks.ts +5 -5
- package/src/cli/cmd_Assets.ts +7 -7
- package/src/cli/cmd_Bump.ts +12 -11
- package/src/cli/cmd_Check.ts +8 -7
- package/src/cli/cmd_Checks.ts +7 -6
- package/src/cli/cmd_Ci.ts +12 -11
- package/src/cli/cmd_Commands.ts +16 -13
- package/src/cli/cmd_ConfigKeys.ts +7 -6
- package/src/cli/cmd_Contracts.ts +3 -3
- package/src/cli/cmd_CrossRepoActivity.ts +6 -5
- package/src/cli/cmd_Fleet.ts +9 -7
- package/src/cli/cmd_GlobalProcesses.ts +116 -4
- package/src/cli/cmd_Home.ts +23 -17
- package/src/cli/cmd_Hooks.ts +6 -5
- package/src/cli/cmd_Init.ts +5 -4
- package/src/cli/cmd_Keys.ts +8 -7
- package/src/cli/cmd_Mv.ts +7 -6
- package/src/cli/cmd_Needs.ts +4 -3
- package/src/cli/cmd_Overview.ts +4 -3
- package/src/cli/cmd_Pb.ts +2 -2
- package/src/cli/cmd_Planned.ts +6 -5
- package/src/cli/cmd_Prose.ts +5 -5
- package/src/cli/cmd_Reflect.ts +7 -7
- package/src/cli/cmd_Release.ts +6 -5
- package/src/cli/cmd_Releases.ts +5 -4
- package/src/cli/cmd_Serve.ts +4 -3
- package/src/cli/cmd_Smoke.ts +5 -4
- package/src/cli/cmd_Start.ts +7 -6
- package/src/cli/cmd_Status.ts +4 -3
- package/src/cli/cmd_Tui.ts +3 -2
- package/src/cli/cmd_When.ts +71 -0
- package/src/cli/cmd_Wiki.ts +4 -4
- package/src/cli.ts +7 -2
- package/src/collections/checks.ts +2 -0
- package/src/collections/collection-types.ts +9 -4
- package/src/core/canonical-scripts.ts +1 -1
- package/src/core/check-runner.ts +37 -20
- package/src/core/config-rewrite.ts +37 -24
- package/src/core/discover-checks.ts +23 -2
- package/src/core/legacy-shapes.ts +39 -10
- package/src/core/one-way-in-policy.ts +2 -2
- package/src/core/outcome-words.ts +18 -2
- package/src/core/port-check-cli.ts +52 -0
- package/src/core/port-check-file.ts +295 -0
- package/src/core/port-check-files.ts +31 -0
- package/src/core/retention.ts +0 -14
- package/src/core/run-check.ts +12 -9
- package/src/core/run-ci.ts +8 -4
- package/src/core/run-fix.ts +30 -23
- package/src/core/run-fixers.ts +42 -5
- package/src/core/runs-matrix.ts +179 -0
- package/src/core/runs.ts +242 -0
- package/src/core/update-policy.ts +20 -0
- package/src/core/when-block.ts +29 -30
- package/src/core/when.ts +41 -103
- package/src/core/wire.ts +10 -14
- package/src/fleet/scan-fleet.ts +20 -4
- package/src/fleet/update-fleet.ts +6 -6
- package/src/home/home-launchd.ts +20 -4
- package/src/home/home-routes.ts +12 -1
- package/src/home/hubs.ts +5 -5
- package/src/home/pm.ts +8 -0
- package/src/home/run-pm.ts +1 -0
- package/src/home/serve-web.ts +2 -2
- package/src/home/supervise.ts +24 -1
- package/src/hooks/run-hooks.ts +48 -4
- package/src/init-repo.ts +6 -6
- package/src/ledger/gate-checks.ts +113 -59
- package/src/ledger/git-key.ts +5 -1
- package/src/ledger/ledger.ts +5 -5
- package/src/ledger/toolchain-identity.ts +45 -27
- package/src/plan/repo-source.ts +18 -39
- package/src/release/run-release.ts +5 -0
- package/src/repo-config.ts +30 -55
- package/src/rules/rule.ts +2 -2
- package/src/scaffold/files/agent-coding.md +1 -1
- package/src/scaffold/files/agent-index.md +1 -1
- package/src/scaffold/files/verify-skill.md +2 -2
- package/src/serve/hub-app/activity-st.ts +5 -4
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/admin-view.tsx +13 -11
- package/src/serve/hub-app/check-view.tsx +66 -46
- package/src/serve/hub-app/check-words.ts +1 -11
- package/src/serve/hub-app/checks-svc.ts +14 -35
- package/src/serve/hub-app/col-tables-view.tsx +13 -6
- package/src/serve/hub-app/cols-svc.ts +5 -4
- package/src/serve/hub-app/dock-st.ts +3 -2
- package/src/serve/hub-app/dock-view.tsx +36 -0
- package/src/serve/hub-app/dock.css +6 -0
- package/src/serve/hub-app/float-cards.tsx +30 -0
- package/src/serve/hub-app/floating.tsx +41 -4
- package/src/serve/hub-app/git-st.ts +42 -0
- package/src/serve/hub-app/git-view.tsx +61 -0
- package/src/serve/hub-app/hash-route.ts +45 -1
- package/src/serve/hub-app/header-bar.tsx +62 -72
- package/src/serve/hub-app/health-svc.ts +3 -2
- package/src/serve/hub-app/home-st.ts +14 -2
- package/src/serve/hub-app/home-strip.css +43 -8
- package/src/serve/hub-app/home-strip.tsx +111 -47
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +14 -4
- package/src/serve/hub-app/hub-types.ts +5 -2
- package/src/serve/hub-app/hub.css +65 -43
- package/src/serve/hub-app/icons.tsx +11 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/keys.ts +5 -2
- package/src/serve/hub-app/live-svc.ts +3 -2
- package/src/serve/hub-app/machine-st.ts +18 -22
- package/src/serve/hub-app/plan-view.tsx +51 -0
- package/src/serve/hub-app/planned-st.ts +9 -4
- package/src/serve/hub-app/processes-view.tsx +38 -0
- package/src/serve/hub-app/projects-svc.ts +15 -10
- package/src/serve/hub-app/prose-hits-st.ts +3 -2
- package/src/serve/hub-app/reach.ts +36 -0
- package/src/serve/hub-app/readme-svc.ts +3 -2
- package/src/serve/hub-app/repo-sections.ts +117 -0
- package/src/serve/hub-app/repo-st.ts +46 -9
- package/src/serve/hub-app/sidebar.tsx +12 -37
- package/src/serve/hub-app/timeline-svc.ts +3 -2
- package/src/serve/hub-app/tree-build.ts +149 -155
- package/src/serve/hub-app/tree-row.tsx +39 -9
- package/src/serve/hub-app/tree-svc.ts +70 -49
- package/src/serve/hub-app/when-view.tsx +89 -0
- package/src/serve/hub-app/when.css +17 -0
- package/src/serve/hub-runtime.ts +3 -16
- package/src/serve/run-serve.ts +1 -1
- package/src/serve/serve-hub.ts +4 -14
- package/src/serve/hub-app/processes-svc.ts +0 -63
- package/src/serve/hub-app/repo-sections.tsx +0 -61
- package/src/serve/repo-processes.ts +0 -89
package/src/core/run-fixers.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// rewrites the tree it checks can turn red green with no commit. one
|
|
9
9
|
// mechanism, two modes, rather than a formatter plus a format:check that
|
|
10
10
|
// drift apart.
|
|
11
|
-
import { existsSync, readFileSync } from 'fs'
|
|
12
|
-
import { join } from 'path'
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
12
|
+
import { dirname, join } from 'path'
|
|
13
13
|
import { fileURLToPath } from 'url'
|
|
14
14
|
import { armHooks } from '../hooks/run-hooks.ts'
|
|
15
15
|
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
@@ -22,6 +22,8 @@ import { layoutSummary, migrateLayout } from './migrate-layout.ts'
|
|
|
22
22
|
import { renderPolicy } from '../checks/SK004-gitignore-policy.check.ts'
|
|
23
23
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
24
24
|
import { gitBin } from './git-bin.ts'
|
|
25
|
+
import { LAYOUT } from './layout.ts'
|
|
26
|
+
import { gateBinary, toolchainIdentity } from '../ledger/toolchain-identity.ts'
|
|
25
27
|
|
|
26
28
|
const FIXER_TIMEOUT_MS = 120_000
|
|
27
29
|
|
|
@@ -123,6 +125,31 @@ function treeState(root: string): Map<string, string> | null {
|
|
|
123
125
|
return m
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
/** where the key of the last clean fix pass is kept, per checkout */
|
|
129
|
+
const memoPath = (root: string): string => join(root, LAYOUT.tmp, 'fix-pass.key')
|
|
130
|
+
|
|
131
|
+
/** what the fixers would see: HEAD, the dirty paths and their bytes, the
|
|
132
|
+
* toolchain that runs them, and their commands. null when HEAD cannot be read */
|
|
133
|
+
function fixMemo(root: string, tree: Map<string, string>, cmds: string[][]): string | null {
|
|
134
|
+
const head = Bun.spawnSync([gitBin(), 'rev-parse', 'HEAD'], { cwd: root })
|
|
135
|
+
if (head.exitCode !== 0) return null
|
|
136
|
+
const h = new Bun.CryptoHasher('sha256')
|
|
137
|
+
h.update(`head=${head.stdout.toString().trim()}\n`)
|
|
138
|
+
for (const [path, hash] of [...tree].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) h.update(`${path}\0${hash}\n`)
|
|
139
|
+
try { h.update(`toolchain=${toolchainIdentity(root, gateBinary()).id}\n`) } catch { return null }
|
|
140
|
+
h.update(`cmds=${JSON.stringify(cmds)}\n`)
|
|
141
|
+
return h.digest('hex')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const readMemo = (root: string): string | null => { try { return readFileSync(memoPath(root), 'utf8').trim() } catch { return null } }
|
|
145
|
+
|
|
146
|
+
const writeMemo = (root: string, key: string): void => {
|
|
147
|
+
try {
|
|
148
|
+
mkdirSync(dirname(memoPath(root)), { recursive: true })
|
|
149
|
+
writeFileSync(memoPath(root), `${key}\n`)
|
|
150
|
+
} catch { /* a memo that cannot be kept only means the next pass runs the fixers */ }
|
|
151
|
+
}
|
|
152
|
+
|
|
126
153
|
/** the bytes of repo.config.ts and the set of check and check files, as one
|
|
127
154
|
* hash. Read without git, so a repo with no history is covered too */
|
|
128
155
|
function shapeStamp(root: string): string {
|
|
@@ -231,13 +258,22 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
|
|
|
231
258
|
for (const u of unfilled) detail(`skip: ${u.id} (${u.reason})`)
|
|
232
259
|
for (const d of disabled) detail(`skip: ${d.id} (${d.reason})`)
|
|
233
260
|
const before = treeState(root)
|
|
234
|
-
|
|
235
|
-
|
|
261
|
+
// the fixers already ran clean over exactly this tree (HEAD, every dirty
|
|
262
|
+
// path's bytes, the toolchain, the commands): running them again rewrites
|
|
263
|
+
// nothing and costs a whole formatter pass. CI never skips: there the
|
|
264
|
+
// pass IS the check
|
|
265
|
+
const memo = verify || before == null ? null : fixMemo(root, before, slots.map((s) => s.cmd))
|
|
266
|
+
const skip = memo != null && readMemo(root) === memo
|
|
267
|
+
let fixersOk = true
|
|
268
|
+
if (skip) detail(`ok: fixers skipped, the tree is unchanged since they last ran clean (${slots.length} fixer(s))`)
|
|
269
|
+
else for (const s of slots) {
|
|
236
270
|
const r = await spawnWithTimeout(s.cmd, { cwd: root, timeoutMs: FIXER_TIMEOUT_MS })
|
|
237
271
|
// a broken fixer must never be mistaken for a clean one: the gate
|
|
238
272
|
// still runs, but the failure is loud and names the command
|
|
239
|
-
if (r.code !== 0)
|
|
273
|
+
if (r.code !== 0) {
|
|
274
|
+
fixersOk = false
|
|
240
275
|
o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
|
|
276
|
+
}
|
|
241
277
|
}
|
|
242
278
|
// structural repo-config repair is never implicit, and never in verify mode
|
|
243
279
|
if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print, detail: o.detail, config })
|
|
@@ -265,6 +301,7 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
|
|
|
265
301
|
return { ran: true, touched, failed: false, reshaped }
|
|
266
302
|
}
|
|
267
303
|
if (touched.length === 0) {
|
|
304
|
+
if (memo != null && !skip && fixersOk) writeMemo(root, memo)
|
|
268
305
|
detail(`ok: ${verify ? 'fixers verified' : 'fix pass'} clean (${slots.length} fixer(s), nothing to rewrite)`)
|
|
269
306
|
return { ran: true, touched, failed: false, reshaped }
|
|
270
307
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// WHAT RUNS WHEN, as data every surface prints the same way: the legend (each
|
|
2
|
+
// axis, each value, what it means) and one row of cells per check. Pure: it
|
|
3
|
+
// reads composed rows and returns strings, so `shipkit when`, the hub's when
|
|
4
|
+
// page and the docs page can never disagree.
|
|
5
|
+
import type { CheckRow } from '../ledger/gate-checks.ts'
|
|
6
|
+
import { cellText, RUNS_KEYS, type RunsAxis, type RunsSource } from './runs.ts'
|
|
7
|
+
|
|
8
|
+
export type MatrixAxis = RunsAxis | 'fix'
|
|
9
|
+
|
|
10
|
+
export const MATRIX_AXES: readonly MatrixAxis[] = [...RUNS_KEYS, 'fix']
|
|
11
|
+
|
|
12
|
+
/** one axis of the legend: the command or event it is, and every value */
|
|
13
|
+
export type LegendAxis = { axis: MatrixAxis | 'cache'; short: string; context: string; values: { value: string; means: string }[] }
|
|
14
|
+
|
|
15
|
+
/** THE legend. Every surface prints it verbatim, and build/shipkit/runs.md is
|
|
16
|
+
* generated from it */
|
|
17
|
+
export const RUNS_LEGEND: readonly LegendAxis[] = [
|
|
18
|
+
{
|
|
19
|
+
axis: 'onPreCommit', short: 'commit', context: '`git commit`, in the pre-commit hook, on the working tree',
|
|
20
|
+
values: [
|
|
21
|
+
{ value: 'false', means: 'never at commit' },
|
|
22
|
+
{ value: '{ budgetMs: n }', means: 'runs in process with no cache, before the message. A failure refuses the commit, a run past n ms (at most 100) warns' },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
axis: 'onCi', short: 'ci', context: '`shipkit ci`, the quick gate: an agent runs it once before it pushes its work, and the hub button runs a whole one. Named after the command, never a CI server',
|
|
27
|
+
values: [
|
|
28
|
+
{ value: 'true', means: 'in every quick gate: the cached answer while its key has not moved, else the body runs' },
|
|
29
|
+
{ value: 'false', means: 'not in the quick gate. `--with <id>` or `--only <id>` still runs it by name' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
axis: 'onFull', short: 'full', context: '`shipkit ci --full`: the quick gate (every onCi row) plus every slow suite',
|
|
34
|
+
values: [
|
|
35
|
+
{ value: 'true', means: 'in the full gate' },
|
|
36
|
+
{ value: 'false', means: 'not in the full gate' },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
axis: 'onRelease', short: 'release', context: 'the preflight of `shipkit bump` and `shipkit release`: full tier, frozen, no cache read, no repair',
|
|
41
|
+
values: [
|
|
42
|
+
{ value: 'true', means: 'its body runs before a version ships, never from the cache, and a failure stops the release. A non blocking check runs there only when the repo sets release.requireHealthy' },
|
|
43
|
+
{ value: 'false', means: 'a release never asks it' },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
axis: 'onHealth', short: 'health', context: '`shipkit check`, the TUI checks panel and the hub health run: is this repo healthy',
|
|
48
|
+
values: [
|
|
49
|
+
{ value: 'true', means: 'part of the health pass' },
|
|
50
|
+
{ value: 'false', means: 'not part of it (every build step)' },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
axis: 'onSave', short: 'save', context: 'the daemon, 60s after the last save in the repo',
|
|
55
|
+
values: [
|
|
56
|
+
{ value: 'false', means: 'a save never starts it' },
|
|
57
|
+
{ value: "'moved'", means: 'runs only when a file it is keyed on moved' },
|
|
58
|
+
{ value: "'burst'", means: 'runs after every save burst, whatever moved (nothing keys it)' },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
axis: 'onClock', short: 'clock', context: 'the daemon, on its own time',
|
|
63
|
+
values: [
|
|
64
|
+
{ value: 'false', means: 'never on a clock' },
|
|
65
|
+
{ value: "'once'", means: 'once while no answer exists for its fact (first run, a changed declaration)' },
|
|
66
|
+
{ value: '{ every, inactive }', means: "on a period ('24h', a range ['20h','28h'], a cron, or a function of its last result). inactive 'wait' holds it while nobody works in the repo, 'run' runs it anyway" },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
axis: 'fix', short: 'fix', context: 'which command applies the check\'s repair',
|
|
71
|
+
values: [
|
|
72
|
+
{ value: "'none'", means: 'no repair: a failure is fixed by hand' },
|
|
73
|
+
{ value: "'ci'", means: 'the fix pass of `shipkit ci` applies it after the body fails or warns, and `shipkit check --fix`' },
|
|
74
|
+
{ value: "'manual'", means: 'only `shipkit check --fix <id>` or the hub fix button' },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
axis: 'cache', short: 'cache', context: 'how an answer is kept, in every context but the release and the hook',
|
|
79
|
+
values: [
|
|
80
|
+
{ value: 'files', means: 'keyed on these paths and the check\'s own code: the cached answer holds while they do not move' },
|
|
81
|
+
{ value: 'reads', means: 'keyed on what the body read last time, which the cache checks again before it answers' },
|
|
82
|
+
{ value: 'ttl', means: 'an answer about the world, kept this long' },
|
|
83
|
+
{ value: 'fact', means: 'a decided value, kept until the declaration changes' },
|
|
84
|
+
{ value: 'never', means: 'runs every time, with the reason' },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
/** one cell: what it prints, whether the context runs it, and who said so */
|
|
90
|
+
export type RunsCell = { text: string; on: boolean; source: RunsSource }
|
|
91
|
+
|
|
92
|
+
/** one check as the matrix prints it */
|
|
93
|
+
export type RunsCells = {
|
|
94
|
+
cells: Record<MatrixAxis, RunsCell>
|
|
95
|
+
cache: string
|
|
96
|
+
/** the reason it runs nowhere, null for a live row */
|
|
97
|
+
off: string | null
|
|
98
|
+
/** why a derived row has its values, null for a declared one */
|
|
99
|
+
derivedWhy: string | null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function runsCellsOf(row: CheckRow): RunsCells {
|
|
103
|
+
const r = row.runs
|
|
104
|
+
const cells = Object.fromEntries(MATRIX_AXES.map((k) => {
|
|
105
|
+
const v = k === 'fix' ? r.fix : r[k]
|
|
106
|
+
const on = r.off == null && v !== false && v !== 'none'
|
|
107
|
+
return [k, { text: cellText(r, k), on, source: r.source[k] }]
|
|
108
|
+
})) as Record<MatrixAxis, RunsCell>
|
|
109
|
+
return { cells, cache: row.cache, off: r.off, derivedWhy: r.derivedWhy }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** where a row runs, as one line of words: every context that runs it, with
|
|
113
|
+
* its value when that says more than yes, then the command that repairs it */
|
|
114
|
+
export function runsWords(c: RunsCells): string {
|
|
115
|
+
if (c.off != null) return `runs nowhere: ${c.off}`
|
|
116
|
+
const word = (k: MatrixAxis): string => {
|
|
117
|
+
const cell = c.cells[k]
|
|
118
|
+
const short = RUNS_LEGEND.find((a) => a.axis === k)!.short
|
|
119
|
+
return `${short}${cell.text === 'yes' ? '' : ` ${cell.text}`}`
|
|
120
|
+
}
|
|
121
|
+
const on = RUNS_KEYS.filter((k) => c.cells[k].on).map(word)
|
|
122
|
+
const where = on.length === 0 ? 'runs only when named' : `runs on ${on.join(' · ')}`
|
|
123
|
+
return c.cells.fix.on ? `${where}, repaired by ${word('fix')}` : where
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** what a cell's value means, the legend's own sentence for it. A cell
|
|
127
|
+
* prints short (yes, ·, moved, 24h); the legend names the declared value */
|
|
128
|
+
export function cellMeaning(k: MatrixAxis | 'cache', text: string): string {
|
|
129
|
+
const values = RUNS_LEGEND.find((a) => a.axis === k)!.values
|
|
130
|
+
const declared = k === 'cache'
|
|
131
|
+
? text.split(/[\s:]/)[0]!
|
|
132
|
+
: text === 'yes' ? 'true'
|
|
133
|
+
: text === '·' ? 'false'
|
|
134
|
+
: k === 'onPreCommit' ? '{ budgetMs: n }'
|
|
135
|
+
: k === 'onClock' && text !== 'once' ? '{ every, inactive }'
|
|
136
|
+
: `'${text}'`
|
|
137
|
+
return values.find((v) => v.value === declared)?.means ?? ''
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** which rows a context runs, for the matrix's column filter */
|
|
141
|
+
export const inColumn = (c: RunsCells, k: MatrixAxis): boolean => c.cells[k].on
|
|
142
|
+
|
|
143
|
+
/** the matrix as terminal lines: the legend, then one row per check, and a
|
|
144
|
+
* row the repo turned off with its reason */
|
|
145
|
+
/** what a failure does, as one word: a build step and a blocking check stop
|
|
146
|
+
* you, a check about the world only warns */
|
|
147
|
+
export const stopsWord = (r: { blocking: boolean | null; build: boolean }): string => (r.build ? 'build' : r.blocking === false ? 'warns' : 'blocks')
|
|
148
|
+
|
|
149
|
+
export function matrixLines(rows: { id: string; blocking: boolean | null; build: boolean; runs: RunsCells }[], o: { legend: boolean; only?: MatrixAxis } = { legend: true }): string[] {
|
|
150
|
+
const out: string[] = []
|
|
151
|
+
if (o.legend) {
|
|
152
|
+
for (const a of RUNS_LEGEND) {
|
|
153
|
+
out.push(`${a.axis.padEnd(12)}${a.context}`)
|
|
154
|
+
for (const v of a.values) out.push(`${''.padEnd(14)}${v.value.padEnd(20)}${v.means}`)
|
|
155
|
+
}
|
|
156
|
+
out.push('')
|
|
157
|
+
}
|
|
158
|
+
const shown = o.only == null ? rows : rows.filter((r) => inColumn(r.runs, o.only!))
|
|
159
|
+
const width = Math.max(5, ...shown.map((r) => r.id.length)) + 2
|
|
160
|
+
const heads = MATRIX_AXES.map((k) => RUNS_LEGEND.find((a) => a.axis === k)!.short)
|
|
161
|
+
const colW = (i: number): number => Math.max(heads[i]!.length, ...shown.map((r) => cellOut(r.runs, MATRIX_AXES[i]!).length)) + 2
|
|
162
|
+
const widths = MATRIX_AXES.map((_, i) => colW(i))
|
|
163
|
+
out.push(`${'check'.padEnd(width)}${'fails'.padEnd(8)}${heads.map((h, i) => h.padEnd(widths[i]!)).join('')}cache`)
|
|
164
|
+
for (const r of shown) {
|
|
165
|
+
if (r.runs.off != null) { out.push(`${r.id.padEnd(width)}${stopsWord(r).padEnd(8)}off: ${r.runs.off}`); continue }
|
|
166
|
+
out.push(`${r.id.padEnd(width)}${stopsWord(r).padEnd(8)}${MATRIX_AXES.map((k, i) => cellOut(r.runs, k).padEnd(widths[i]!)).join('')}${r.runs.cache}`)
|
|
167
|
+
}
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const cellOut = (c: RunsCells, k: MatrixAxis): string => c.cells[k].text
|
|
172
|
+
|
|
173
|
+
/** the legend as markdown, the block `shipkit when --out` writes into a docs
|
|
174
|
+
* page between its markers */
|
|
175
|
+
export function legendMarkdown(): string {
|
|
176
|
+
const out = ['| key | the context | value | what it means |', '|---|---|---|---|']
|
|
177
|
+
for (const a of RUNS_LEGEND) a.values.forEach((v, i) => out.push(`| ${i === 0 ? `\`${a.axis}\`` : ''} | ${i === 0 ? a.context.replace(/\|/g, '\\|') : ''} | \`${v.value}\` | ${v.means.replace(/\|/g, '\\|')} |`))
|
|
178
|
+
return out.join('\n')
|
|
179
|
+
}
|
package/src/core/runs.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// WHERE A CHECK RUNS AND HOW ITS ANSWER IS KEPT, declared in full by every
|
|
2
|
+
// check, with no default anywhere.
|
|
3
|
+
//
|
|
4
|
+
// Every context that can run a check body or its repair is one key of `runs`,
|
|
5
|
+
// plus `fix` (which command applies the repair), `cache` (how the answer is
|
|
6
|
+
// kept) and `applies` (whether this repo gives it anything to do). A check
|
|
7
|
+
// file writes every key. A row with no file to say it in (a floor slot, a
|
|
8
|
+
// config task, a legacy form) gets every key from the code that derives it,
|
|
9
|
+
// with the reason, so no row ever resolves to a value nobody wrote.
|
|
10
|
+
//
|
|
11
|
+
// the one constraint a reader breaks first: a missing key is a load error,
|
|
12
|
+
// never a default. `runsProblem` and `cacheProblem` are the only judges, and
|
|
13
|
+
// `check()` calls both at import time.
|
|
14
|
+
import type { CheckEnv } from '../check.ts'
|
|
15
|
+
import { cadenceOf, ONCE, type Every, type WhenInactiveDecl } from '../plan/cadence.ts'
|
|
16
|
+
import { parseDuration } from './wire.ts'
|
|
17
|
+
|
|
18
|
+
// ── the declaration ──────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** the pre-commit hook: never, or in process on the working tree under a
|
|
21
|
+
* budget of at most PRE_COMMIT_MAX_MS */
|
|
22
|
+
export type OnPreCommit = false | { budgetMs: number }
|
|
23
|
+
/** the daemon after a quiet save burst: never, only when the check's key
|
|
24
|
+
* moved, or after every burst */
|
|
25
|
+
export type OnSave = false | 'moved' | 'burst'
|
|
26
|
+
/** the daemon on its own time: never, once while no answer exists for the
|
|
27
|
+
* fact's key, or on a period with what to do while the repo is inactive */
|
|
28
|
+
export type OnClock = false | 'once' | { every: Every; inactive: WhenInactiveDecl }
|
|
29
|
+
|
|
30
|
+
export type Runs = {
|
|
31
|
+
/** `git commit`, in the pre-commit hook, before the message */
|
|
32
|
+
onPreCommit: OnPreCommit
|
|
33
|
+
/** `shipkit ci`: the gate an agent runs once before it pushes its work,
|
|
34
|
+
* and a whole run from the hub's run button */
|
|
35
|
+
onCi: boolean
|
|
36
|
+
/** `shipkit ci --full`: the full tests tier. Every `onCi` row is here too */
|
|
37
|
+
onFull: boolean
|
|
38
|
+
/** the preflight of `shipkit bump` and `shipkit release`: full tier,
|
|
39
|
+
* frozen, no cache read, no repair */
|
|
40
|
+
onRelease: boolean
|
|
41
|
+
/** `shipkit check`, the TUI checks panel, the hub health run */
|
|
42
|
+
onHealth: boolean
|
|
43
|
+
/** the daemon, after a quiet save burst in the repo */
|
|
44
|
+
onSave: OnSave
|
|
45
|
+
/** the daemon, on its own time */
|
|
46
|
+
onClock: OnClock
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** which command applies the check's `repair`: none exists, the gate's fix
|
|
50
|
+
* pass (and `shipkit check --fix`), or only `shipkit check --fix <id>` */
|
|
51
|
+
export type FixWhen = 'none' | 'ci' | 'manual'
|
|
52
|
+
|
|
53
|
+
/** how an answer is kept. Every variant names its one parameter */
|
|
54
|
+
export type CacheDecl =
|
|
55
|
+
/** keyed on the content of these repo paths (and the check's own code) */
|
|
56
|
+
| { by: 'files'; inputs: string[]; key: ((env: CheckEnv) => string) | null }
|
|
57
|
+
/** keyed on what the body read through its context, re-checked before a replay */
|
|
58
|
+
| { by: 'reads' }
|
|
59
|
+
/** a clean answer about the world keeps for `span` */
|
|
60
|
+
| { by: 'ttl'; span: string; key: ((env: CheckEnv) => string) | null }
|
|
61
|
+
/** a decided fact, keyed on the declared value alone, never expires */
|
|
62
|
+
| { by: 'fact'; key: (env: CheckEnv) => string }
|
|
63
|
+
/** no key can describe it, with the reason: measured every time */
|
|
64
|
+
| { by: 'never'; why: string }
|
|
65
|
+
|
|
66
|
+
export type CacheBy = CacheDecl['by']
|
|
67
|
+
|
|
68
|
+
/** whether this repo gives the check anything to do. A reason back makes the
|
|
69
|
+
* row n/a in every context: never run, never planned, shown with the reason */
|
|
70
|
+
export type Applies = 'always' | ((env: CheckEnv) => string | null)
|
|
71
|
+
|
|
72
|
+
export const RUNS_KEYS = ['onPreCommit', 'onCi', 'onFull', 'onRelease', 'onHealth', 'onSave', 'onClock'] as const
|
|
73
|
+
export type RunsAxis = (typeof RUNS_KEYS)[number]
|
|
74
|
+
export const CACHE_BY: readonly CacheBy[] = ['files', 'reads', 'ttl', 'fact', 'never']
|
|
75
|
+
export const FIX_WHEN: readonly FixWhen[] = ['none', 'ci', 'manual']
|
|
76
|
+
|
|
77
|
+
/** the ceiling a pre-commit check may declare. The hook boots in about
|
|
78
|
+
* 500ms, and the text gates it already runs cost 50 to 100ms together, so a
|
|
79
|
+
* check that needs more than this belongs in `onCi` */
|
|
80
|
+
export const PRE_COMMIT_MAX_MS = 100
|
|
81
|
+
|
|
82
|
+
// ── the judges ───────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
const isBool = (v: unknown): v is boolean => typeof v === 'boolean'
|
|
85
|
+
|
|
86
|
+
function everyProblem(v: unknown): string | null {
|
|
87
|
+
if (typeof v === 'function') return null
|
|
88
|
+
if (v == null || v === ONCE) return `onClock.every takes a span, a range, a cron or a function, never ${JSON.stringify(v)} (use onClock: false or 'once')`
|
|
89
|
+
const c = cadenceOf(v as Every & (string | [string, string]), '')
|
|
90
|
+
return typeof c === 'string' ? `onClock.every: ${c}` : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** what is wrong with a `runs` object, or null. Every key is required */
|
|
94
|
+
export function runsProblem(r: unknown): string | null {
|
|
95
|
+
if (r == null || typeof r !== 'object') return `runs is required: { ${RUNS_KEYS.join(', ')} }, every key explicit`
|
|
96
|
+
const o = r as Record<string, unknown>
|
|
97
|
+
const missing = RUNS_KEYS.filter((k) => !(k in o))
|
|
98
|
+
if (missing.length > 0) return `runs is missing ${missing.join(', ')}: every key is explicit, no default`
|
|
99
|
+
const unknown = Object.keys(o).filter((k) => !(RUNS_KEYS as readonly string[]).includes(k))
|
|
100
|
+
if (unknown.length > 0) return `runs has unknown key ${unknown.join(', ')} (the keys: ${RUNS_KEYS.join(', ')})`
|
|
101
|
+
const p = o.onPreCommit
|
|
102
|
+
if (p !== false) {
|
|
103
|
+
const b = (p as { budgetMs?: unknown } | null)?.budgetMs
|
|
104
|
+
if (p == null || typeof p !== 'object' || typeof b !== 'number' || !Number.isInteger(b) || b < 1 || b > PRE_COMMIT_MAX_MS)
|
|
105
|
+
return `runs.onPreCommit takes false or { budgetMs: 1..${PRE_COMMIT_MAX_MS} }`
|
|
106
|
+
}
|
|
107
|
+
for (const k of ['onCi', 'onFull', 'onRelease', 'onHealth'] as const) if (!isBool(o[k])) return `runs.${k} takes true or false`
|
|
108
|
+
if (o.onSave !== false && o.onSave !== 'moved' && o.onSave !== 'burst') return `runs.onSave takes false, 'moved' or 'burst'`
|
|
109
|
+
const c = o.onClock
|
|
110
|
+
if (c !== false && c !== ONCE) {
|
|
111
|
+
if (c == null || typeof c !== 'object') return `runs.onClock takes false, 'once' or { every, inactive }`
|
|
112
|
+
const cc = c as { every?: unknown; inactive?: unknown }
|
|
113
|
+
if (!('every' in cc) || !('inactive' in cc)) return `runs.onClock takes { every, inactive }, both keys explicit`
|
|
114
|
+
const e = everyProblem(cc.every)
|
|
115
|
+
if (e != null) return `runs.${e}`
|
|
116
|
+
if (typeof cc.inactive !== 'function' && cc.inactive !== 'run' && cc.inactive !== 'wait') return `runs.onClock.inactive takes 'run', 'wait' or a function returning one`
|
|
117
|
+
}
|
|
118
|
+
if (o.onCi === true && o.onFull !== true) return `runs.onCi true needs onFull true: the full gate is the quick gate plus more`
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** what is wrong with a `cache` object, or null */
|
|
123
|
+
export function cacheProblem(c: unknown): string | null {
|
|
124
|
+
if (c == null || typeof c !== 'object') return `cache is required: { by: ${CACHE_BY.map((b) => `'${b}'`).join(' | ')}, ... }`
|
|
125
|
+
const o = c as Record<string, unknown>
|
|
126
|
+
const want: Record<CacheBy, string[]> = { files: ['inputs', 'key'], reads: [], ttl: ['span', 'key'], fact: ['key'], never: ['why'] }
|
|
127
|
+
const by = o.by as CacheBy
|
|
128
|
+
if (!CACHE_BY.includes(by)) return `cache.by takes ${CACHE_BY.map((b) => `'${b}'`).join(', ')}`
|
|
129
|
+
const keys = want[by]
|
|
130
|
+
const missing = keys.filter((k) => !(k in o))
|
|
131
|
+
if (missing.length > 0) return `cache { by: '${by}' } is missing ${missing.join(', ')}: every key is explicit`
|
|
132
|
+
const unknown = Object.keys(o).filter((k) => k !== 'by' && !keys.includes(k))
|
|
133
|
+
if (unknown.length > 0) return `cache { by: '${by}' } has unknown key ${unknown.join(', ')} (it takes ${keys.length === 0 ? 'nothing else' : keys.join(', ')})`
|
|
134
|
+
if (by === 'files' && (!Array.isArray(o.inputs) || o.inputs.length === 0 || o.inputs.some((p) => typeof p !== 'string' || p === ''))) return `cache.inputs is a non empty list of repo relative paths`
|
|
135
|
+
if ((by === 'files' || by === 'ttl') && o.key !== null && typeof o.key !== 'function') return `cache.key is null or a function of { root, config }`
|
|
136
|
+
if (by === 'fact' && typeof o.key !== 'function') return `cache { by: 'fact' } needs key: a function of { root, config } returning the declared value`
|
|
137
|
+
if (by === 'ttl') {
|
|
138
|
+
if (typeof o.span !== 'string') return `cache.span is a duration, as in '24h'`
|
|
139
|
+
try { if (parseDuration(o.span) <= 0) return `cache.span must be above zero` } catch (e) { return `cache.span: ${(e as Error).message}` }
|
|
140
|
+
}
|
|
141
|
+
if (by === 'never' && (typeof o.why !== 'string' || o.why.trim() === '')) return `cache { by: 'never' } needs why: the reason no key can describe it`
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** what a whole declaration must satisfy together, or null */
|
|
146
|
+
export type WholeDecl = { runs: Runs; cache: CacheDecl; fix: FixWhen; repair: unknown; build: boolean; body: 'run' | 'cmd'; blocking: boolean }
|
|
147
|
+
|
|
148
|
+
export function combinationProblem(d: WholeDecl): string | null {
|
|
149
|
+
const { runs: r, cache: c } = d
|
|
150
|
+
if (!FIX_WHEN.includes(d.fix)) return `fix takes 'none', 'ci' or 'manual'`
|
|
151
|
+
if (d.fix !== 'none' && typeof d.repair !== 'function') return `fix '${d.fix}' needs a repair function`
|
|
152
|
+
if (d.fix === 'none' && d.repair != null) return `a repair function needs fix 'ci' or 'manual', never 'none'`
|
|
153
|
+
if (d.fix === 'ci' && !r.onCi) return `fix 'ci' needs onCi true: the gate's fix pass runs the body first, so a check the gate does not run would run there anyway. Say 'manual'`
|
|
154
|
+
if (d.build && (r.onHealth || r.onPreCommit !== false || d.fix !== 'none'))
|
|
155
|
+
return `a build step runs only in gates: onHealth false, onPreCommit false, fix 'none' (a tool repairs its own findings)`
|
|
156
|
+
if (r.onPreCommit !== false) {
|
|
157
|
+
if (d.body === 'cmd') return `onPreCommit needs an in process run body, never a cmd`
|
|
158
|
+
if (!d.blocking) return `onPreCommit needs blocking true: the hook only refuses, it never warns about the world`
|
|
159
|
+
if (c.by === 'ttl' || c.by === 'fact' || c.by === 'never') return `onPreCommit needs cache files or reads: the hook reads the tree, never the world`
|
|
160
|
+
}
|
|
161
|
+
if (r.onSave === 'moved' && (c.by === 'ttl' || c.by === 'never')) return `onSave 'moved' needs cache files, reads or fact: nothing can move under ${c.by}`
|
|
162
|
+
if (r.onClock === ONCE && c.by !== 'fact') return `onClock 'once' needs cache fact: once means while no answer exists for the fact's key`
|
|
163
|
+
if (c.by === 'fact' && r.onClock !== ONCE) return `cache fact needs onClock 'once': a fact nobody runs once is a fact never answered`
|
|
164
|
+
if (c.by === 'reads' && d.body === 'cmd') return `cache reads needs a run body: a subprocess records no reads, say files or never`
|
|
165
|
+
const triggered = r.onPreCommit !== false || r.onCi || r.onFull || r.onRelease || r.onHealth || r.onSave !== false || r.onClock !== false
|
|
166
|
+
// an opt in build step (a deploy) is the one row nothing but a name runs:
|
|
167
|
+
// `shipkit ci --with <id>` asks it on purpose
|
|
168
|
+
if (!triggered && d.fix === 'none' && !d.build) return `nothing runs it: every trigger is false and fix is 'none'. Delete the file, turn it off in the repo with a reason, or make it an opt in build step (build: true)`
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── the resolved row ─────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/** where one resolved value came from */
|
|
175
|
+
export type RunsSource = 'check file' | 'repo.config.ts' | 'derived'
|
|
176
|
+
|
|
177
|
+
export type ResolvedRuns = Runs & {
|
|
178
|
+
fix: FixWhen
|
|
179
|
+
/** a reason the row runs in no context: the repo turned it off, or it
|
|
180
|
+
* does not apply here. Null for a live row */
|
|
181
|
+
off: string | null
|
|
182
|
+
/** where each value came from */
|
|
183
|
+
source: Record<RunsAxis | 'fix', RunsSource>
|
|
184
|
+
/** why a derived row has the values it has, null for a declared one */
|
|
185
|
+
derivedWhy: string | null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** every value from one source */
|
|
189
|
+
export function sourced(r: Runs, fix: FixWhen, from: RunsSource, derivedWhy: string | null = null, off: string | null = null): ResolvedRuns {
|
|
190
|
+
const source = Object.fromEntries([...RUNS_KEYS, 'fix'].map((k) => [k, from])) as ResolvedRuns['source']
|
|
191
|
+
return { ...r, fix, off, source, derivedWhy }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** runs in no context at all */
|
|
195
|
+
export const NOWHERE: Runs = { onPreCommit: false, onCi: false, onFull: false, onRelease: false, onHealth: false, onSave: false, onClock: false }
|
|
196
|
+
|
|
197
|
+
/** the context a runner asks about */
|
|
198
|
+
export type RunContext = 'preCommit' | 'ci' | 'full' | 'release' | 'health'
|
|
199
|
+
|
|
200
|
+
/** does this context run the row? Off is never */
|
|
201
|
+
export function runsIn(r: ResolvedRuns, ctx: RunContext): boolean {
|
|
202
|
+
if (r.off != null) return false
|
|
203
|
+
switch (ctx) {
|
|
204
|
+
case 'preCommit': return r.onPreCommit !== false
|
|
205
|
+
case 'ci': return r.onCi
|
|
206
|
+
case 'full': return r.onFull
|
|
207
|
+
case 'release': return r.onRelease
|
|
208
|
+
case 'health': return r.onHealth
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** the daemon plans it at all */
|
|
213
|
+
export const planned = (r: ResolvedRuns): boolean => r.off == null && (r.onSave !== false || r.onClock !== false)
|
|
214
|
+
|
|
215
|
+
/** the one line a surface prints for a row that is off */
|
|
216
|
+
export const offLine = (id: string, r: ResolvedRuns): string => `${id} off: ${r.off ?? '(no reason)'}`
|
|
217
|
+
|
|
218
|
+
/** the cell a matrix prints for one axis */
|
|
219
|
+
export function cellText(r: ResolvedRuns, k: RunsAxis | 'fix'): string {
|
|
220
|
+
const v = k === 'fix' ? r.fix : r[k]
|
|
221
|
+
if (v === true) return 'yes'
|
|
222
|
+
if (v === false) return '·'
|
|
223
|
+
if (typeof v === 'string') return v
|
|
224
|
+
if (k === 'onPreCommit') return `${(v as { budgetMs: number }).budgetMs}ms`
|
|
225
|
+
const c = v as { every: Every; inactive: WhenInactiveDecl }
|
|
226
|
+
const every = typeof c.every === 'function' ? 'fn' : Array.isArray(c.every) ? `${c.every[0]}..${c.every[1]}` : String(c.every)
|
|
227
|
+
return `${every}${c.inactive === 'run' ? ' (runs inactive)' : ''}`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** a files cache as one short cell: three paths, then a count */
|
|
231
|
+
export const filesText = (inputs: string[]): string => `files ${inputs.length <= 3 ? inputs.join(' ') : `${inputs.slice(0, 3).join(' ')} +${inputs.length - 3}`}`
|
|
232
|
+
|
|
233
|
+
/** the cache variant as one short cell */
|
|
234
|
+
export function cacheText(c: CacheDecl): string {
|
|
235
|
+
switch (c.by) {
|
|
236
|
+
case 'files': return filesText(c.inputs)
|
|
237
|
+
case 'reads': return 'reads'
|
|
238
|
+
case 'ttl': return `ttl ${c.span}`
|
|
239
|
+
case 'fact': return 'fact'
|
|
240
|
+
case 'never': return `never: ${c.why}`
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// startup self-update and `shipkit fleet --update`. An automatic update never
|
|
3
3
|
// crosses a breaking boundary. It reports the jump and names the one command
|
|
4
4
|
// that takes it on purpose.
|
|
5
|
+
import { lstatSync } from 'fs'
|
|
6
|
+
import { join } from 'path'
|
|
5
7
|
|
|
6
8
|
/** set by `shipkit fleet --update --major` on the repo run it spawns */
|
|
7
9
|
export const ALLOW_MAJOR_ENV = 'SHIPKIT_ALLOW_MAJOR'
|
|
@@ -23,3 +25,21 @@ export function isBreaking(installed: string, latest: string): boolean {
|
|
|
23
25
|
* name, which is what `fleet --only` matches */
|
|
24
26
|
export const breakingAdvice = (name: string, latest: string, repo: string): string =>
|
|
25
27
|
`${name} ${latest} available (breaking): run \`shipkit fleet --update --major --only ${repo}\``
|
|
28
|
+
|
|
29
|
+
/** the lowest version a semver pin admits (`^1.3.0` → 1.3.0, `1.3.0` →
|
|
30
|
+
* 1.3.0), null for anything else (`link:`, a tag, a url, a range with two
|
|
31
|
+
* ends). What a fresh clone of the repo installs is judged from this */
|
|
32
|
+
export function pinFloor(spec: string): string | null {
|
|
33
|
+
const m = /^(?:[\^~]|>=|=)?\s*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(spec.trim())
|
|
34
|
+
return m?.[1] ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** true when node_modules/<name> is a symlink (`bun link <name>`): it then
|
|
38
|
+
* carries a source tree, whose version says nothing about what the repo pins */
|
|
39
|
+
export function isLinkedInstall(root: string, name: string): boolean {
|
|
40
|
+
try {
|
|
41
|
+
return lstatSync(join(root, 'node_modules', name)).isSymbolicLink()
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|