rvlib-shipkit 0.31.0 → 0.32.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 +9 -0
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/cli/cmd_Bump.ts +2 -0
- package/src/cli/cmd_Ci.ts +3 -1
- package/src/cli/cmd_Pb.ts +27 -0
- package/src/cli/cmd_Release.ts +1 -1
- package/src/cli/cmd_Wiki.ts +2 -2
- package/src/cli.ts +49 -39
- package/src/collections/providers.ts +7 -2
- package/src/contracts/contracts-once.ts +43 -0
- package/src/core/canonical-scripts.ts +33 -0
- package/src/core/check-ctx.ts +13 -0
- package/src/core/check-suite.ts +121 -53
- package/src/core/floor.ts +82 -0
- package/src/core/motherhome.ts +14 -1
- package/src/core/run-check.ts +34 -0
- package/src/core/run-ci.ts +63 -2
- package/src/core/run-fixers.ts +6 -1
- package/src/ledger/toolchain-identity.ts +134 -0
- package/src/pb/collection-scaffold.ts +299 -0
- package/src/pb/pb-types.ts +233 -0
- package/src/pb/run-pb.ts +157 -0
- package/src/release/git-facts.ts +3 -2
- package/src/release/release-scope.ts +52 -0
- package/src/release/releases-share.ts +85 -0
- package/src/release/run-bump.ts +31 -8
- package/src/release/run-release.ts +8 -1
- package/src/release/run-releases.ts +20 -6
- package/src/repo-config.ts +22 -65
- package/src/rules/SK001-package-json-shape.ts +65 -2
- package/src/rules/SK002-contracts-fulfillness.ts +2 -2
- package/src/rules/SK004-gitignore-policy.ts +27 -3
- package/src/rules/SK012-config-barrel-import.ts +60 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/rules/untrack-generated.ts +117 -0
- package/src/scaffold/project-scaffold.ts +1 -1
- package/src/serve/hub-port.ts +17 -0
- package/src/serve/run-serve.ts +2 -5
- package/src/wiki/run-wiki.ts +18 -8
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// resolveFloor — which task fills each floor slot (lint, typecheck, test,
|
|
2
|
+
// imports:check, cycles) and, when nothing can, the reason it stays empty.
|
|
3
|
+
//
|
|
4
|
+
// Its own module rather than a section of repo-config so a RULE can ask what
|
|
5
|
+
// the gate would actually run: repo-config imports the rule registry, so a
|
|
6
|
+
// rule importing repo-config back is a cycle that dies at module init.
|
|
7
|
+
// RepoConfig arrives as a TYPE only, which is erased.
|
|
8
|
+
import { existsSync, readFileSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { fileURLToPath } from 'url'
|
|
11
|
+
import { rawName, routesToShipkit } from './canonical-scripts.ts'
|
|
12
|
+
import type { TaskDef } from './task-runner.ts'
|
|
13
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
14
|
+
|
|
15
|
+
export const FLOOR_IDS = ['lint', 'typecheck', 'test', 'imports:check', 'cycles'] as const
|
|
16
|
+
|
|
17
|
+
export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)'
|
|
18
|
+
export type FloorResolution = {
|
|
19
|
+
tasks: TaskDef[]
|
|
20
|
+
/** slots disabled by `defaults: { x: false }` — always printed */
|
|
21
|
+
disabled: string[]
|
|
22
|
+
/** slots nothing could fill — always printed, never silent */
|
|
23
|
+
unfilled: { id: string; reason: string }[]
|
|
24
|
+
/** where each registered slot came from (init prints this) */
|
|
25
|
+
sources: Record<string, FloorSource>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// zero-config floor (O6): with no script, imports:check/cycles resolve to
|
|
29
|
+
// shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc. Returns
|
|
30
|
+
// the task, or the reason the slot stays empty.
|
|
31
|
+
function builtinFloorTask(id: string, root: string): { task: TaskDef; source: FloorSource } | string {
|
|
32
|
+
if (id === 'imports:check' || id === 'cycles') {
|
|
33
|
+
let cli: string
|
|
34
|
+
// shipkit's OWN module, resolved from this file: no dependency to be
|
|
35
|
+
// out of date with, and no resolution that can fail in a consumer tree
|
|
36
|
+
cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
|
|
37
|
+
return {
|
|
38
|
+
task: { id, cmd: id === 'cycles' ? ['bun', cli, 'cycles'] : ['bun', cli, 'imports', '--check'] },
|
|
39
|
+
source: 'built-in (shipkit)',
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (id === 'typecheck') {
|
|
43
|
+
if (!existsSync(join(root, 'tsconfig.json'))) return `no 'typecheck' script and no tsconfig.json`
|
|
44
|
+
const local = join(root, 'node_modules/.bin/tsc')
|
|
45
|
+
const tsc = existsSync(local) ? local : Bun.which('tsc')
|
|
46
|
+
if (tsc == null) return `no 'typecheck' script and tsc not found — add typescript or a typecheck script`
|
|
47
|
+
return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
|
|
48
|
+
}
|
|
49
|
+
return `no package.json '${id}' script`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
|
|
53
|
+
const pkgPath = join(root, 'package.json')
|
|
54
|
+
let scripts: Record<string, string> = {}
|
|
55
|
+
if (existsSync(pkgPath)) {
|
|
56
|
+
// unparseable package.json must NOT silently empty the floor — the
|
|
57
|
+
// gate would run only `contracts` and report green
|
|
58
|
+
try { scripts = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {} }
|
|
59
|
+
catch (e) { throw new Error(`package.json unparseable — floor tasks cannot register: ${String(e).slice(0, 120)}`) }
|
|
60
|
+
}
|
|
61
|
+
const tasks: TaskDef[] = []
|
|
62
|
+
const disabled: string[] = []
|
|
63
|
+
const unfilled: { id: string; reason: string }[] = []
|
|
64
|
+
const sources: Record<string, FloorSource> = {}
|
|
65
|
+
for (const id of FLOOR_IDS) {
|
|
66
|
+
const override = c.defaults?.[id]
|
|
67
|
+
if (override === false) { disabled.push(id); continue }
|
|
68
|
+
if (override != null) { tasks.push(override); sources[id] = 'repo.config override'; continue }
|
|
69
|
+
// ONE WAY IN: `<id>` routes to `shipkit ci --only <id>`, so the TOOL
|
|
70
|
+
// body lives at `<id>:raw` and that is what the gate runs. Running the
|
|
71
|
+
// routed script here would spawn the gate from inside itself, forever
|
|
72
|
+
const raw = scripts[rawName(id)]
|
|
73
|
+
if (raw != null) { tasks.push({ id, cmd: ['bun', 'run', rawName(id)] }); sources[id] = 'package.json script'; continue }
|
|
74
|
+
if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push({ id, cmd: ['bun', 'run', id] }); sources[id] = 'package.json script'; continue }
|
|
75
|
+
const b = builtinFloorTask(id, root)
|
|
76
|
+
// a routed script with nothing behind it measures NOTHING, and the
|
|
77
|
+
// reason must say that rather than `no '<id>' script`, which is false
|
|
78
|
+
if (typeof b === 'string') unfilled.push({ id, reason: scripts[id] != null ? `script '${id}' routes to shipkit and there is no '${rawName(id)}' body to run` : b })
|
|
79
|
+
else { tasks.push(b.task); sources[id] = b.source }
|
|
80
|
+
}
|
|
81
|
+
return { tasks, disabled, unfilled, sources }
|
|
82
|
+
}
|
package/src/core/motherhome.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// path no longer exists are pruned on every write. Churn control: a row
|
|
11
11
|
// rewrites only when its DATA changes or its last-seen day is older than
|
|
12
12
|
// 7 days. Never fails the calling command.
|
|
13
|
-
import { existsSync, readFileSync } from 'fs'
|
|
13
|
+
import { existsSync, readFileSync, statSync } from 'fs'
|
|
14
14
|
import { homedir } from 'os'
|
|
15
15
|
import { join, resolve } from 'path'
|
|
16
16
|
import type { RepoConfig } from '../repo-config.ts'
|
|
@@ -46,6 +46,13 @@ function consumptionMode(root: string): string {
|
|
|
46
46
|
} catch { return '?' }
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** a LINKED worktree, where `.git` is a file pointing at the main checkout
|
|
50
|
+
* rather than a directory. Cheap on purpose: this runs on every check pass in
|
|
51
|
+
* every repo, and spawning git to learn it would cost more than the write. */
|
|
52
|
+
function isLinkedWorktree(root: string): boolean {
|
|
53
|
+
try { return statSync(join(root, '.git')).isFile() } catch { return false }
|
|
54
|
+
}
|
|
55
|
+
|
|
49
56
|
export type MotherhomeOutcome = { wrote: boolean; note?: string }
|
|
50
57
|
|
|
51
58
|
export function reportToMotherhome(root: string, config: RepoConfig, today: string = new Date().toISOString().slice(0, 10)): MotherhomeOutcome {
|
|
@@ -57,6 +64,12 @@ export function reportToMotherhome(root: string, config: RepoConfig, today: stri
|
|
|
57
64
|
if (!base.startsWith('/')) return { wrote: false }
|
|
58
65
|
const page = join(base, 'build/shipkit/local-projects.md')
|
|
59
66
|
if (!existsSync(page)) return { wrote: false }
|
|
67
|
+
// a gate run inside a linked worktree must not write the MAIN
|
|
68
|
+
// checkout's tracked page: the row it would add describes a temporary
|
|
69
|
+
// side checkout, and the write lands as unexplained dirt in a tree
|
|
70
|
+
// somebody else is working in. The worktree's own runs are covered
|
|
71
|
+
// when its branch merges.
|
|
72
|
+
if (isLinkedWorktree(root)) return { wrote: false, note: `motherhome: skipped (${config.name} is a linked worktree — its row belongs to the main checkout)` }
|
|
60
73
|
|
|
61
74
|
const mode = consumptionMode(root)
|
|
62
75
|
// source-consumed code is not the published release its version
|
package/src/core/run-check.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { RULES } from '../rules/rule-registry.ts'
|
|
|
15
15
|
import { runRuleLint } from '../rules/rule.ts'
|
|
16
16
|
import { formatLine, parseWireOutput, type Line } from './wire.ts'
|
|
17
17
|
import { writeStatus } from './write-status.ts'
|
|
18
|
+
import { SHARE_ENV } from '../release/releases-share.ts'
|
|
19
|
+
import { rmSync } from 'fs'
|
|
18
20
|
|
|
19
21
|
// orchestrator cache for checks WITHOUT their own suite — short on purpose:
|
|
20
22
|
// this layer only absorbs back-to-back runs, real TTLs live in the checks
|
|
@@ -42,6 +44,21 @@ export type CheckOpts = {
|
|
|
42
44
|
* spends a third-party API call per publish. Undeclared checks are still
|
|
43
45
|
* collected, because an unclassified check stays fatal. */
|
|
44
46
|
onlyBlocking?: boolean
|
|
47
|
+
/** the caller narrowed the run to these ids (`ci --only`). Empty or absent
|
|
48
|
+
* means everything. A `--only` that narrowed TASKS while every check still
|
|
49
|
+
* ran made the flag actively harmful: `ci --only cycles`, a 0.03s task,
|
|
50
|
+
* probed every third-party service the repo knows about. One flag, one
|
|
51
|
+
* meaning, across all three surfaces. */
|
|
52
|
+
only?: string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** does this rule or check answer to one of the `--only` ids? Matched against
|
|
56
|
+
* every name the surface is known by (a rule's id AND its title, a discovered
|
|
57
|
+
* check's name AND its path), because the id an operator types is whichever
|
|
58
|
+
* one they last saw on the wire. */
|
|
59
|
+
export function matchesOnly(only: string[] | undefined, ...names: string[]): boolean {
|
|
60
|
+
if (only == null || only.length === 0) return true
|
|
61
|
+
return only.some((want) => names.some((n) => n.toLowerCase() === want.toLowerCase()))
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
export type CheckRun = {
|
|
@@ -85,26 +102,41 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
85
102
|
t.warned = true
|
|
86
103
|
}
|
|
87
104
|
for (const rule of [...RULES, ...(c.rules ?? [])]) {
|
|
105
|
+
if (!matchesOnly(o.only, rule.id, rule.title)) continue
|
|
88
106
|
if (o.onlyBlocking === true && !rule.blocking) continue
|
|
89
107
|
suite.addCheck({
|
|
108
|
+
blocking: rule.blocking,
|
|
90
109
|
name: rule.title,
|
|
91
110
|
remainValidFor: rule.ttl,
|
|
92
111
|
cacheExtra: rule.cacheKey != null ? () => rule.cacheKey!({ root, config: c }) : undefined,
|
|
93
112
|
logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
|
|
94
113
|
})
|
|
95
114
|
}
|
|
115
|
+
// ONE release survey for the whole run: five discovered checks call
|
|
116
|
+
// collectReports, each in its own subprocess, and the survey is ~4s of
|
|
117
|
+
// network. The path is per RUN and never reused across runs, so nothing
|
|
118
|
+
// here can serve a stale answer to a later gate (releases-share.ts).
|
|
119
|
+
// a caller that already opened one (runCi, which starts the survey next to
|
|
120
|
+
// the tasks) owns it, cleanup included; otherwise this run owns its own
|
|
121
|
+
const inherited = process.env[SHARE_ENV]
|
|
122
|
+
const sharePath = inherited != null && inherited !== '' ? inherited : join(root, 'tmp/shipkit-ci', `releases-${process.pid}-${Date.now()}.json`)
|
|
96
123
|
const childEnv: Record<string, string | undefined> = {
|
|
97
124
|
...process.env,
|
|
125
|
+
[SHARE_ENV]: sharePath,
|
|
98
126
|
...(o.fresh === true ? { SHIPKIT_FRESH: '1', RV_CHECK_FRESH: '1' } : {}),
|
|
99
127
|
}
|
|
100
128
|
for (const d of discoverChecks(root)) {
|
|
101
129
|
const rel = relative(root, d.path)
|
|
102
130
|
// file wins, then the repo default, then nothing: an unclassified
|
|
103
131
|
// check is an error, never a guess
|
|
132
|
+
if (!matchesOnly(o.only, d.name, rel)) continue
|
|
104
133
|
const blocking = d.blocking ?? c.checkDefaults?.blocking
|
|
105
134
|
if (blocking == null) undeclared.push(rel)
|
|
106
135
|
if (o.onlyBlocking === true && blocking !== true) continue
|
|
107
136
|
suite.addCheck({
|
|
137
|
+
// a check about the WORLD may remember a measured negative; one
|
|
138
|
+
// about this repo never does (check-suite.ts, the rule)
|
|
139
|
+
blocking: blocking ?? true,
|
|
108
140
|
name: d.name,
|
|
109
141
|
remainValidFor: d.selfCached ? undefined : DISCOVERED_TTL,
|
|
110
142
|
logic: async (t) => {
|
|
@@ -121,6 +153,8 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
121
153
|
})
|
|
122
154
|
}
|
|
123
155
|
const r = await suite.execute()
|
|
156
|
+
// the survey dies with the run that made it, and only the run that made it
|
|
157
|
+
if (inherited == null || inherited === '') for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
|
|
124
158
|
if (prevFresh == null) delete process.env.SHIPKIT_FRESH; else process.env.SHIPKIT_FRESH = prevFresh
|
|
125
159
|
if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
|
|
126
160
|
// frozen = a release-shaped run: reporting would re-dirty the tree the
|
package/src/core/run-ci.ts
CHANGED
|
@@ -13,6 +13,11 @@ import { runFixPass } from './run-fixers.ts'
|
|
|
13
13
|
import { formatLine } from './wire.ts'
|
|
14
14
|
import { runTasks, type TaskResult } from './task-runner.ts'
|
|
15
15
|
import { writeStatus } from './write-status.ts'
|
|
16
|
+
import { rmSync } from 'fs'
|
|
17
|
+
|
|
18
|
+
import { SHARE_ENV } from '../release/releases-share.ts'
|
|
19
|
+
import { collectReports } from '../release/run-releases.ts'
|
|
20
|
+
import { withContractsScope } from '../contracts/contracts-once.ts'
|
|
16
21
|
|
|
17
22
|
export type CiOpts = {
|
|
18
23
|
bail?: boolean
|
|
@@ -54,8 +59,22 @@ export type CiOpts = {
|
|
|
54
59
|
* loads of repo.config.ts can disagree, and the release path classifies
|
|
55
60
|
* rule-vs-probe severity from the config it holds. */
|
|
56
61
|
config?: RepoConfig
|
|
62
|
+
/** which test tier the run asks the repo's suite for. 'quick' is the
|
|
63
|
+
* working default; 'full' sets SHIPKIT_TEST_FULL=1 for every task, which
|
|
64
|
+
* a suite reads to include the slow, real-world tiers it otherwise skips.
|
|
65
|
+
* The release path always asks for 'full': a version published behind a
|
|
66
|
+
* partial suite is a green nobody measured. Default reads the env var, so
|
|
67
|
+
* `SHIPKIT_TEST_FULL=1 shipkit ci` works with no flag. */
|
|
68
|
+
tests?: TestTier
|
|
57
69
|
}
|
|
58
70
|
|
|
71
|
+
export type TestTier = 'quick' | 'full'
|
|
72
|
+
|
|
73
|
+
/** the env var a repo's own suite reads to widen itself. Named here because
|
|
74
|
+
* the gate is what sets it, and a suite that invents its own name is a tier
|
|
75
|
+
* the release path cannot reach. */
|
|
76
|
+
export const FULL_TESTS_ENV = 'SHIPKIT_TEST_FULL'
|
|
77
|
+
|
|
59
78
|
/** what STATUS.md may claim about this run. `--with` ADDS a task, so it stays
|
|
60
79
|
* the full gate; `--only`, `--skip` and skipChecks each remove coverage, and
|
|
61
80
|
* a narrowed run that stamps the full gate's verdict is a green nobody
|
|
@@ -71,6 +90,12 @@ function ciScope(o: CiOpts): { command: string; fullGate: boolean } {
|
|
|
71
90
|
}
|
|
72
91
|
|
|
73
92
|
export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
93
|
+
// ONE gate run: the `contracts` task evaluates, rule SK002 reads what it
|
|
94
|
+
// computed instead of evaluating a second time (contracts-once.ts)
|
|
95
|
+
return await withContractsScope(() => runCiInScope(root, o))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
74
99
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
75
100
|
const collected: string[] = []
|
|
76
101
|
const sink = (l: string): void => { collected.push(l); print(l) }
|
|
@@ -83,12 +108,36 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
83
108
|
const all = allTasks(c, root)
|
|
84
109
|
const { disabled, unfilled } = all
|
|
85
110
|
const skipIds = new Set(o.skipTaskIds ?? [])
|
|
86
|
-
const
|
|
111
|
+
const tier: TestTier = o.tests ?? (process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick')
|
|
112
|
+
const tasks = all.tasks
|
|
113
|
+
.filter((t) => !skipIds.has(t.id))
|
|
114
|
+
// the whole run carries the flag rather than one task id: a repo names
|
|
115
|
+
// its suites what it likes (`test`, `test:libs`, a prepublish script),
|
|
116
|
+
// and a gate that guesses which one is the tests will miss one
|
|
117
|
+
.map((t) => (tier === 'full' ? { ...t, env: { ...t.env, [FULL_TESTS_ENV]: '1' } } : t))
|
|
118
|
+
// announced BEFORE the run, so a log that died mid-way still says which
|
|
119
|
+
// tier it was asking for
|
|
120
|
+
sink(tier === 'full'
|
|
121
|
+
? `ok: test tier FULL — every suite, the slow ones included (${FULL_TESTS_ENV}=1)`
|
|
122
|
+
: `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
|
|
87
123
|
for (const t of all.tasks) if (skipIds.has(t.id)) sink(`skip: ${t.id} (not part of this release, the unit's own gate covers it)`)
|
|
88
124
|
for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
|
|
89
125
|
// a floor slot nothing could fill is VISIBLE — a thin gate must never
|
|
90
126
|
// look like a full one (the fresh-repo floor gap)
|
|
91
127
|
for (const u of unfilled) sink(`skip: ${u.id} (${u.reason})`)
|
|
128
|
+
// the release survey is ~4s of network that five discovered checks need
|
|
129
|
+
// and the TASKS do not. Started HERE it overlaps lint/typecheck/test and
|
|
130
|
+
// costs the run nothing; started inside the first check that wants it, it
|
|
131
|
+
// is 4s nobody can overlap. Failures are the checks' to report, not this
|
|
132
|
+
// line's: a survey nobody can run just means the checks do it themselves.
|
|
133
|
+
const sharePath = join(root, 'tmp/shipkit-ci', `releases-${process.pid}-${Date.now()}.json`)
|
|
134
|
+
const priorShare = process.env[SHARE_ENV]
|
|
135
|
+
process.env[SHARE_ENV] = sharePath
|
|
136
|
+
// a NARROWED run pre-warms nothing: `--only cycles` asks one question, and
|
|
137
|
+
// paying a 4s network survey to answer it is the same bug --only was
|
|
138
|
+
// fixed for
|
|
139
|
+
const willRunChecks = o.skipChecks !== true && (o.onlyIds ?? []).length === 0
|
|
140
|
+
const survey = willRunChecks ? collectReports(root, c).catch(() => null) : null
|
|
92
141
|
const summary = await runTasks({
|
|
93
142
|
tasks,
|
|
94
143
|
logDir: c.logDir ?? join(root, 'tmp/shipkit-ci'),
|
|
@@ -100,11 +149,23 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
100
149
|
print: sink,
|
|
101
150
|
onTaskFinish: o.onTaskFinish,
|
|
102
151
|
})
|
|
152
|
+
// what the suite ACTUALLY ran, from its own summary line. A flag that was
|
|
153
|
+
// set proves the intent; the count proves the tests
|
|
154
|
+
const ranTests = summary.results
|
|
155
|
+
.map((r) => /Ran (\d+) tests?/.exec(r.lastLine ?? '')?.[1])
|
|
156
|
+
.find((n) => n != null)
|
|
157
|
+
if (ranTests != null) sink(`ok: test tier ${tier}: ${ranTests} tests ran`)
|
|
103
158
|
// the checks, in the SAME command. there is no second surface: a check
|
|
104
159
|
// declares whether it blocks, and that is the only question about it.
|
|
160
|
+
// a narrowed run says what it did NOT look at: a green that only ever
|
|
161
|
+
// asked one question must not read like the full gate
|
|
162
|
+
if ((o.onlyIds ?? []).length > 0 && o.skipChecks !== true) sink(`skip: checks not named by --only (${(o.onlyIds ?? []).join(', ')})`)
|
|
163
|
+
if (survey != null) await survey
|
|
105
164
|
const checks = o.skipChecks === true
|
|
106
165
|
? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
|
|
107
|
-
: await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks })
|
|
166
|
+
: await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds })
|
|
167
|
+
if (priorShare == null) delete process.env[SHARE_ENV]; else process.env[SHARE_ENV] = priorShare
|
|
168
|
+
for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
|
|
108
169
|
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
109
170
|
for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
|
|
110
171
|
for (const u of checks.undeclared) {
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { fileURLToPath } from 'url'
|
|
|
14
14
|
import { armHooks } from '../hooks/run-hooks.ts'
|
|
15
15
|
import type { RepoConfig } from '../repo-config.ts'
|
|
16
16
|
import { runFix } from '../rules/run-fix.ts'
|
|
17
|
+
import { routesToShipkit } from './canonical-scripts.ts'
|
|
17
18
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
18
19
|
|
|
19
20
|
const FIXER_TIMEOUT_MS = 120_000
|
|
@@ -60,8 +61,12 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
|
|
|
60
61
|
return true
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
// `fmt` is the one slot the fix pass invokes BY SCRIPT, so it is the one
|
|
65
|
+
// name ONE WAY IN deliberately leaves raw: a routed body here would make
|
|
66
|
+
// the fixer spawn the gate that spawns the fixer
|
|
67
|
+
const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null && !routesToShipkit(scripts[n]!))
|
|
64
68
|
if (fmtName != null) slots.push({ id: 'fmt', cmd: ['bun', 'run', fmtName], source: 'package.json script' })
|
|
69
|
+
else if (FMT_SCRIPTS.some((n) => scripts[n] != null)) unfilled.push({ id: 'fmt', reason: `the 'fmt' script routes back to shipkit — the fix pass runs it, so it holds the formatter itself` })
|
|
65
70
|
else unfilled.push({ id: 'fmt', reason: `no 'fmt' or 'format' script — formatting stays a manual step` })
|
|
66
71
|
|
|
67
72
|
if (offByConfig('imports:fix')) { /* the repo said no, in the one place that governs both directions */ }
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// WHAT PROGRAM IS ABOUT TO RUN, as one hash.
|
|
2
|
+
//
|
|
3
|
+
// A cache key built from the repo's git tree answers "what source is being
|
|
4
|
+
// gated". It cannot answer "what is doing the gating", and those two already
|
|
5
|
+
// have different answers inside a single checkout. Three facts, each measured
|
|
6
|
+
// rather than argued:
|
|
7
|
+
//
|
|
8
|
+
// 1. `node_modules/` is gitignored, so no git-derived key can ever see what
|
|
9
|
+
// is installed. `bun.lock` is tracked, which is not the same thing.
|
|
10
|
+
// 2. Byte-identical `bun.lock`, different installed program: a repo and a
|
|
11
|
+
// linked worktree of it were found carrying different top-level packages,
|
|
12
|
+
// one of them a runtime dependency of a package both declared. A lockfile
|
|
13
|
+
// hash calls those the same program.
|
|
14
|
+
// 3. The same lockfile can hold a different VERSION on disk, and the cheap
|
|
15
|
+
// proxies miss it: two releases of one package were found with
|
|
16
|
+
// package.json files of exactly the same byte length, so only the parsed
|
|
17
|
+
// `version` separates them.
|
|
18
|
+
//
|
|
19
|
+
// So the key has three parts: the runtime, the gate binary, and what is
|
|
20
|
+
// actually installed.
|
|
21
|
+
//
|
|
22
|
+
// THE ONE DELIBERATE MISS: a human hand-editing a file inside `node_modules`
|
|
23
|
+
// is accepted, not caught. Catching it means content-hashing the whole tree,
|
|
24
|
+
// measured at 0.9s on a 6,700-file tree and 5.3s on a 33,000-file one, against
|
|
25
|
+
// ~2ms and ~18ms for this key. That is 350x to 470x, which would make the
|
|
26
|
+
// cache slower than the gate it caches. No tool produces that state; a human
|
|
27
|
+
// debugging on purpose can delete the ledger.
|
|
28
|
+
import { createHash } from 'crypto'
|
|
29
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'fs'
|
|
30
|
+
import { isAbsolute, join, relative, resolve } from 'path'
|
|
31
|
+
|
|
32
|
+
export type Toolchain = {
|
|
33
|
+
/** the identity, 16 hex chars, stable for identical programs */
|
|
34
|
+
id: string
|
|
35
|
+
/** non-empty means DO NOT CACHE this run: something in the toolchain is
|
|
36
|
+
* neither in the repo's git tree nor pinned by a version string, so no
|
|
37
|
+
* key can describe it. Each entry says which package and why. */
|
|
38
|
+
uncacheable: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** does this path live inside the repo being gated? */
|
|
42
|
+
const inRepo = (repoRoot: string, p: string): boolean => {
|
|
43
|
+
const r = relative(repoRoot, p)
|
|
44
|
+
return r !== '' && !r.startsWith('..') && !isAbsolute(r)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** every top-level package name, scopes expanded, sorted */
|
|
48
|
+
function installedNames(nm: string): string[] {
|
|
49
|
+
const names: string[] = []
|
|
50
|
+
for (const e of readdirSync(nm, { withFileTypes: true })) {
|
|
51
|
+
if (e.name.startsWith('.')) continue
|
|
52
|
+
if (e.name.startsWith('@')) {
|
|
53
|
+
for (const n of readdirSync(join(nm, e.name))) if (!n.startsWith('.')) names.push(`${e.name}/${n}`)
|
|
54
|
+
} else names.push(e.name)
|
|
55
|
+
}
|
|
56
|
+
return names.sort()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** the gate binary's own source, content-hashed. Only reached when the cli
|
|
60
|
+
* lives OUTSIDE the repo, where the git tree key cannot see it. */
|
|
61
|
+
function hashCliTree(h: ReturnType<typeof createHash>, cli: string): void {
|
|
62
|
+
const srcRoot = resolve(cli, '..')
|
|
63
|
+
const files: string[] = []
|
|
64
|
+
const walk = (d: string): void => {
|
|
65
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
66
|
+
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
|
|
67
|
+
const p = join(d, e.name)
|
|
68
|
+
if (e.isDirectory()) walk(p)
|
|
69
|
+
else files.push(p)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
walk(srcRoot)
|
|
73
|
+
files.sort()
|
|
74
|
+
for (const f of files) h.update(readFileSync(f))
|
|
75
|
+
h.update(`clifiles=${files.length}\n`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* the identity of the program about to run, for `repoRoot`, driven by `cliEntry`.
|
|
80
|
+
*
|
|
81
|
+
* Deterministic: identical trees produce identical ids, and a `touch` that
|
|
82
|
+
* moves an mtime without changing content produces no miss.
|
|
83
|
+
*/
|
|
84
|
+
export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain {
|
|
85
|
+
const h = createHash('sha256')
|
|
86
|
+
const uncacheable: string[] = []
|
|
87
|
+
// BOTH sides resolved, or the comparison is wrong on macOS: /var is a
|
|
88
|
+
// symlink to /private/var, so an in-repo cli under a resolved path reads
|
|
89
|
+
// as outside an unresolved root. That mistake is silent and expensive: it
|
|
90
|
+
// content-hashes the gate binary on every single run, and folds absolute
|
|
91
|
+
// paths into the id, which is what would stop two worktrees agreeing.
|
|
92
|
+
const root = ((): string => { try { return realpathSync(repoRoot) } catch { return resolve(repoRoot) } })()
|
|
93
|
+
|
|
94
|
+
// the runtime. Free, and a bun upgrade genuinely changes behaviour
|
|
95
|
+
h.update(`bun=${Bun.version}\n`)
|
|
96
|
+
|
|
97
|
+
// the gate binary. In-repo, the git tree key already covers every byte of
|
|
98
|
+
// it, so the literal is enough and the walk is skipped
|
|
99
|
+
const cli = realpathSync(cliEntry)
|
|
100
|
+
const cliInRepo = inRepo(root, cli)
|
|
101
|
+
h.update(`cli=${cliInRepo ? 'in-repo' : cli}\n`)
|
|
102
|
+
if (!cliInRepo) hashCliTree(h, cli)
|
|
103
|
+
|
|
104
|
+
// what is installed. The lockfile states the intent; the tree states the fact
|
|
105
|
+
const lock = join(root, 'bun.lock')
|
|
106
|
+
h.update(`lock=${existsSync(lock) ? createHash('sha256').update(readFileSync(lock)).digest('hex') : 'none'}\n`)
|
|
107
|
+
const nm = join(root, 'node_modules')
|
|
108
|
+
if (!existsSync(nm)) {
|
|
109
|
+
// a worktree nobody installed is a REAL difference, not a missing input
|
|
110
|
+
h.update('nm=absent\n')
|
|
111
|
+
return { id: h.digest('hex').slice(0, 16), uncacheable }
|
|
112
|
+
}
|
|
113
|
+
const names = installedNames(nm)
|
|
114
|
+
for (const n of names) {
|
|
115
|
+
const dir = join(nm, n)
|
|
116
|
+
let link = ''
|
|
117
|
+
try {
|
|
118
|
+
if (lstatSync(dir).isSymbolicLink()) {
|
|
119
|
+
const target = realpathSync(dir)
|
|
120
|
+
link = `->${target}`
|
|
121
|
+
// a `bun link`ed package is live source under someone else's
|
|
122
|
+
// edit: its content is in neither this repo's git tree nor its
|
|
123
|
+
// version string, so nothing here can describe it
|
|
124
|
+
if (!inRepo(root, target)) uncacheable.push(`${n} links outside the repo (${target})`)
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
let version = 'MISSING'
|
|
128
|
+
try { version = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { version?: string }).version ?? '?' } catch {}
|
|
129
|
+
h.update(`${n}@${version}${link}\n`)
|
|
130
|
+
}
|
|
131
|
+
// the count catches a removal that leaves every survivor identical
|
|
132
|
+
h.update(`n=${names.length}\n`)
|
|
133
|
+
return { id: h.digest('hex').slice(0, 16), uncacheable }
|
|
134
|
+
}
|