rvlib-shipkit 1.2.0 → 1.3.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 +67 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +20 -6
- package/src/checks/SK001-package-json-shape.check.ts +14 -3
- package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
- package/src/checks/SK004-gitignore-policy.check.ts +11 -3
- package/src/checks/SK005-rvlib-versions.check.ts +13 -1
- package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
- package/src/checks/SK007-github-visibility.check.ts +9 -9
- package/src/checks/SK008-reviewer-agent.check.ts +14 -8
- package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
- package/src/checks/SK024-identity-colors.check.ts +5 -0
- package/src/checks/SK025-release-drift.check.ts +58 -0
- package/src/checks/SK026-asks-autoclose.check.ts +44 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
- package/src/checks/SK028-readme.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Ci.ts +5 -0
- package/src/cli/cmd_CrossRepoActivity.ts +116 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Keys.ts +17 -3
- package/src/cli/cmd_Overview.ts +98 -0
- package/src/cli/cmd_Planned.ts +117 -0
- package/src/cli.ts +9 -1
- package/src/collections/checks.ts +3 -2
- package/src/collections/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-runner.ts +40 -12
- package/src/core/discover-checks.ts +2 -0
- package/src/core/file-stamp.ts +20 -0
- package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
- package/src/core/identity-color.ts +1 -1
- package/src/core/repo-facts.ts +14 -0
- package/src/core/retention.ts +14 -5
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +43 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/wire.ts +11 -0
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +135 -0
- package/src/home/activity.ts +249 -0
- package/src/home/global-units.ts +3 -2
- package/src/home/home-routes.ts +32 -5
- package/src/home/pm.ts +37 -4
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +35 -8
- package/src/home/run-daemon.ts +6 -6
- package/src/home/run-pm.ts +1 -0
- package/src/home/serve-web.ts +6 -3
- package/src/home/supervise.ts +16 -1
- package/src/home/widget-text.ts +119 -0
- package/src/hooks/run-hooks.ts +20 -3
- package/src/ledger/db.ts +1 -1
- package/src/ledger/gate-checks.ts +17 -3
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +39 -10
- package/src/ledger/store.ts +19 -2
- package/src/ledger/world-cache.ts +51 -4
- package/src/plan/cadence.ts +21 -5
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +138 -13
- package/src/plan/repo-source.ts +120 -8
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +11 -3
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-words.ts +2 -0
- package/src/serve/hub-app/controls.css +32 -0
- package/src/serve/hub-app/cycle-button.tsx +36 -0
- package/src/serve/hub-app/dock-anim.ts +25 -0
- package/src/serve/hub-app/dock-st.ts +329 -0
- package/src/serve/hub-app/dock-view.tsx +68 -0
- package/src/serve/hub-app/dock.css +35 -0
- package/src/serve/hub-app/hash-route.ts +7 -1
- package/src/serve/hub-app/header-bar.tsx +79 -104
- package/src/serve/hub-app/home-st.ts +49 -12
- package/src/serve/hub-app/home-strip.css +65 -22
- package/src/serve/hub-app/home-strip.tsx +228 -97
- package/src/serve/hub-app/home.html +4 -0
- package/src/serve/hub-app/hub-app.tsx +96 -160
- package/src/serve/hub-app/hub.css +77 -37
- package/src/serve/hub-app/icons.tsx +23 -3
- package/src/serve/hub-app/index.html +4 -0
- package/src/serve/hub-app/keys.ts +42 -7
- package/src/serve/hub-app/live-svc.ts +24 -64
- package/src/serve/hub-app/machine-st.ts +64 -1
- package/src/serve/hub-app/md.ts +38 -5
- package/src/serve/hub-app/next-run.ts +1 -1
- package/src/serve/hub-app/palette-st.ts +11 -7
- package/src/serve/hub-app/palette-view.tsx +47 -0
- package/src/serve/hub-app/pill-row.tsx +65 -0
- package/src/serve/hub-app/planned-st.ts +21 -85
- package/src/serve/hub-app/planned-view.tsx +12 -7
- package/src/serve/hub-app/planned.css +5 -2
- package/src/serve/hub-app/project-list.css +43 -21
- package/src/serve/hub-app/project-list.tsx +122 -23
- package/src/serve/hub-app/projects-svc.ts +120 -12
- package/src/serve/hub-app/readme-svc.ts +38 -0
- package/src/serve/hub-app/readme-view.tsx +19 -0
- package/src/serve/hub-app/readme.css +11 -0
- package/src/serve/hub-app/repo-sections.tsx +61 -0
- package/src/serve/hub-app/repo-st.ts +25 -6
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/sidebar.tsx +84 -0
- package/src/serve/hub-app/tests-view.tsx +31 -4
- package/src/serve/hub-app/timeline-chart.tsx +31 -27
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +2 -2
- package/src/serve/hub-app/timeline-view.tsx +20 -3
- package/src/serve/hub-runtime.ts +17 -9
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +100 -20
- package/src/serve/project-gutter.ts +33 -11
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/serve-hub.ts +13 -8
- package/src/serve/tracked-watch.ts +33 -1
- package/src/shipkit-files.ts +20 -4
- package/src/checks/SK023-stores-under-cap.check.ts +0 -49
- package/src/checks/contracts.check.ts +0 -20
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
//
|
|
18
18
|
// Skipped dirs: node_modules, dist, tmp, references (templates for other
|
|
19
19
|
// repos), and every dot-dir.
|
|
20
|
+
import { RETIRED_CHECK_IDS } from './retired-checks.ts'
|
|
20
21
|
import { readdirSync, readFileSync, realpathSync, statSync } from 'fs'
|
|
21
22
|
import { basename, join, relative } from 'path'
|
|
22
23
|
import { check, type CheckDef, type CheckFrom } from '../check.ts'
|
|
@@ -176,6 +177,7 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
|
|
|
176
177
|
if (isBuiltin) {
|
|
177
178
|
if (id !== fallback) throw new Error(`${f}: declares id '${id}' but its file name says '${fallback}' — a built-in id is its file name`)
|
|
178
179
|
if ((def.owner ?? '') === '') throw new Error(`${f}: a built-in check names the page that OWNS the invariant`)
|
|
180
|
+
if (RETIRED_CHECK_IDS[id] != null) throw new Error(`${f}: id '${id}' is retired (${RETIRED_CHECK_IDS[id]}), and an id is never reused`)
|
|
179
181
|
} else if (id.startsWith('SK')) {
|
|
180
182
|
throw new Error(`${rel}: id '${id}' uses the reserved SK prefix — a repo check picks its own (e.g. RV${id.slice(-3)}) or plain words`)
|
|
181
183
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// the content of a file no tree id reaches (outside the repo, gitignored, an
|
|
2
|
+
// install), as one short hash for a check's `cacheKey`. `absent` when nothing
|
|
3
|
+
// stands there, so a file appearing or vanishing moves the key too.
|
|
4
|
+
import { createHash } from 'crypto'
|
|
5
|
+
import { readFileSync } from 'fs'
|
|
6
|
+
import { homedir } from 'os'
|
|
7
|
+
import { isAbsolute, join } from 'path'
|
|
8
|
+
|
|
9
|
+
export function fileStamp(abs: string): string {
|
|
10
|
+
try {
|
|
11
|
+
return createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16)
|
|
12
|
+
} catch {
|
|
13
|
+
return 'absent'
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** a path as a config declares it (`~/...`, absolute, or repo relative), absolute */
|
|
18
|
+
export function configPath(root: string, p: string): string {
|
|
19
|
+
return p.startsWith('~/') ? join(homedir(), p.slice(2)) : isAbsolute(p) ? p : join(root, p)
|
|
20
|
+
}
|
|
@@ -1,27 +1,30 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// visibility with it. gh keeps that login outside any secrets manager, so
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// the gh login the toolkit itself runs on: `init` and `start` create a
|
|
2
|
+
// PRIVATE repo with it, every push and release goes through it, SK007 reads
|
|
3
|
+
// visibility with it. gh keeps that login outside any secrets manager, so the
|
|
4
|
+
// house repo's one `github-login` check (a machine fact, asked once, never in
|
|
5
|
+
// every repo) runs `probeGhLogin`. It is a `core` credential: red on the
|
|
6
|
+
// secrets page and in `shipkit needs` (collections/providers.ts reads the
|
|
7
|
+
// `cred <name> missing` line).
|
|
8
8
|
//
|
|
9
9
|
// A private repo needs the classic `repo` scope. The token itself is never
|
|
10
10
|
// asked for: `gh auth status --json` carries no token field, and the text
|
|
11
11
|
// answer masks it. github.com unreachable is `unreachable`, a WARN that
|
|
12
12
|
// measured nothing, never a lost login.
|
|
13
13
|
import { carveGhJson, firstGhLine } from '../git-bootstrap.ts'
|
|
14
|
-
import {
|
|
14
|
+
import type { CheckCtx } from './check-ctx.ts'
|
|
15
15
|
|
|
16
16
|
const HOST = 'github.com'
|
|
17
17
|
|
|
18
|
-
/** the
|
|
19
|
-
|
|
18
|
+
/** the id of the house check that runs `probeGhLogin` */
|
|
19
|
+
export const GH_LOGIN_CHECK = 'github-login'
|
|
20
|
+
|
|
21
|
+
/** the secrets page row the probe proves: a built-in `core` credential, shown
|
|
22
|
+
* in the repo where the `github-login` check has run */
|
|
20
23
|
export const GH_LOGIN_CRED = {
|
|
21
24
|
name: 'gh/github/login',
|
|
22
25
|
service: 'gh login',
|
|
23
26
|
level: 'core' as const,
|
|
24
|
-
probedBy:
|
|
27
|
+
probedBy: GH_LOGIN_CHECK,
|
|
25
28
|
mintUrl: 'https://cli.github.com/manual/gh_auth_login',
|
|
26
29
|
/** what the row offers to copy when no failing line names a better fix */
|
|
27
30
|
command: 'gh auth login',
|
|
@@ -76,48 +79,36 @@ export function readGhText(out: string): GhLogin {
|
|
|
76
79
|
return { state: 'ok', login: login[1]!, scopes: scopesOf(scopes?.[1]) }
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
when: 'health',
|
|
88
|
-
ttl: '1h',
|
|
89
|
-
run: async (c) => {
|
|
90
|
-
const cred = GH_LOGIN_CRED.name
|
|
91
|
-
// the switch that keeps every github step off (git-bootstrap.ts), which every test suite sets
|
|
92
|
-
if ((process.env['SHIPKIT_NO_GITHUB'] ?? '') !== '') return void c.lines.push({ kind: 'skip', text: 'gh login not probed, SHIPKIT_NO_GITHUB is set' })
|
|
93
|
-
const gh = c.tool('gh')
|
|
94
|
-
if (gh == null)
|
|
95
|
-
return c.fail(`cred ${cred} missing: gh is not installed, so shipkit cannot create a repo, push or release`, 'install gh (https://cli.github.com), then run `gh auth login`')
|
|
96
|
-
|
|
97
|
-
let r = await c.exec([gh, 'auth', 'status', '--hostname', HOST, '--active', '--json', 'hosts'], { cwd: c.root, timeoutMs: 20_000 })
|
|
98
|
-
let seen: GhLogin
|
|
99
|
-
if (r.code !== 0 && /unknown flag/i.test(r.out)) {
|
|
100
|
-
r = await c.exec([gh, 'auth', 'status', '--hostname', HOST], { cwd: c.root, timeoutMs: 20_000 })
|
|
101
|
-
seen = readGhText(r.out)
|
|
102
|
-
} else seen = r.timedOut ? { state: 'unreachable', why: 'gh auth status timed out' } : readGhJson(r.out)
|
|
82
|
+
/** the probe: logged in to github.com, with the repo scope */
|
|
83
|
+
export async function probeGhLogin(c: CheckCtx): Promise<void> {
|
|
84
|
+
const cred = GH_LOGIN_CRED.name
|
|
85
|
+
// the switch that keeps every github step off (git-bootstrap.ts), which every test suite sets
|
|
86
|
+
if ((process.env['SHIPKIT_NO_GITHUB'] ?? '') !== '') return void c.lines.push({ kind: 'skip', text: 'gh login not probed, SHIPKIT_NO_GITHUB is set' })
|
|
87
|
+
const gh = c.tool('gh')
|
|
88
|
+
if (gh == null)
|
|
89
|
+
return c.fail(`cred ${cred} missing: gh is not installed, so shipkit cannot create a repo, push or release`, 'install gh (https://cli.github.com), then run `gh auth login`')
|
|
103
90
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return c.unreachable(`gh login unverified: ${HOST} unreachable (${seen.why.slice(0, 120)})`)
|
|
111
|
-
case 'unreadable':
|
|
112
|
-
return c.unreachable(`gh login unverified: gh answered something unreadable (${seen.why})`)
|
|
113
|
-
case 'ok':
|
|
114
|
-
if (seen.scopes == null)
|
|
115
|
-
return c.warn(`gh is logged in to ${HOST} as ${seen.login}, but its token lists no oauth scopes (a fine grained or app token), so creating a private repo is unverified`, 'gh auth login')
|
|
116
|
-
if (!seen.scopes.includes('repo'))
|
|
117
|
-
return c.fail(`cred ${cred} missing: the gh login ${seen.login} lacks the repo scope (it has ${seen.scopes.join(', ')}), so shipkit cannot create a private repo`, `gh auth refresh -h ${HOST} -s repo`)
|
|
118
|
-
return c.ok(`gh is logged in to ${HOST} as ${seen.login}, with the repo scope`)
|
|
119
|
-
}
|
|
120
|
-
},
|
|
121
|
-
})
|
|
91
|
+
let r = await c.exec([gh, 'auth', 'status', '--hostname', HOST, '--active', '--json', 'hosts'], { cwd: c.root, timeoutMs: 20_000 })
|
|
92
|
+
let seen: GhLogin
|
|
93
|
+
if (r.code !== 0 && /unknown flag/i.test(r.out)) {
|
|
94
|
+
r = await c.exec([gh, 'auth', 'status', '--hostname', HOST], { cwd: c.root, timeoutMs: 20_000 })
|
|
95
|
+
seen = readGhText(r.out)
|
|
96
|
+
} else seen = r.timedOut ? { state: 'unreachable', why: 'gh auth status timed out' } : readGhJson(r.out)
|
|
122
97
|
|
|
123
|
-
|
|
98
|
+
switch (seen.state) {
|
|
99
|
+
case 'none':
|
|
100
|
+
return c.fail(`cred ${cred} missing: gh is not logged in to ${HOST}, so shipkit cannot create a repo, push or release`, 'gh auth login')
|
|
101
|
+
case 'refused':
|
|
102
|
+
return c.fail(`cred ${cred} missing: the gh login from ${seen.source} was refused by ${HOST} (${seen.why.slice(0, 120)})`, 'gh auth login')
|
|
103
|
+
case 'unreachable':
|
|
104
|
+
return c.unreachable(`gh login unverified: ${HOST} unreachable (${seen.why.slice(0, 120)})`)
|
|
105
|
+
case 'unreadable':
|
|
106
|
+
return c.unreachable(`gh login unverified: gh answered something unreadable (${seen.why})`)
|
|
107
|
+
case 'ok':
|
|
108
|
+
if (seen.scopes == null)
|
|
109
|
+
return c.warn(`gh is logged in to ${HOST} as ${seen.login}, but its token lists no oauth scopes (a fine grained or app token), so creating a private repo is unverified`, 'gh auth login')
|
|
110
|
+
if (!seen.scopes.includes('repo'))
|
|
111
|
+
return c.fail(`cred ${cred} missing: the gh login ${seen.login} lacks the repo scope (it has ${seen.scopes.join(', ')}), so shipkit cannot create a private repo`, `gh auth refresh -h ${HOST} -s repo`)
|
|
112
|
+
return c.ok(`gh is logged in to ${HOST} as ${seen.login}, with the repo scope`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -116,7 +116,7 @@ const KEY_RE = /(?:^|[{,])\s*(['"]?)identity\1\s*:\s*/gm
|
|
|
116
116
|
|
|
117
117
|
/** per char of the text: the object depth inside `defineRepo({` after that
|
|
118
118
|
* char (1 on the top level keys), or -1 inside a string or a comment */
|
|
119
|
-
function configDepths(text: string): Int32Array {
|
|
119
|
+
export function configDepths(text: string): Int32Array {
|
|
120
120
|
const depth = new Int32Array(text.length).fill(0)
|
|
121
121
|
const open = /defineRepo\(\s*\{/.exec(text)
|
|
122
122
|
if (open == null) return depth
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// what a repo declares about itself and its root unit, read from its config.
|
|
2
|
+
// Its own module, free of any fs import, so a check that asks it stays keyed
|
|
3
|
+
// on what it read.
|
|
4
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
5
|
+
|
|
6
|
+
/** the github visibility this repo declares (absent = 'private') */
|
|
7
|
+
export const declaredVisibility = (c: RepoConfig): 'private' | 'public' => c.visibility ?? 'private'
|
|
8
|
+
|
|
9
|
+
/** declared public: strangers read the tracked tree */
|
|
10
|
+
export const declaredPublic = (c: RepoConfig): boolean => c.visibility === 'public'
|
|
11
|
+
|
|
12
|
+
/** the root unit declares an npm target. Declared, never inferred from a
|
|
13
|
+
* missing `private` flag, so an app that forgot the flag is still an app */
|
|
14
|
+
export const rootPublishesNpm = (c: RepoConfig): boolean => c.release?.npm === true
|
package/src/core/retention.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// NOTHING GROWS UNBOUND: every store shipkit writes declares a cap here (an
|
|
2
2
|
// age, a count, a size), and one sweep enforces all of them. The daemon runs
|
|
3
|
-
// it on boot and hourly for every repo it loads and for the machine;
|
|
4
|
-
//
|
|
5
|
-
// is loud. Each store's sweep lives beside the code that writes it, so a gate
|
|
3
|
+
// it on boot and hourly for every repo it loads and for the machine; the
|
|
4
|
+
// house repo's one `stores-under-cap` check fails when a store is past its
|
|
5
|
+
// cap, so a sweep that stopped running is loud. Each store's sweep lives beside the code that writes it, so a gate
|
|
6
6
|
// on a machine with no daemon still keeps its own stores in bounds.
|
|
7
7
|
//
|
|
8
8
|
// The one constraint a reader breaks first: a sweep never removes what a live
|
|
@@ -45,8 +45,8 @@ export type Store = {
|
|
|
45
45
|
sweep(now: number): void | Promise<void>
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** how far past its cap a store may drift between two sweeps before
|
|
49
|
-
* calls it over: the daemon sweeps hourly, and gates write in between */
|
|
48
|
+
/** how far past its cap a store may drift between two sweeps before the
|
|
49
|
+
* house check calls it over: the daemon sweeps hourly, and gates write in between */
|
|
50
50
|
export const SLACK = { factor: 2, ageMs: 2 * 60 * 60 * 1000 }
|
|
51
51
|
|
|
52
52
|
/** why a store is past its cap (with the drift a sweep interval allows), or
|
|
@@ -59,6 +59,15 @@ export function overCap(use: Use, cap: Cap, now: number, slack = SLACK): string
|
|
|
59
59
|
return null
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** a cap in words: `200 entries, 30d, 5.0 MB a file` */
|
|
63
|
+
export const capWords = (s: Store): string =>
|
|
64
|
+
[
|
|
65
|
+
s.cap.maxCount != null ? `${s.cap.maxCount} entries` : null,
|
|
66
|
+
s.cap.maxAgeMs != null ? fmtAge(s.cap.maxAgeMs) : null,
|
|
67
|
+
s.cap.maxFileBytes != null ? `${fmtBytes(s.cap.maxFileBytes)} a file` : null,
|
|
68
|
+
s.cap.maxBytes != null ? fmtBytes(s.cap.maxBytes) : null,
|
|
69
|
+
].filter((x) => x != null).join(', ')
|
|
70
|
+
|
|
62
71
|
export const fmtBytes = (n: number): string => (n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`)
|
|
63
72
|
export const fmtAge = (ms: number): string => (ms >= 86_400_000 ? `${Math.round(ms / 86_400_000)}d` : ms >= 3_600_000 ? `${Math.round(ms / 3_600_000)}h` : `${Math.round(ms / 60_000)}m`)
|
|
64
73
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// built-in check ids that no longer exist, and where their question went. An
|
|
2
|
+
// id is never reused, so a doc or a changelog may still name one, and
|
|
3
|
+
// discovery refuses a built-in that takes one again.
|
|
4
|
+
export const RETIRED_CHECK_IDS: Readonly<Record<string, string>> = {
|
|
5
|
+
SK021: "the gh login is a machine fact: the house repo's github-login check runs src/core/gh-login.ts",
|
|
6
|
+
SK023: "the store caps are the machine daemon's health: the house repo's stores-under-cap check",
|
|
7
|
+
}
|
package/src/core/run-check.ts
CHANGED
|
@@ -37,6 +37,9 @@ export type CheckOpts = {
|
|
|
37
37
|
status?: boolean
|
|
38
38
|
/** line sink — commands pass their context stream; defaults to console */
|
|
39
39
|
print?: (line: string) => void
|
|
40
|
+
/** where the lines nobody acts on go: the parked list and the green
|
|
41
|
+
* summary. `shipkit ci` shows them only under --verbose. Default: print */
|
|
42
|
+
detail?: (line: string) => void
|
|
40
43
|
/** per-CHECK completion tap: the real status, whether it replayed, what it
|
|
41
44
|
* cost. The demotion of a non-blocking failure happens in the LINES (a
|
|
42
45
|
* FAIL is rewritten to a warn before anything sees it), so `status` here
|
|
@@ -197,10 +200,11 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
|
|
|
197
200
|
bail: o.bail,
|
|
198
201
|
color: o.color,
|
|
199
202
|
print: sink,
|
|
203
|
+
detail: o.detail,
|
|
200
204
|
onCheck: (e) => { collected.push(...e.lines); o.onCheck?.(e) },
|
|
201
205
|
})
|
|
202
206
|
const lines: Line[] = []
|
|
203
|
-
const say = (l: Line): void => { lines.push(l);
|
|
207
|
+
const say = (l: Line, to: (line: string) => void = print): void => { lines.push(l); to(formatLine(l, false)) }
|
|
204
208
|
// a declaration in a shape the check file replaced is reported by check
|
|
205
209
|
// SK016, which also carries the fixer that rewrites it: one owner, one
|
|
206
210
|
// line, one command, rather than a warning per surface.
|
|
@@ -210,7 +214,7 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
|
|
|
210
214
|
// page just replaced
|
|
211
215
|
if (summary.parked.length > 0) {
|
|
212
216
|
const head = summary.parked.slice(0, 8).join(', ')
|
|
213
|
-
say({ kind: 'skip', text: `${summary.parked.length} check(s) not in this run (health only, release only at the quick tier, or a skipped group): ${head}${summary.parked.length > 8 ? `, and ${summary.parked.length - 8} more` : ''}` })
|
|
217
|
+
say({ kind: 'skip', text: `${summary.parked.length} check(s) not in this run (health only, release only at the quick tier, or a skipped group): ${head}${summary.parked.length > 8 ? `, and ${summary.parked.length - 8} more` : ''}` }, o.detail ?? print)
|
|
214
218
|
}
|
|
215
219
|
// the survey dies with the run that made it, and only the run that made it
|
|
216
220
|
if (inherited == null || inherited === '') for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
|
package/src/core/run-ci.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { passScope, runCheckPass, type CheckOpts } from './run-check.ts'
|
|
|
12
12
|
import { poolLines } from './slots.ts'
|
|
13
13
|
import { runFixPass } from './run-fixers.ts'
|
|
14
14
|
import { formatLine } from './wire.ts'
|
|
15
|
+
import { outcomeCounts } from './outcome-words.ts'
|
|
15
16
|
import { writeStatus } from './write-status.ts'
|
|
16
17
|
import { allTasks, composeChecks, envTier, FULL_TESTS_ENV, type TestTier } from '../ledger/gate-checks.ts'
|
|
17
18
|
import { CHECK_TABLE_FILE, writeCheckTable } from '../ledger/check-table.ts'
|
|
@@ -85,6 +86,10 @@ export type CiOpts = {
|
|
|
85
86
|
tests?: TestTier
|
|
86
87
|
/** who started this run, as the run record files it */
|
|
87
88
|
invokedBy?: InvokedBy
|
|
89
|
+
/** print every line, the ones nobody acts on included: the clean fix
|
|
90
|
+
* pass, the pool, the tier, the cache declarations, the parked checks,
|
|
91
|
+
* the cache counts. STATUS.md always holds them all */
|
|
92
|
+
verbose?: boolean
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
export type { TestTier } from '../ledger/gate-checks.ts'
|
|
@@ -107,10 +112,26 @@ function ciScope(o: CiOpts): { command: string; fullGate: boolean } {
|
|
|
107
112
|
return { command: parts.length === 0 ? 'shipkit ci' : `shipkit ci ${parts.join(' ')}`, fullGate: narrowing.length === 0 }
|
|
108
113
|
}
|
|
109
114
|
|
|
115
|
+
/** `ok: 12 checks green`, or which ones warned. A whole gate adds its tier
|
|
116
|
+
* and where its answers came from */
|
|
117
|
+
export function verdictLine(results: CheckResult[], whole: { tier: TestTier; cache: { hit: number; miss: number; attached: number; uncacheable: number } | null } | null): string | null {
|
|
118
|
+
const ran = results.filter((r) => r.status !== 'skip').length
|
|
119
|
+
if (ran === 0) return null
|
|
120
|
+
const warned = results.filter((r) => r.status === 'warn' || (r.status === 'fail' && r.advisory)).map((r) => r.id)
|
|
121
|
+
const head = warned.length === 0 ? `${ran} check${ran === 1 ? '' : 's'} green` : `${ran} check${ran === 1 ? '' : 's'} passed, ${warned.length} with a warning: ${warned.join(', ')}`
|
|
122
|
+
if (whole == null) return `ok: ${head}`
|
|
123
|
+
const c = whole.cache
|
|
124
|
+
const cache = c == null ? '' : `; ${outcomeCounts({ cached: c.hit, ran: c.miss, attached: c.attached, neverCached: c.uncacheable })}`
|
|
125
|
+
return `ok: ${head}; ${whole.tier} tier${cache}`
|
|
126
|
+
}
|
|
127
|
+
|
|
110
128
|
export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
111
129
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
112
130
|
const collected: string[] = []
|
|
113
131
|
const sink = (l: string): void => { collected.push(l); print(l) }
|
|
132
|
+
// what the reader never acts on: kept for STATUS.md and --verbose, never
|
|
133
|
+
// printed on a default run, where every line costs a reader tokens
|
|
134
|
+
const detail = (l: string): void => { collected.push(l); if (o.verbose === true) print(l) }
|
|
114
135
|
let c = o.config ?? (await loadRepoConfig(root))
|
|
115
136
|
// mechanical failures are not information, so they never cost a round trip
|
|
116
137
|
let fixFailed = false
|
|
@@ -123,7 +144,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
123
144
|
// back as a test failure.
|
|
124
145
|
const narrowed = (o.onlyIds ?? []).length > 0
|
|
125
146
|
if (o.fix !== false) {
|
|
126
|
-
const pass = await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink })
|
|
147
|
+
const pass = await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink, detail })
|
|
127
148
|
fixFailed = pass.failed
|
|
128
149
|
// a fixer that rewrote the config or moved a check file leaves the
|
|
129
150
|
// config above describing a repo that is gone, so the gate reads the
|
|
@@ -134,20 +155,22 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
134
155
|
return 1
|
|
135
156
|
}
|
|
136
157
|
}
|
|
137
|
-
} else
|
|
158
|
+
} else detail('skip: fix pass (--no-fix)')
|
|
138
159
|
const tier: TestTier = o.tests ?? envTier()
|
|
139
|
-
// announced BEFORE the run
|
|
140
|
-
// tier it was asking for
|
|
141
|
-
|
|
160
|
+
// announced BEFORE the run (under --verbose, and in every release log),
|
|
161
|
+
// so a log that died mid-way still says which tier it was asking for
|
|
162
|
+
detail(tier === 'full'
|
|
142
163
|
? `ok: test tier FULL — every suite, the slow ones included (${FULL_TESTS_ENV}=1)`
|
|
143
164
|
: `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
|
|
144
|
-
for (const l of poolLines()) sink(l)
|
|
165
|
+
for (const l of poolLines()) (l.startsWith('WARN') ? sink : detail)(l)
|
|
145
166
|
// a floor slot nothing could fill is VISIBLE — a thin gate must never look
|
|
146
167
|
// like a full one (the fresh-repo floor gap)
|
|
147
168
|
const floor = await allTasks(c, root)
|
|
148
|
-
|
|
169
|
+
// a narrowed run says nothing about the slots it never asked about
|
|
170
|
+
const asked = (id: string): boolean => !narrowed || (o.onlyIds ?? []).includes(id)
|
|
171
|
+
for (const n of floor.notes ?? []) (narrowed ? detail : sink)(n)
|
|
149
172
|
for (const u of floor.unfilled) {
|
|
150
|
-
sink(`skip: ${u.id} (${u.reason})`)
|
|
173
|
+
(asked(u.id) ? sink : detail)(`skip: ${u.id} (${u.reason})`)
|
|
151
174
|
// asked for BY NAME and unfillable: this is where an agent lands after
|
|
152
175
|
// the harness refused it the raw runner, so the line says what to add
|
|
153
176
|
// rather than leaving it with `unknown check id` and no next move
|
|
@@ -210,7 +233,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
210
233
|
// visible as a shrinking undeclared one.
|
|
211
234
|
const decided = reg.checks - reg.burnDown.length - reg.declared
|
|
212
235
|
if (reg.burnDown.length > 0 || decided > 0)
|
|
213
|
-
|
|
236
|
+
detail(`skip: cache declarations — ${reg.declared}/${reg.checks} declared, ${decided} never cached on purpose, ${reg.burnDown.length} undeclared, so those run every time (shipkit keys --burn-down)`)
|
|
214
237
|
if (ledger.layer.failure != null)
|
|
215
238
|
sink(`WARN the git scan failed, so NOTHING comes from the cache and nothing is stored this run — ${ledger.layer.failure}`)
|
|
216
239
|
} catch (e) {
|
|
@@ -225,13 +248,14 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
225
248
|
onCheck: o.onCheck,
|
|
226
249
|
reportHome: false,
|
|
227
250
|
print: sink,
|
|
251
|
+
detail,
|
|
228
252
|
})
|
|
229
253
|
// what the suite ACTUALLY ran, from its own headline. A flag that was set
|
|
230
254
|
// proves the intent; the count proves the tests
|
|
231
255
|
// proves the intent; the count proves the tests. Every test check counts,
|
|
232
256
|
// one per unit
|
|
233
257
|
const counts = run.results.map((r) => /Ran (\d+) tests?/.exec(r.lastLine)?.[1]).filter((n) => n != null).map(Number)
|
|
234
|
-
if (counts.length > 0)
|
|
258
|
+
if (counts.length > 0) detail(`ok: test tier ${tier}: ${counts.reduce((a, b) => a + b, 0)} tests ran${counts.length > 1 ? ` in ${counts.length} test checks` : ''}`)
|
|
235
259
|
for (const u of run.undeclared) {
|
|
236
260
|
sink(`FAIL: ${u} does not declare whether it blocks`)
|
|
237
261
|
sink(` fix: add \`blocking: true\` (about this repo) or \`false\` (about the world) to that check`)
|
|
@@ -248,16 +272,23 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
248
272
|
advisoryFatal = true
|
|
249
273
|
sink(`FAIL: ${run.advisoryFailures.length} non-blocking check(s) failing and release.requireHealthy is on: ${run.advisoryFailures.join(', ')}`)
|
|
250
274
|
sink(' fix: shipkit check')
|
|
251
|
-
} else
|
|
275
|
+
} else detail(`WARN ${run.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${run.advisoryFailures.join(', ')}`)
|
|
252
276
|
}
|
|
253
277
|
// the post-run re-hash, then the store. NOTHING was written while the gate
|
|
254
278
|
// ran: a check whose inputs moved under it answered about a tree that no
|
|
255
279
|
// longer exists, and this is where that result is thrown away instead of
|
|
256
280
|
// stored under a key that describes the file as it was before the edit.
|
|
257
|
-
|
|
281
|
+
// the counts line folds into the verdict; what else it says (a store
|
|
282
|
+
// that failed, answers thrown away) is printed as it is
|
|
283
|
+
if (ledger != null) for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
|
|
258
284
|
// one write for the whole gate, after everything has finished
|
|
259
285
|
for (const l of runLog.flush()) sink(formatLine(l, false))
|
|
260
286
|
const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownWhen.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
|
|
287
|
+
// THE VERDICT, one line and only on a pass: a red run already said so in
|
|
288
|
+
// its FAIL lines. The whole gate adds the tier and the cache counts, which
|
|
289
|
+
// a narrowed run asked no question about
|
|
290
|
+
const verdict = code === 0 && !run.interrupted ? verdictLine(run.results, scope.fullGate ? { tier, cache: ledger?.counts ?? null } : null) : null
|
|
291
|
+
if (verdict != null) sink(verdict)
|
|
261
292
|
// motherhome row (docsBase wiki on this disk) — never affects the gate,
|
|
262
293
|
// and never fires under frozen: a bump/release gate must not re-dirty the
|
|
263
294
|
// tree it just proved clean
|
package/src/core/run-fix.ts
CHANGED
|
@@ -17,6 +17,9 @@ export type FixOpts = {
|
|
|
17
17
|
* not wire-shaped, which the frozen output contract forbids */
|
|
18
18
|
quiet?: boolean
|
|
19
19
|
print?: (line: string) => void
|
|
20
|
+
/** where a fix that changed nothing reports, since the run after it says
|
|
21
|
+
* the same about that check. Default: print */
|
|
22
|
+
detail?: (line: string) => void
|
|
20
23
|
/** the config as it stands on disk now, when the caller holds one: a
|
|
21
24
|
* second load of a rewritten repo.config.ts can hit the module cache */
|
|
22
25
|
config?: RepoConfig
|
|
@@ -75,7 +78,8 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
|
75
78
|
// it is an ok, not a warning. It carries a frozen wire prefix like
|
|
76
79
|
// every other line the fix pass emits: `shipkit ci` promises its whole
|
|
77
80
|
// output is parseable, and a bare line breaks that for every reader.
|
|
78
|
-
|
|
81
|
+
const say = f.changed ? print : o.detail ?? print
|
|
82
|
+
say(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `ok: [${rule.id}] fix changed nothing — ${f.summary}`)
|
|
79
83
|
// a fixer may rewrite repo.config.ts, and the re-run below and every
|
|
80
84
|
// fixer after it must read what is on disk now, never the config this
|
|
81
85
|
// pass loaded before the rewrite
|
|
@@ -89,7 +93,8 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
|
89
93
|
await runCheckBody(rule, after)
|
|
90
94
|
// the fix already ran: advertising it again on surviving findings
|
|
91
95
|
// would send an agent in a loop
|
|
92
|
-
|
|
96
|
+
// inside the ci fix pass the run below reports this check's end state
|
|
97
|
+
for (const l of after.lines) (o.quiet === true ? o.detail ?? print : say)(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix \S+$/, '') }, color))
|
|
93
98
|
if (after.failed) exit = 1
|
|
94
99
|
}
|
|
95
100
|
const unfixable = all.filter((r) => r.fix == null).length
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -140,6 +140,9 @@ export type FixPassOpts = {
|
|
|
140
140
|
* mutate the tree they are about to ship) */
|
|
141
141
|
armHooks?: boolean
|
|
142
142
|
print: (line: string) => void
|
|
143
|
+
/** where the lines nobody acts on go (a clean pass, an unfilled slot).
|
|
144
|
+
* Default: print. `shipkit ci` shows them only under --verbose */
|
|
145
|
+
detail?: (line: string) => void
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
export type FixPassResult = {
|
|
@@ -220,12 +223,13 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
|
|
|
220
223
|
if (o.armHooks === true && !verify) {
|
|
221
224
|
const armed = armHooks(root, config)
|
|
222
225
|
if (armed.state === 'armed') o.print(`ok: hooks armed (${armed.detail}), commit the .shipkit/hooks/ dir`)
|
|
223
|
-
else if (armed.state === 'foreign' || armed.state === 'not-root') o.print(`skip: hooks (${armed.detail})`)
|
|
226
|
+
else if (armed.state === 'foreign' || armed.state === 'not-root') (o.detail ?? o.print)(`skip: hooks (${armed.detail})`)
|
|
224
227
|
else if (armed.state === 'failed') o.print(`WARN hooks could not arm: ${armed.detail}`)
|
|
225
228
|
}
|
|
229
|
+
const detail = o.detail ?? o.print
|
|
226
230
|
const { slots, unfilled, disabled } = resolveFixers(root, config)
|
|
227
|
-
for (const u of unfilled)
|
|
228
|
-
for (const d of disabled)
|
|
231
|
+
for (const u of unfilled) detail(`skip: ${u.id} (${u.reason})`)
|
|
232
|
+
for (const d of disabled) detail(`skip: ${d.id} (${d.reason})`)
|
|
229
233
|
const before = treeState(root)
|
|
230
234
|
|
|
231
235
|
for (const s of slots) {
|
|
@@ -236,7 +240,7 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
|
|
|
236
240
|
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`)
|
|
237
241
|
}
|
|
238
242
|
// structural repo-config repair is never implicit, and never in verify mode
|
|
239
|
-
if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print, config })
|
|
243
|
+
if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print, detail: o.detail, config })
|
|
240
244
|
|
|
241
245
|
const after = treeState(root)
|
|
242
246
|
const reshaped = shapeStamp(root) !== shapeBefore
|
|
@@ -257,11 +261,11 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
|
|
|
257
261
|
}
|
|
258
262
|
const list = `${touched.slice(0, 8).join(' ')}${touched.length > 8 ? ` … +${touched.length - 8}` : ''}`
|
|
259
263
|
if (before == null) {
|
|
260
|
-
|
|
264
|
+
detail(`ok: fix pass ran, ${slots.length} fixer(s) (not a git repo — rewritten files cannot be listed)`)
|
|
261
265
|
return { ran: true, touched, failed: false, reshaped }
|
|
262
266
|
}
|
|
263
267
|
if (touched.length === 0) {
|
|
264
|
-
|
|
268
|
+
detail(`ok: ${verify ? 'fixers verified' : 'fix pass'} clean (${slots.length} fixer(s), nothing to rewrite)`)
|
|
265
269
|
return { ran: true, touched, failed: false, reshaped }
|
|
266
270
|
}
|
|
267
271
|
if (verify) {
|
package/src/core/wire.ts
CHANGED
|
@@ -49,6 +49,17 @@ export function parseDuration(d: string | number): number {
|
|
|
49
49
|
return Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3600_000, d: 86400_000 }[m[2] as 'ms' | 's' | 'm' | 'h' | 'd']
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** `ttl: 'forever'`: a DECIDED fact (a repo's visibility, an account that
|
|
53
|
+
* exists). It moves only when someone decides to move it, and that decision
|
|
54
|
+
* is the declared value in its key, so a clean answer never expires */
|
|
55
|
+
export const FOREVER = 'forever'
|
|
56
|
+
|
|
57
|
+
/** what a forever answer keeps for: no clock ends it, no sweep drops it */
|
|
58
|
+
export const FOREVER_MS = Number.MAX_SAFE_INTEGER
|
|
59
|
+
|
|
60
|
+
/** how long a clean answer keeps, from a declared ttl. 0 = none declared */
|
|
61
|
+
export const ttlMsOf = (ttl: string | undefined): number => (ttl == null || ttl === '' ? 0 : ttl === FOREVER ? FOREVER_MS : parseDuration(ttl))
|
|
62
|
+
|
|
52
63
|
/** absolute local stamp (2026-08-05 09:12:34), never a relative age: a
|
|
53
64
|
* relative render changes every run and turns committed STATUS.md
|
|
54
65
|
* checkpoints into noise diffs */
|
package/src/fleet/scan-fleet.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// `shipkit check`, so the fix travels through that one code path.
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
14
14
|
import { join } from 'path'
|
|
15
|
+
import { isArchived } from '../core/archived.ts'
|
|
15
16
|
import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
|
|
16
17
|
import { isBreaking } from '../core/update-policy.ts'
|
|
17
18
|
|
|
@@ -84,7 +85,7 @@ export function scanFleet(root: string, o: { prefix?: string } = {}): FleetRepo[
|
|
|
84
85
|
continue
|
|
85
86
|
}
|
|
86
87
|
const pkg = readPkg(dir)
|
|
87
|
-
if (pkg == null) continue
|
|
88
|
+
if (pkg == null || isArchived(dir)) continue
|
|
88
89
|
const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
|
89
90
|
const names = Object.keys(all).filter((n) => n.startsWith(prefix))
|
|
90
91
|
if (names.length === 0) continue
|