rvlib-shipkit 1.2.0 → 1.4.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 +104 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +4 -2
- package/src/assets/kits/identity-kit.ts +15 -13
- package/src/assets/run-assets.ts +15 -10
- package/src/check.ts +26 -10
- package/src/checks/SK001-package-json-shape.check.ts +34 -5
- 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 +103 -61
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK014-prose-ste.check.ts +16 -6
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
- 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 +50 -0
- package/src/checks/SK028-readme.check.ts +83 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Ci.ts +5 -0
- package/src/cli/cmd_CrossRepoActivity.ts +119 -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/collection-types.ts +15 -0
- package/src/collections/providers.ts +7 -3
- package/src/collections/test-time.ts +10 -1
- package/src/contracts/apply-contracts.ts +9 -8
- package/src/contracts/contract-types.ts +8 -2
- package/src/contracts/contracts-engine.ts +34 -13
- package/src/contracts/manifests/identity.contract.ts +6 -5
- package/src/contracts/manifests/social.contract.ts +4 -2
- package/src/contracts/manifests/steam.contract.ts +7 -3
- package/src/contracts/web-surface.ts +59 -0
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/budget.ts +19 -9
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-runner.ts +66 -17
- package/src/core/discover-checks.ts +10 -5
- 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/layout.ts +2 -0
- package/src/core/motherhome.ts +88 -66
- 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 +8 -7
- package/src/core/run-ci.ts +46 -15
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/wire.ts +49 -9
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +138 -0
- package/src/home/activity.ts +256 -0
- package/src/home/global-units.ts +3 -2
- package/src/home/gui-app/ShipkitGui.swift +144 -25
- package/src/home/gui-app.ts +93 -30
- package/src/home/home-launchd.ts +10 -2
- package/src/home/home-routes.ts +34 -6
- package/src/home/home-start.ts +33 -5
- package/src/home/machine-stores.ts +4 -2
- package/src/home/open-app.ts +23 -10
- package/src/home/pm-config.ts +15 -11
- package/src/home/pm-inputs.ts +76 -0
- package/src/home/pm.ts +40 -7
- package/src/home/process-rows.ts +222 -0
- package/src/home/resources.ts +99 -20
- package/src/home/run-daemon.ts +12 -7
- package/src/home/run-pm.ts +30 -5
- 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/home/widgets.ts +32 -3
- package/src/hooks/commit-message.ts +2 -3
- package/src/hooks/run-hooks.ts +32 -5
- package/src/ledger/db.ts +1 -1
- package/src/ledger/gate-checks.ts +20 -6
- 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 +30 -2
- package/src/ledger/world-cache.ts +63 -14
- 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 +17 -4
- 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/checks-svc.ts +2 -1
- package/src/serve/hub-app/col-tables-view.tsx +124 -0
- package/src/serve/hub-app/col-tables.ts +115 -0
- package/src/serve/hub-app/cols-svc.ts +28 -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 +50 -13
- package/src/serve/hub-app/home-strip.css +81 -23
- package/src/serve/hub-app/home-strip.tsx +243 -102
- package/src/serve/hub-app/home.html +4 -0
- package/src/serve/hub-app/hub-app.tsx +103 -161
- package/src/serve/hub-app/hub-types.ts +6 -3
- package/src/serve/hub-app/hub.css +101 -39
- package/src/serve/hub-app/icons.tsx +27 -3
- package/src/serve/hub-app/index.html +4 -0
- package/src/serve/hub-app/keys.ts +44 -9
- package/src/serve/hub-app/live-svc.ts +24 -64
- package/src/serve/hub-app/machine-st.ts +65 -13
- 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 +13 -9
- 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 +26 -7
- 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 +29 -9
- 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 +34 -13
- 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/serve/warm-cell.ts +49 -16
- package/src/shipkit-files.ts +20 -4
- package/src/suite/judge-suites.ts +42 -17
- package/src/suite/suite-cli.ts +5 -8
- package/src/wiki/lint-md.ts +2 -0
- package/src/wiki/scan-md.ts +37 -10
- package/src/checks/SK023-stores-under-cap.check.ts +0 -49
- package/src/checks/contracts.check.ts +0 -20
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// What a repo serves on the web, read from how it deploys: a GitHub Pages
|
|
2
|
+
// workflow, a wrangler config, or deploy.sh. The web root is where the
|
|
3
|
+
// identity outputs (favicon, logo, OG image) must land to be served, so it
|
|
4
|
+
// comes from the deploy, never a hardcoded `public/`.
|
|
5
|
+
// pure over the disk it is handed: SK002 is cached on what it reads through it
|
|
6
|
+
import type { ScanCtx } from '../core/check-ctx.ts'
|
|
7
|
+
|
|
8
|
+
export type WebDisk = Pick<ScanCtx, 'read' | 'exists' | 'entries'>
|
|
9
|
+
/** `source`: the file that says the repo deploys; `root`: the served dir, null when that file does not name one */
|
|
10
|
+
export type WebDeploy = { source: string; root: string | null }
|
|
11
|
+
|
|
12
|
+
const WORKFLOWS = '.github/workflows'
|
|
13
|
+
const PAGES = /actions\/(?:deploy-pages|upload-pages-artifact)|peaceiris\/actions-gh-pages|cloudflare\/(?:pages|wrangler)-action|wrangler\s+pages\s+deploy/
|
|
14
|
+
const WRANGLER = ['wrangler.toml', 'wrangler.json', 'wrangler.jsonc']
|
|
15
|
+
export const DEFAULT_WEB_ROOT = 'public'
|
|
16
|
+
|
|
17
|
+
const clean = (dir: string): string => dir.replace(/^["']|["']$/g, '').replace(/^\.\//, '').replace(/\/+$/, '') || '.'
|
|
18
|
+
|
|
19
|
+
/** the served dir a deploy file names, null when it names none */
|
|
20
|
+
function rootIn(text: string): string | null {
|
|
21
|
+
const pick = (re: RegExp): string | null => {
|
|
22
|
+
const m = text.match(re)
|
|
23
|
+
return m?.[1] != null ? clean(m[1]) : null
|
|
24
|
+
}
|
|
25
|
+
return (
|
|
26
|
+
pick(/upload-pages-artifact@[^\n]*\n(?:[ \t]+[^\n]*\n)*?[ \t]+path:\s*(\S+)/) ??
|
|
27
|
+
pick(/publish_dir:\s*(\S+)/) ??
|
|
28
|
+
pick(/wrangler\s+pages\s+deploy\s+([^\s-]\S*)/) ??
|
|
29
|
+
pick(/pages_build_output_dir["']?\s*[:=]\s*["']([^"']+)["']/) ??
|
|
30
|
+
pick(/\[assets\][^[]*?directory\s*=\s*["']([^"']+)["']/) ??
|
|
31
|
+
pick(/"assets"\s*:\s*\{[^}]*"directory"\s*:\s*"([^"]+)"/) ??
|
|
32
|
+
pick(/\[site\][^[]*?bucket\s*=\s*["']([^"']+)["']/)
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** every deploy this repo declares, in the order a web root is read from */
|
|
37
|
+
export function webDeploys(d: WebDisk): WebDeploy[] {
|
|
38
|
+
const out: WebDeploy[] = []
|
|
39
|
+
for (const e of d.entries(WORKFLOWS)) {
|
|
40
|
+
if (e.kind !== 'file' || !/\.ya?ml$/.test(e.name)) continue
|
|
41
|
+
const text = d.read(`${WORKFLOWS}/${e.name}`) ?? ''
|
|
42
|
+
if (PAGES.test(text)) out.push({ source: `${WORKFLOWS}/${e.name}`, root: rootIn(text) })
|
|
43
|
+
}
|
|
44
|
+
for (const f of WRANGLER) {
|
|
45
|
+
const text = d.exists(f, 'file') ? d.read(f) : null
|
|
46
|
+
if (text != null) out.push({ source: f, root: rootIn(text) })
|
|
47
|
+
}
|
|
48
|
+
const sh = d.exists('deploy.sh', 'file') ? d.read('deploy.sh') : null
|
|
49
|
+
if (sh != null) out.push({ source: 'deploy.sh', root: rootIn(sh) })
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** where the identity outputs go: the config's `contracts.webRoot`, else the
|
|
54
|
+
* first dir a deploy names, else `public` */
|
|
55
|
+
export function webRootOf(configured: string | undefined, deploys: WebDeploy[]): string {
|
|
56
|
+
if (configured != null) return clean(configured)
|
|
57
|
+
return deploys.find((x) => x.root != null)?.root ?? DEFAULT_WEB_ROOT
|
|
58
|
+
}
|
|
59
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// AGENT SPAWN, found statically: code that starts a Claude or agent process on
|
|
2
|
+
// its own (a spawn or exec of `claude`, `claude -p`, the agent sdk). No tool
|
|
3
|
+
// here starts an agent unless its owner asks in person, so the sweep tests
|
|
4
|
+
// (tests/no-agent-spawn.test.ts, and the house repo's own) fail on any hit.
|
|
5
|
+
//
|
|
6
|
+
// a string that only MENTIONS claude (a url, `~/.claude/projects`, a help
|
|
7
|
+
// line naming the product) is no spawn, and comments are skipped.
|
|
8
|
+
|
|
9
|
+
export type AgentSpawn = { line: number; what: string }
|
|
10
|
+
|
|
11
|
+
const SHAPES: { re: RegExp; what: string }[] = [
|
|
12
|
+
// the sdks that run an agent loop in process or spawn the cli
|
|
13
|
+
{ re: /(['"`])@anthropic-ai\/(?:claude-agent-sdk|claude-code)(?:\/[^'"`]*)?\1/, what: 'imports the agent sdk' },
|
|
14
|
+
// an argv whose command is claude: spawn(['claude', …]), c.exec(['claude'])
|
|
15
|
+
{ re: /\[\s*(['"`])(?:[^'"`\s]*\/)?claude\1/, what: 'an argv that runs claude' },
|
|
16
|
+
// spawn('claude', …), execSync('claude -p …'), execFile('claude')
|
|
17
|
+
{ re: /\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execa|execaSync|fork)\s*\(\s*(['"`])(?:[^'"`\s]*\/)?claude(?:\s[^'"`]*)?\1/, what: 'a process call that runs claude' },
|
|
18
|
+
// the bun shell: $`claude -p …`
|
|
19
|
+
{ re: /\$\s*`\s*(?:[^`\s]*\/)?claude(?:\s|`)/, what: 'a bun shell line that runs claude' },
|
|
20
|
+
// the headless form, wherever the string goes
|
|
21
|
+
{ re: /(['"`])(?:[^'"`\s]*\/)?claude\s+(?:-p|--print)\b/, what: 'a claude -p command line' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/** the first agent spawn in one source text, 1-based line, comments skipped */
|
|
25
|
+
export function agentSpawnIn(src: string): AgentSpawn | null {
|
|
26
|
+
const lines = src.split('\n')
|
|
27
|
+
let inBlock = false
|
|
28
|
+
for (let i = 0; i < lines.length; i++) {
|
|
29
|
+
let l = lines[i]!
|
|
30
|
+
if (inBlock) {
|
|
31
|
+
const end = l.indexOf('*/')
|
|
32
|
+
if (end < 0) continue
|
|
33
|
+
l = l.slice(end + 2)
|
|
34
|
+
inBlock = false
|
|
35
|
+
}
|
|
36
|
+
l = l.replace(/\/\*.*?\*\//g, '')
|
|
37
|
+
const open = l.indexOf('/*')
|
|
38
|
+
if (open >= 0) {
|
|
39
|
+
inBlock = true
|
|
40
|
+
l = l.slice(0, open)
|
|
41
|
+
}
|
|
42
|
+
l = l.replace(/(^|\s)\/\/.*$/, '$1')
|
|
43
|
+
for (const s of SHAPES) if (s.re.test(l)) return { line: i + 1, what: s.what }
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `archived: true` in repo.config.ts: the repo stays known and listed, and
|
|
2
|
+
// nothing runs there (no hub load, so no watch, no plan rows, no gates, no
|
|
3
|
+
// processes, no widgets, no identity fill). Read and written as TEXT, never by
|
|
4
|
+
// loading the config: the daemon and the page rows read many configs per poll,
|
|
5
|
+
// and the toggle moves that one key and no other byte.
|
|
6
|
+
import { readFileSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { atomicWrite } from './locked-write.ts'
|
|
9
|
+
import { configDepths } from './identity-color.ts'
|
|
10
|
+
|
|
11
|
+
const KEY_RE = /(?:^|[{,])\s*(['"]?)archived\1\s*:\s*(true|false)\b/gm
|
|
12
|
+
|
|
13
|
+
/** every top level `archived` key of `defineRepo({…})` with its value and the
|
|
14
|
+
* offsets of the whole `archived: <value>` token */
|
|
15
|
+
function archivedKeys(text: string): { on: boolean; start: number; end: number }[] {
|
|
16
|
+
const depth = configDepths(text)
|
|
17
|
+
const out: { on: boolean; start: number; end: number }[] = []
|
|
18
|
+
for (const m of text.matchAll(KEY_RE)) {
|
|
19
|
+
const lead = /^[{,]/.test(m[0]) ? 1 : 0
|
|
20
|
+
const sep = lead === 1 ? m.index : m.index - 1
|
|
21
|
+
if (sep < 0 || depth[sep] !== 1) continue
|
|
22
|
+
const start = m.index + lead + /^[{,]?(\s*)/.exec(m[0])![1]!.length
|
|
23
|
+
out.push({ on: m[2] === 'true', start, end: m.index + m[0].length })
|
|
24
|
+
}
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** does the config text archive the repo */
|
|
29
|
+
export const archivedOfText = (text: string): boolean => archivedKeys(text).some((k) => k.on)
|
|
30
|
+
|
|
31
|
+
/** the config text with the repo archived or not. On: the key reads true, a
|
|
32
|
+
* missing one lands as the first key of `defineRepo({`. Off: the key goes,
|
|
33
|
+
* so a round trip gives the text back byte for byte */
|
|
34
|
+
export function setArchivedText(text: string, on: boolean): string {
|
|
35
|
+
const keys = archivedKeys(text)
|
|
36
|
+
if (keys.length > 1) throw new Error('repo.config.ts declares `archived` more than once, set it by hand')
|
|
37
|
+
const k = keys[0]
|
|
38
|
+
if (on) {
|
|
39
|
+
if (k != null) return k.on ? text : `${text.slice(0, k.start)}archived: true${text.slice(k.end)}`
|
|
40
|
+
const lines = text.split('\n')
|
|
41
|
+
const open = lines.findIndex((l) => /defineRepo\(\s*\{/.test(l))
|
|
42
|
+
if (open === -1) throw new Error('repo.config.ts has no `defineRepo({` to write `archived` into, set it by hand')
|
|
43
|
+
const line = lines[open]!
|
|
44
|
+
if (/defineRepo\(\s*\{\s*$/.test(line)) {
|
|
45
|
+
const next = lines.slice(open + 1).find((l) => l.trim() !== '' && !/^\s*}/.test(l))
|
|
46
|
+
const pad = next != null ? /^(\s*)/.exec(next)![1]! : `${/^(\s*)/.exec(line)![1]!} `
|
|
47
|
+
return [...lines.slice(0, open + 1), `${pad}archived: true,`, ...lines.slice(open + 1)].join('\n')
|
|
48
|
+
}
|
|
49
|
+
lines[open] = line.replace(/defineRepo\(\s*\{\s*/, (m) => `${m}archived: true, `)
|
|
50
|
+
return lines.join('\n')
|
|
51
|
+
}
|
|
52
|
+
if (k == null) return text
|
|
53
|
+
// a line of its own goes whole, an inline key goes with its comma
|
|
54
|
+
const lineStart = text.lastIndexOf('\n', k.start - 1) + 1
|
|
55
|
+
const lineEnd = text.indexOf('\n', k.end)
|
|
56
|
+
const rest = text.slice(k.end, lineEnd === -1 ? text.length : lineEnd)
|
|
57
|
+
if (text.slice(lineStart, k.start).trim() === '' && /^\s*,?\s*$/.test(rest)) return text.slice(0, lineStart) + text.slice(lineEnd === -1 ? text.length : lineEnd + 1)
|
|
58
|
+
const after = /^\s*,\s*/.exec(text.slice(k.end))
|
|
59
|
+
if (after != null) return text.slice(0, k.start) + text.slice(k.end + after[0].length)
|
|
60
|
+
const before = /,\s*$/.exec(text.slice(0, k.start))
|
|
61
|
+
return before != null ? text.slice(0, k.start - before[0].length) + text.slice(k.end) : text.slice(0, k.start) + text.slice(k.end)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const CONFIG_FILE = 'repo.config.ts'
|
|
65
|
+
|
|
66
|
+
/** is this repo archived; false when it has no readable config */
|
|
67
|
+
export function isArchived(root: string): boolean {
|
|
68
|
+
try {
|
|
69
|
+
return archivedOfText(readFileSync(join(root, CONFIG_FILE), 'utf8'))
|
|
70
|
+
} catch {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** archive the repo or bring it back: only that key of its config moves */
|
|
76
|
+
export function writeArchived(root: string, on: boolean): boolean {
|
|
77
|
+
const path = join(root, CONFIG_FILE)
|
|
78
|
+
const text = readFileSync(path, 'utf8')
|
|
79
|
+
const out = setArchivedText(text, on)
|
|
80
|
+
if (out !== text) atomicWrite(path, out)
|
|
81
|
+
return on
|
|
82
|
+
}
|
package/src/core/budget.ts
CHANGED
|
@@ -11,12 +11,14 @@ import type { MachineLoad } from '../suite/judge-suites.ts'
|
|
|
11
11
|
* 2s at their 90th percentile, and the few past it shell out to a tool */
|
|
12
12
|
export const IN_PROCESS_BUDGET_MS = 2_000
|
|
13
13
|
|
|
14
|
-
/** a subprocess (a build step, a test suite, a `cmd` check) or a world probe
|
|
14
|
+
/** a subprocess (a build step, a test suite, a `cmd` check) or a world probe
|
|
15
|
+
* (a `ttl` check, or a `never:` one, whose answer lives outside the repo).
|
|
15
16
|
* Read off the same record: every typecheck stays under 6s at its 90th
|
|
16
17
|
* percentile, and the quick test suites under 6s but one */
|
|
17
18
|
export const SUBPROCESS_BUDGET_MS = 10_000
|
|
18
19
|
|
|
19
|
-
/** what a check declares about itself that picks its default
|
|
20
|
+
/** what a check declares about itself that picks its default. `world`: it
|
|
21
|
+
* declares `ttl` or `never`, so it reads the world rather than the repo */
|
|
20
22
|
export type BudgetKind = { build: boolean; cmd: boolean; world: boolean }
|
|
21
23
|
|
|
22
24
|
export const defaultBudget = (k: BudgetKind): number => (k.build || k.cmd || k.world ? SUBPROCESS_BUDGET_MS : IN_PROCESS_BUDGET_MS)
|
|
@@ -36,7 +38,15 @@ export const unknownBudgetIds = (ids: readonly string[], budgets: Record<string,
|
|
|
36
38
|
export const budgetValueProblem = (v: unknown): string | null =>
|
|
37
39
|
typeof v === 'number' && Number.isInteger(v) && v > 0 ? null : `must be a whole number of milliseconds above 0, got ${JSON.stringify(v)}`
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
/** `<load1>/<cores>` in this variable pins the reading: a test of a budget
|
|
42
|
+
* verdict must not depend on what else the machine runs */
|
|
43
|
+
export const MACHINE_LOAD_ENV = 'SHIPKIT_MACHINE_LOAD'
|
|
44
|
+
|
|
45
|
+
export const machineLoad = (): MachineLoad => {
|
|
46
|
+
const pinned = process.env[MACHINE_LOAD_ENV]?.match(/^(\d+(?:\.\d+)?)\/(\d+)$/)
|
|
47
|
+
if (pinned != null) return { load1: Number(pinned[1]), cores: Number(pinned[2]) }
|
|
48
|
+
return { load1: loadavg()[0] ?? 0, cores: availableParallelism() }
|
|
49
|
+
}
|
|
40
50
|
|
|
41
51
|
/** `10s` for a whole second count, `150ms` otherwise */
|
|
42
52
|
export const fmtBudget = (ms: number): string => (ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`)
|
|
@@ -49,12 +59,12 @@ export const isBudgetLine = (text: string): boolean => /^\S+ (passed|warned) in
|
|
|
49
59
|
/** a test check sends the reader to the page that breaks its time down */
|
|
50
60
|
const isTestCheck = (id: string): boolean => id === 'test' || id.startsWith('test:')
|
|
51
61
|
|
|
52
|
-
/** the WARN a pass over its budget prints, or null inside it.
|
|
53
|
-
*
|
|
54
|
-
* is the
|
|
62
|
+
/** the WARN a pass over its budget prints, or null inside it. Null too with
|
|
63
|
+
* the load at or past the core count: the budget judges wall time, and on a
|
|
64
|
+
* saturated machine the wall is the machine's, not the check's (a suite's
|
|
65
|
+
* own verdict judges CPU instead, suite/judge-suites.ts) */
|
|
55
66
|
export function budgetLine(id: string, ms: number, budgetMs: number, load: MachineLoad, verb: 'passed' | 'warned' = 'passed'): string | null {
|
|
56
|
-
if (ms <= budgetMs) return null
|
|
57
|
-
const loaded = load.load1 >= load.cores ? ` under load ${load.load1.toFixed(1)} on ${load.cores} cores,` : ''
|
|
67
|
+
if (ms <= budgetMs || load.load1 >= load.cores) return null
|
|
58
68
|
const where = isTestCheck(id) ? 'see the tests page for where the time goes' : 'its page in the hub shows every run'
|
|
59
|
-
return `${id} ${verb} in ${(ms / 1000).toFixed(1)}s
|
|
69
|
+
return `${id} ${verb} in ${(ms / 1000).toFixed(1)}s, over its ${fmtBudget(budgetMs)} budget: ${where}`
|
|
60
70
|
}
|
|
@@ -65,12 +65,21 @@ export function routesToShipkit(body: string): boolean {
|
|
|
65
65
|
return /\bshipkit\b\S*(?:\s+\S+)*?\s+ci(?:\s|$)/.test(body.split(/&&|\|\||;/)[0] ?? body)
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
/** is this path shipkit's own cli: a file named exactly `cli.ts` under a
|
|
69
|
+
* directory whose name says shipkit. A repo's own cli (`src/cli.ts`,
|
|
70
|
+
* `src/cli/comfy-ts-cli.ts`) is not, and taking it for shipkit writes
|
|
71
|
+
* scripts that call a command that cli does not have */
|
|
72
|
+
export function isShipkitCli(path: string): boolean {
|
|
73
|
+
const parts = path.split('/')
|
|
74
|
+
return parts.at(-1) === 'cli.ts' && parts.slice(0, -1).some((d) => /shipkit/.test(d))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// recover the consumption style from existing scripts: any script already
|
|
78
|
+
// running shipkit's cli by path fixes the style for new bodies
|
|
70
79
|
export function inferBin(scripts: Record<string, string>): string | undefined {
|
|
71
80
|
for (const body of Object.values(scripts)) {
|
|
72
81
|
const m = body.match(/^bun (?:--watch )?(\S+cli\.ts) /)
|
|
73
|
-
if (m != null && m[1] !== 'node_modules/rvlib-shipkit/src/cli.ts') return m[1]
|
|
82
|
+
if (m != null && isShipkitCli(m[1]!) && m[1] !== 'node_modules/rvlib-shipkit/src/cli.ts') return m[1]
|
|
74
83
|
}
|
|
75
84
|
return undefined
|
|
76
85
|
}
|
package/src/core/check-runner.ts
CHANGED
|
@@ -39,7 +39,7 @@ import { parallelCap } from './parallel.ts'
|
|
|
39
39
|
import { machinePool, SLOT_HELD_ENV, takeSlot, type SlotPool } from './slots.ts'
|
|
40
40
|
import { budgetLine, machineLoad } from './budget.ts'
|
|
41
41
|
import { killTree, spawnWithTimeout } from './spawn-timeout.ts'
|
|
42
|
-
import { formatLine, parseWireOutput, type Line } from './wire.ts'
|
|
42
|
+
import { FOREVER_MS, formatLine, parseWireOutput, type Line } from './wire.ts'
|
|
43
43
|
import { offLine, type WhenDecl } from './when.ts'
|
|
44
44
|
import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
|
|
45
45
|
import { Ledger, stampCached } from '../ledger/ledger.ts'
|
|
@@ -47,7 +47,7 @@ import { attachedStamp } from './outcome-words.ts'
|
|
|
47
47
|
import type { RunLog, RunStatus } from '../ledger/run-log.ts'
|
|
48
48
|
import { liveStateOf } from '../ledger/live-runs.ts'
|
|
49
49
|
import { CHECK_ID_ENV } from '../ledger/suite-log.ts'
|
|
50
|
-
import { rememberWorld, replayWorld, type WorldCache } from '../ledger/world-cache.ts'
|
|
50
|
+
import { rememberWorld, replayWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
|
|
51
51
|
import { extractProblems } from '../problems/extract.ts'
|
|
52
52
|
import { feedsProblemsPane, type Population, type Problem } from '../problems/problem-line.ts'
|
|
53
53
|
|
|
@@ -56,6 +56,10 @@ import { feedsProblemsPane, type Population, type Problem } from '../problems/pr
|
|
|
56
56
|
* about the world. */
|
|
57
57
|
export const CHECK_TIMEOUT_MS = 120_000
|
|
58
58
|
|
|
59
|
+
/** the most findings a failed build step keeps beside its headline: a tool
|
|
60
|
+
* that prints a wall of them is read in its log */
|
|
61
|
+
const MAX_EXTRA = 20
|
|
62
|
+
|
|
59
63
|
/** epoch ms that never goes backwards inside one process, whole numbers so a
|
|
60
64
|
* row's `at - ms` is its start exactly: the wall clock can step back (an NTP
|
|
61
65
|
* correction), and a check stamped before an earlier one ended makes the
|
|
@@ -191,6 +195,9 @@ export type RunChecksOpts = {
|
|
|
191
195
|
* gate the whole repo, since the check's own gate already ran. */
|
|
192
196
|
exclude?: string[]
|
|
193
197
|
print?: (line: string) => void
|
|
198
|
+
/** where the green summary goes when the caller prints its own verdict
|
|
199
|
+
* (`shipkit ci` does). Default: print */
|
|
200
|
+
detail?: (line: string) => void
|
|
194
201
|
color?: boolean
|
|
195
202
|
onCheck?: (e: CheckEvent) => void
|
|
196
203
|
}
|
|
@@ -227,11 +234,21 @@ function closure(checks: RunnableCheck[], ids: string[]): Set<string> {
|
|
|
227
234
|
return keep
|
|
228
235
|
}
|
|
229
236
|
|
|
237
|
+
/** how many tests a suite ran, read from the headline it prints last
|
|
238
|
+
* (`Ran 920 tests across 12 files.`), null when it printed none */
|
|
239
|
+
export function testsRan(lastLine: string): number | null {
|
|
240
|
+
const m = /Ran (\d+) tests?/.exec(lastLine)
|
|
241
|
+
return m == null ? null : Number(m[1])
|
|
242
|
+
}
|
|
243
|
+
|
|
230
244
|
export type Selection = {
|
|
231
245
|
/** what this run measures */
|
|
232
246
|
selected: RunnableCheck[]
|
|
233
247
|
/** turned off by name, with the reason: never run, always reported */
|
|
234
248
|
off: RunnableCheck[]
|
|
249
|
+
/** off, and outside what `--only` asked about: reported where the caller
|
|
250
|
+
* keeps what nobody acts on */
|
|
251
|
+
offElsewhere?: RunnableCheck[]
|
|
235
252
|
/** not asked about by this run, and why */
|
|
236
253
|
parked: { id: string; text: string }[]
|
|
237
254
|
/** asked for, and it never said whether it blocks */
|
|
@@ -337,6 +354,8 @@ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'ful
|
|
|
337
354
|
if (o.onlyIds != null && o.onlyIds.length > 0) {
|
|
338
355
|
const keep = closure(o.checks, onlyIds)
|
|
339
356
|
selected = selected.filter((u) => keep.has(u.id))
|
|
357
|
+
// a narrowed run says nothing about the checks it never asked about
|
|
358
|
+
return { selected, off: off.filter((u) => keep.has(u.id)), offElsewhere: off.filter((u) => !keep.has(u.id)), parked, undeclared, errors }
|
|
340
359
|
}
|
|
341
360
|
return { selected, off, parked, undeclared, errors }
|
|
342
361
|
}
|
|
@@ -416,6 +435,9 @@ type Live = RunnableCheck & CheckResult & {
|
|
|
416
435
|
/** what the headline carries after the id: a failure's last line, or the
|
|
417
436
|
* reason it never ran */
|
|
418
437
|
tail: string
|
|
438
|
+
/** a failed build step's other findings, the wire lines its tool printed
|
|
439
|
+
* beside the one the headline carries */
|
|
440
|
+
extra: Line[]
|
|
419
441
|
}
|
|
420
442
|
|
|
421
443
|
export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
@@ -439,15 +461,18 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
439
461
|
blocking: u.blocking, advisory: r.advisory, lines: r.lines, problems: r.problems, logPath: r.logPath, lastLine: r.lastLine, note: r.note, attached,
|
|
440
462
|
})
|
|
441
463
|
// an attached check is filed as a replay: it measured nothing, and a
|
|
442
|
-
// row carrying its wait would read as a check running in this process
|
|
443
|
-
|
|
464
|
+
// row carrying its wait would read as a check running in this process.
|
|
465
|
+
// A non-blocking failure is filed as the warning the gate printed, so
|
|
466
|
+
// no surface reading the record paints red what the gate called orange
|
|
467
|
+
const filed = r.status === 'fail' && r.advisory ? 'warn' : r.status
|
|
468
|
+
o.log?.add({ id: u.id, status: filed, ms: r.ms, replayed: r.replayed || attached, lines: r.lines, at })
|
|
444
469
|
}
|
|
445
470
|
|
|
446
471
|
// OFF, in ONE place: skipped by name, never run, never stored, and the
|
|
447
472
|
// reason printed where the verdict would have been
|
|
448
|
-
for (const u of sel.off) {
|
|
473
|
+
for (const u of [...sel.off, ...(sel.offElsewhere ?? [])]) {
|
|
449
474
|
const line: Line = { kind: 'skip', text: offLine(u.id, u.when) }
|
|
450
|
-
emit(line)
|
|
475
|
+
if (sel.off.includes(u)) emit(line); else (o.detail ?? print)(formatLine(line, color))
|
|
451
476
|
report(u, { id: u.id, status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [line], problems: [], lastLine: line.text, note: 'off', logPath: null })
|
|
452
477
|
}
|
|
453
478
|
// `--only` that leaves nothing to run asked a question nobody answers, and
|
|
@@ -461,7 +486,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
461
486
|
const runDir = openRunDir(o.logDir)
|
|
462
487
|
const checks: Live[] = sel.selected.map((u) => ({
|
|
463
488
|
...u,
|
|
464
|
-
status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, synthetic: false, tail: '',
|
|
489
|
+
status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, synthetic: false, tail: '', extra: [],
|
|
465
490
|
logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
|
|
466
491
|
}))
|
|
467
492
|
const selectedIds = new Set(checks.map((u) => u.id))
|
|
@@ -499,10 +524,15 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
499
524
|
* or that a different shipkit measured it. */
|
|
500
525
|
const worldKey = (u: RunnableCheck): string => {
|
|
501
526
|
let code = ''
|
|
502
|
-
try { code = o.ledger?.layer.keyFor(u.id).codeKey ?? '' } catch { code = '' }
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
527
|
+
if (u.ttlMs < FOREVER_MS) try { code = o.ledger?.layer.keyFor(u.id).codeKey ?? '' } catch { code = '' }
|
|
528
|
+
return worldKeyOf({
|
|
529
|
+
ttlMs: u.ttlMs,
|
|
530
|
+
toolchain: u.ttlMs < FOREVER_MS ? o.ledger?.layer.toolchainId ?? '' : '',
|
|
531
|
+
code,
|
|
532
|
+
rotation: u.rotation ?? '',
|
|
533
|
+
// a check that writes answers a different question when frozen
|
|
534
|
+
frozen: u.writes === true ? process.env['SHIPKIT_FROZEN'] === '1' : null,
|
|
535
|
+
})
|
|
506
536
|
}
|
|
507
537
|
|
|
508
538
|
// a non-blocking failure becomes a WARNING here, at the source: printing
|
|
@@ -532,10 +562,18 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
532
562
|
// baked into a remembered line comes back on a replay as if this run
|
|
533
563
|
// had measured it (ledger/ledger.ts rule 5)
|
|
534
564
|
const head = u.lines[0]
|
|
565
|
+
// a suite's count rides on its own line: `test 920 tests (1.4s)`
|
|
566
|
+
const ran = u.status === 'pass' || u.status === 'warn' ? testsRan(u.lastLine) : null
|
|
567
|
+
const counted = ran != null && head != null && head.kind === 'ok' && (head.text === u.id || head.text.startsWith(`${u.id} (`))
|
|
568
|
+
const id = counted ? `${u.id} ${ran} test${ran === 1 ? '' : 's'}` : u.id
|
|
535
569
|
const shown = u.synthetic && !u.replayed && head != null
|
|
536
|
-
? [{ ...head, text: `${
|
|
537
|
-
: u.lines
|
|
538
|
-
|
|
570
|
+
? [{ ...head, text: `${id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
|
|
571
|
+
: counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}` }, ...u.lines.slice(1)] : u.lines
|
|
572
|
+
// a caller printing its own verdict wants one line per green check and
|
|
573
|
+
// only the warnings of a warned one; a failure prints whole
|
|
574
|
+
const quiet = (l: Line, i: number): boolean =>
|
|
575
|
+
o.detail != null && (u.status === 'pass' ? i > 0 : u.status === 'warn' || u.advisory ? l.kind === 'ok' || l.kind === 'note' || l.kind === 'skip' : false)
|
|
576
|
+
for (const [i, l] of shown.entries()) if (quiet(l, i)) o.detail!(formatLine(l, color)); else emit(l)
|
|
539
577
|
if (u.status === 'fail' && !u.advisory && u.logPath != null)
|
|
540
578
|
emit({ kind: 'fix-fail', text: `full log: ${u.logPath}` })
|
|
541
579
|
report(u, { id: u.id, status: u.status, advisory: u.advisory, replayed: u.replayed, ms: u.ms, exitCode: u.exitCode, lines: u.lines, problems: u.problems, lastLine: u.lastLine, note: u.note, logPath: u.logPath }, finishedAt, u.attached)
|
|
@@ -642,14 +680,25 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
642
680
|
u.problems = findings(u, u.lines, r.out)
|
|
643
681
|
const lastFail = u.lines.filter((l) => l.kind === 'fail').at(-1)
|
|
644
682
|
if (lastFail != null) u.lastLine = lastFail.text.slice(0, 200)
|
|
683
|
+
// a tool that speaks the wire (the suite runner) ends on its FAIL
|
|
684
|
+
// lines, and the headline carries only the last one. Only that closing
|
|
685
|
+
// block is its report: a wire line earlier in the log is some test's
|
|
686
|
+
// own output
|
|
687
|
+
if (u.build && u.status === 'fail') {
|
|
688
|
+
const parsed = parseWireOutput(r.out)
|
|
689
|
+
let i = parsed.length
|
|
690
|
+
while (i > 0 && (parsed[i - 1]!.kind === 'fail' || parsed[i - 1]!.kind === 'fix-fail')) i--
|
|
691
|
+
u.extra = parsed.slice(i).filter((l) => !(l.kind === 'fail' && `FAIL: ${l.text}` === u.lastLine)).slice(-MAX_EXTRA)
|
|
692
|
+
}
|
|
645
693
|
}
|
|
646
694
|
|
|
647
695
|
/** a check that printed nothing of its own still has to report that it
|
|
648
696
|
* ran: the verdict, its name, and (at print time) what it cost */
|
|
649
697
|
const headlineOf = (u: Live): void => {
|
|
650
698
|
u.synthetic = true
|
|
651
|
-
|
|
652
|
-
u.
|
|
699
|
+
const more = u.extra.filter((l) => l.kind === 'fail').length
|
|
700
|
+
u.tail = u.status === 'fail' ? (u.lastLine === '' ? '' : ` — ${u.lastLine}${more > 0 ? ` (+${more} more below)` : ''}`) : u.status === 'skip' ? (u.note === '' ? '' : ` (${u.note})`) : ''
|
|
701
|
+
u.lines = [{ kind: u.status === 'fail' ? 'fail' : u.status === 'skip' ? 'skip' : 'ok', text: `${u.id}${u.tail}` }, ...u.extra]
|
|
653
702
|
}
|
|
654
703
|
|
|
655
704
|
/** what an attached asker adopts: the verdict and the lines BEFORE this
|
|
@@ -842,7 +891,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
842
891
|
const warned = results.filter((r) => r.status === 'warn' || (r.status === 'fail' && r.advisory))
|
|
843
892
|
const ran = results.filter((r) => r.status !== 'skip').length
|
|
844
893
|
if (blocked.length === 0 && !interrupted && results.length > 0)
|
|
845
|
-
|
|
894
|
+
(o.detail ?? print)(formatLine({ kind: 'ok', text: warned.length === 0 ? `all ${ran} check(s) green` : `all ${ran} check(s) passed, ${warned.length} with a warning: ${warned.map((r) => r.id).join(', ')}` }, color))
|
|
846
895
|
return {
|
|
847
896
|
ok: blocked.length === 0 && !interrupted,
|
|
848
897
|
interrupted,
|
|
@@ -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'
|
|
@@ -55,20 +56,23 @@ export type DiscoveredCheck = {
|
|
|
55
56
|
from: CheckFrom
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
function walk(dir: string, out: string[], suffix = SUFFIX): void {
|
|
59
|
+
function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[]): void {
|
|
60
|
+
dirs?.push(dir)
|
|
59
61
|
let entries
|
|
60
62
|
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
61
63
|
for (const e of entries) {
|
|
62
64
|
if (e.isDirectory()) {
|
|
63
|
-
if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix)
|
|
65
|
+
if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix, dirs)
|
|
64
66
|
} else if (e.name.endsWith(suffix)) out.push(join(dir, e.name))
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
/** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted
|
|
69
|
-
|
|
70
|
+
/** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted.
|
|
71
|
+
* `dirs` collects every dir the walk read: a file added to one moves its
|
|
72
|
+
* mtime, which is how a reader tells the list moved without walking again */
|
|
73
|
+
export function repoUnitFiles(root: string, dirs?: string[]): string[] {
|
|
70
74
|
const out: string[] = []
|
|
71
|
-
walk(root, out, UNIT_SUFFIX)
|
|
75
|
+
walk(root, out, UNIT_SUFFIX, dirs)
|
|
72
76
|
return out.sort()
|
|
73
77
|
}
|
|
74
78
|
|
|
@@ -176,6 +180,7 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
|
|
|
176
180
|
if (isBuiltin) {
|
|
177
181
|
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
182
|
if ((def.owner ?? '') === '') throw new Error(`${f}: a built-in check names the page that OWNS the invariant`)
|
|
183
|
+
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
184
|
} else if (id.startsWith('SK')) {
|
|
180
185
|
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
186
|
}
|
|
@@ -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
|
+
}
|