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.
Files changed (40) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +3 -0
  3. package/package.json +1 -1
  4. package/src/cli/cmd_Bump.ts +2 -0
  5. package/src/cli/cmd_Ci.ts +3 -1
  6. package/src/cli/cmd_Pb.ts +27 -0
  7. package/src/cli/cmd_Release.ts +1 -1
  8. package/src/cli/cmd_Wiki.ts +2 -2
  9. package/src/cli.ts +49 -39
  10. package/src/collections/providers.ts +7 -2
  11. package/src/contracts/contracts-once.ts +43 -0
  12. package/src/core/canonical-scripts.ts +33 -0
  13. package/src/core/check-ctx.ts +13 -0
  14. package/src/core/check-suite.ts +121 -53
  15. package/src/core/floor.ts +82 -0
  16. package/src/core/motherhome.ts +14 -1
  17. package/src/core/run-check.ts +34 -0
  18. package/src/core/run-ci.ts +63 -2
  19. package/src/core/run-fixers.ts +6 -1
  20. package/src/ledger/toolchain-identity.ts +134 -0
  21. package/src/pb/collection-scaffold.ts +299 -0
  22. package/src/pb/pb-types.ts +233 -0
  23. package/src/pb/run-pb.ts +157 -0
  24. package/src/release/git-facts.ts +3 -2
  25. package/src/release/release-scope.ts +52 -0
  26. package/src/release/releases-share.ts +85 -0
  27. package/src/release/run-bump.ts +31 -8
  28. package/src/release/run-release.ts +8 -1
  29. package/src/release/run-releases.ts +20 -6
  30. package/src/repo-config.ts +22 -65
  31. package/src/rules/SK001-package-json-shape.ts +65 -2
  32. package/src/rules/SK002-contracts-fulfillness.ts +2 -2
  33. package/src/rules/SK004-gitignore-policy.ts +27 -3
  34. package/src/rules/SK012-config-barrel-import.ts +60 -0
  35. package/src/rules/rule-registry.ts +2 -0
  36. package/src/rules/untrack-generated.ts +117 -0
  37. package/src/scaffold/project-scaffold.ts +1 -1
  38. package/src/serve/hub-port.ts +17 -0
  39. package/src/serve/run-serve.ts +2 -5
  40. 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
+ }
@@ -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
@@ -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
@@ -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 tasks = all.tasks.filter((t) => !skipIds.has(t.id))
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) {
@@ -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
- const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null)
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
+ }