rvlib-shipkit 0.38.0 → 1.0.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 (252) hide show
  1. package/CHANGELOG.md +371 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +216 -18
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,264 @@
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
+ // Beside the slots it derives two checks per declared sub-unit,
4
+ // `test:<unit id>` (suite/test-units.ts owns which file each one runs) and,
5
+ // when the unit has a tsconfig of its own, `typecheck:<unit id>`.
6
+ //
7
+ // Its own module rather than a section of repo-config so a RULE can ask what
8
+ // the gate would actually run: repo-config imports the rule registry, so a
9
+ // rule importing repo-config back is a cycle that dies at module init.
10
+ // RepoConfig arrives as a TYPE only, which is erased.
11
+ //
12
+ // No disk access of its own: every read goes through the FloorReader it is
13
+ // handed. core/floor.ts hands it plain fs, a check hands it the check context
14
+ // (checkFloorReader), so SK001 and SK017, which ask how the floor resolves,
15
+ // are keyed on exactly the files it read.
16
+ import { join, posix } from 'path'
17
+ import { fileURLToPath } from 'url'
18
+ import { rawName, slotBody } from './canonical-scripts.ts'
19
+ import type { CheckCtx } from './check-ctx.ts'
20
+ import { checkUnitReader, type UnitReader } from '../release/unit-resolve.ts'
21
+ import { parseJsonc } from '../ts/jsonc.ts'
22
+ import { resolveTypecheckProjects } from '../ts/tsconfig-resolve.ts'
23
+ import { parseBunTestScript, suiteFlags } from '../suite/judge-suites.ts'
24
+ import { declaredTestUnits, siblingUnits, testCheckId, unitIgnores, unitTestBody, unitTsconfig, type TestUnitDecl } from '../suite/test-units.ts'
25
+ import { describeFirst, type TaskDef } from '../check.ts'
26
+ import type { RepoConfig } from '../repo-config.ts'
27
+
28
+ /** how the floor reads the repo, by repo-relative path, plus the one lookup
29
+ * outside it: a binary on PATH */
30
+ export type FloorReader = UnitReader & { which: (bin: string) => string | null }
31
+
32
+ /** the check context as a floor reader. A PATH lookup is not a repo read, so
33
+ * it marks the answer untracked, and only a repo with no local tsc and no
34
+ * typecheck script ever asks it */
35
+ export function checkFloorReader(c: CheckCtx): FloorReader {
36
+ return {
37
+ ...checkUnitReader(c),
38
+ which: (bin) => {
39
+ c.untrack(`looked ${bin} up on PATH, outside the repo`)
40
+ return Bun.which(bin)
41
+ },
42
+ }
43
+ }
44
+
45
+ export const FLOOR_IDS = ['lint', 'typecheck', 'test', 'imports:check', 'cycles'] as const
46
+
47
+ /** what a floor slot asserts, whatever fills it: the row's description on
48
+ * every surface. With units, the root `test` runs only the files
49
+ * outside them, since each unit has its own `test:<unit>` */
50
+ export function slotDescription(id: string, withUnits: boolean): string {
51
+ if (id === 'lint') return 'the linter reports no problem in the repo'
52
+ if (id === 'typecheck') return 'tsc finds no type error in the repo'
53
+ if (id === 'test') return withUnits ? 'every test file outside the units passes' : 'every test file in the repo passes'
54
+ if (id === 'imports:check') return 'every local import is absolute, carries its extension and resolves'
55
+ if (id === 'cycles') return 'no import cycle between modules'
56
+ throw new Error(`no description for floor slot '${id}'`)
57
+ }
58
+
59
+ /** what a unit's own test check asserts */
60
+ export const unitTestDescription = (u: { id: string; rel: string }): string =>
61
+ describeFirst(`every test file under ${u.rel} passes`, `every test file of unit ${u.id} passes`)
62
+
63
+ /** what a unit's own typecheck asserts */
64
+ export const unitTypecheckDescription = (u: { id: string; rel: string }): string =>
65
+ describeFirst(`tsc finds no type error under ${u.rel}`, `tsc finds no type error in unit ${u.id}`)
66
+
67
+ export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)' | 'unit'
68
+ export type FloorResolution = {
69
+ tasks: TaskDef[]
70
+ /** slots disabled by `defaults: { x: false }` — always printed */
71
+ disabled: string[]
72
+ /** slots nothing could fill — always printed, never silent */
73
+ unfilled: { id: string; reason: string }[]
74
+ /** where each registered slot came from (init prints this) */
75
+ sources: Record<string, FloorSource>
76
+ /** skip lines about how a slot runs, printed by the gate */
77
+ notes?: string[]
78
+ }
79
+
80
+ /** a package.json body for a slot. A plain `bun test` in the test slot runs
81
+ * through the suite runner (parallel, timed against the budget), anything
82
+ * else runs as written and the gate says what that costs */
83
+ function scriptCmd(c: RepoConfig, id: string, name: string, body: string, notes: string[], subs: TestUnitDecl[]): string[] {
84
+ if (id !== 'test') return ['bun', 'run', name]
85
+ const args = parseBunTestScript(body)
86
+ if (args == null) {
87
+ notes.push(`skip: suite runner (script '${name}' is not a plain \`bun test\`, so its suites run as written: not in parallel, not timed against a budget${subs.length > 0 ? ', and the units\' own suites may run in it too' : ''})`)
88
+ return ['bun', 'run', name]
89
+ }
90
+ // each sub-unit's suites belong to its own check, run in its own dir
91
+ const unitArgs = subs.length === 0 ? [] : ['--unit', 'root']
92
+ return ['bun', SUITE_CLI, ...suiteFlags(c.tests), ...unitArgs, '--', ...args, ...unitIgnores(subs).map((g) => `--path-ignore-patterns=${g}`)]
93
+ }
94
+
95
+ const SUITE_CLI = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/suite/suite-cli.ts'))
96
+
97
+ /** one test check per declared sub-unit, `test:<unit id>`, a `release: false`
98
+ * one included, run in the unit's own dir through the suite runner, so its
99
+ * bunfig, its preload and its `units[].tests` budget apply, after the checks
100
+ * its `deps` name. Its inputs are its dir and the dirs of the
101
+ * sibling units its package.json names, derived rather than declared */
102
+ function unitTestTasks(c: RepoConfig, root: string, io: FloorReader, subs: TestUnitDecl[], siblings: Map<string, string[]>, notes: string[], unfilled: { id: string; reason: string }[]): TaskDef[] {
103
+ const read = io.text
104
+ const out: TaskDef[] = []
105
+ for (const u of subs) {
106
+ const id = testCheckId(u.id)
107
+ if (!io.exists(u.rel)) { unfilled.push({ id, reason: `unit '${u.id}': '${u.rel}' is not a directory` }); continue }
108
+ const { script, body } = unitTestBody(read(join(u.rel, 'package.json')))
109
+ const args = parseBunTestScript(body)
110
+ let cmd: string[]
111
+ if (args == null) {
112
+ notes.push(`skip: suite runner for ${id} (script '${script}' is not a plain \`bun test\`, so its suites run as written: not in parallel, not timed against a budget)`)
113
+ cmd = ['bun', 'run', script!]
114
+ } else cmd = ['bun', SUITE_CLI, ...suiteFlags(u.tests), '--declared-in', `repo.config.ts units['${u.rel}'].tests (paths relative to ${u.rel})`, '--unit', u.id, '--', ...args]
115
+ out.push({ id, description: unitTestDescription(u), cmd, cwd: join(root, u.rel), inputs: [u.rel, ...(siblings.get(u.id) ?? [])], ...(u.deps == null ? {} : { deps: u.deps }) })
116
+ }
117
+ return out
118
+ }
119
+
120
+ /** one typecheck per declared sub-unit with a tsconfig of its own,
121
+ * `typecheck:<unit id>`: the root tsconfig excludes a unit's dir (it compiles
122
+ * standalone), so without this a green root typecheck hides every error only
123
+ * the unit's tsconfig sees. It runs the same runner the root slot falls back
124
+ * to, so a solution style config is checked project by project and says what
125
+ * it checked. tsc comes from the unit's node_modules, else the repo's, else
126
+ * PATH. Keyed and ordered like the unit's test check */
127
+ function unitTypecheckTasks(root: string, io: FloorReader, subs: TestUnitDecl[], siblings: Map<string, string[]>, unfilled: { id: string; reason: string }[]): TaskDef[] {
128
+ const out: TaskDef[] = []
129
+ for (const u of subs) {
130
+ const id = `typecheck:${u.id}`
131
+ const tsconfig = unitTsconfig(u)
132
+ if (!io.exists(tsconfig, 'file')) {
133
+ if (u.tsconfig != null) unfilled.push({ id, reason: `units['${u.rel}'].tsconfig names '${u.tsconfig}', which does not exist` })
134
+ continue
135
+ }
136
+ const local = join(u.rel, 'node_modules/.bin/tsc')
137
+ const tsc = io.exists(local) ? join(root, local) : io.exists('node_modules/.bin/tsc') ? join(root, 'node_modules/.bin/tsc') : io.which('tsc')
138
+ if (tsc == null) { unfilled.push({ id, reason: `unit '${u.id}' has a tsconfig and no tsc resolves (its node_modules, the repo's, PATH): add typescript` }); continue }
139
+ const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/typecheck-cli.ts'))
140
+ const outside = tsconfigChain(tsconfig, io).filter((f) => f !== u.rel && !f.startsWith(`${u.rel}/`))
141
+ out.push({ id, description: unitTypecheckDescription(u), cmd: ['bun', cli, tsc, join(root, tsconfig)], cwd: join(root, u.rel), inputs: [u.rel, ...(siblings.get(u.id) ?? []), ...outside], ...(u.deps == null ? {} : { deps: u.deps }) })
142
+ }
143
+ return out
144
+ }
145
+
146
+ /** a tsconfig and every config its `extends` reaches by a relative path,
147
+ * repo-relative: a base config outside the unit dir decides what compiles,
148
+ * so it belongs to the key. A package specifier (`@tsconfig/bun`) lives in
149
+ * node_modules and is left to the toolchain */
150
+ function tsconfigChain(rel: string, io: FloorReader): string[] {
151
+ const out: string[] = []
152
+ for (let at: string | null = posix.normalize(rel); at != null && !out.includes(at) && !at.startsWith('../'); ) {
153
+ out.push(at)
154
+ const text = io.text(at)
155
+ let ext: unknown = null
156
+ try { ext = text == null ? null : (parseJsonc(text) as { extends?: unknown } | null)?.extends } catch { ext = null }
157
+ at = typeof ext === 'string' && ext.startsWith('.') ? posix.normalize(posix.join(posix.dirname(at), ext.endsWith('.json') ? ext : `${ext}.json`)) : null
158
+ }
159
+ return out
160
+ }
161
+
162
+ // zero-config floor (O6): with no script, imports:check/cycles resolve to
163
+ // shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc. Returns
164
+ // the task, or the reason the slot stays empty.
165
+ function builtinFloorTask(id: string, root: string, io: FloorReader): { task: TaskDef; source: FloorSource } | string {
166
+ if (id === 'imports:check' || id === 'cycles') {
167
+ let cli: string
168
+ // shipkit's OWN module, resolved from this file: no dependency to be
169
+ // out of date with, and no resolution that can fail in a consumer tree
170
+ cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
171
+ return {
172
+ task: { id, cmd: id === 'cycles' ? ['bun', cli, 'cycles'] : ['bun', cli, 'imports', '--check'] },
173
+ source: 'built-in (shipkit)',
174
+ }
175
+ }
176
+ if (id === 'typecheck') {
177
+ const tsconfig = join(root, 'tsconfig.json')
178
+ if (!io.exists('tsconfig.json')) return `no 'typecheck' script and no tsconfig.json`
179
+ const local = join(root, 'node_modules/.bin/tsc')
180
+ const tsc = io.exists('node_modules/.bin/tsc') ? local : io.which('tsc')
181
+ if (tsc == null) return `no 'typecheck' script and tsc not found — add typescript or a typecheck script`
182
+ // `tsc --noEmit` over a solution-style config (`files: []` plus
183
+ // `references`) compiles ZERO files and exits 0, so the slot reports a
184
+ // green nobody measured. only a config that is its OWN single project
185
+ // keeps the plain command; everything else goes through the runner,
186
+ // which resolves and REPORTS what it checked.
187
+ //
188
+ // a config that compiles nothing, or references a path that is gone,
189
+ // registers the runner too rather than leaving the slot unfilled: an
190
+ // unfilled slot is a skip, `shipkit ci` still exits 0, and a repo with
191
+ // no typecheck coverage would look the same as a repo that passed.
192
+ let plain = false
193
+ try {
194
+ const projects = resolveTypecheckProjects(tsconfig, io, root)
195
+ plain = projects.projects.length === 1 && projects.projects[0] === tsconfig && projects.missing.length === 0
196
+ } catch { plain = false }
197
+ if (plain) return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
198
+ const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/typecheck-cli.ts'))
199
+ return { task: { id, cmd: ['bun', cli, tsc, tsconfig] }, source: 'built-in (tsc)' }
200
+ }
201
+ return `no package.json '${id}' script`
202
+ }
203
+
204
+ // NOTHING here declares a read set on a repo's behalf, and that is a decision
205
+ // rather than an omission. A floor slot with no `inputs` is measured every run,
206
+ // which costs speed and never correctness (ledger/git-key.ts owns that default).
207
+ //
208
+ // The tempting shortcut is `['.']`, on the reasoning that the whole tree cannot
209
+ // under declare. It can. `['.']` is the TRACKED tree: git-scan force-adds only a
210
+ // path a declaration NAMES, so an ignored path enters the key by name and never
211
+ // by content. Every floor slot reads ignored content in an ordinary repo, a
212
+ // suite through its fixtures and a local database, tsc and the graph walks
213
+ // through generated types and node_modules. Shipped for one day, that made the
214
+ // gate print `ok: test (replayed)` over a suite that exits 1
215
+ // (tests/floor-cache-safety.test.ts holds the repro).
216
+ //
217
+ // Speed here is bought by a REPO saying what its own tasks read, in
218
+ // `defaults`, which is the only declaration with a real hit rate anyway.
219
+ // `shipkit init` scaffolds the line, `shipkit keys --burn-down` names what is
220
+ // still undeclared, and the gate prints the cost of it once per run.
221
+
222
+ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): FloorResolution {
223
+ const pkg = io.text('package.json')
224
+ let scripts: Record<string, string> = {}
225
+ if (pkg != null) {
226
+ // unparseable package.json must NOT silently empty the floor — the
227
+ // gate would run only `contracts` and report green
228
+ try { scripts = (JSON.parse(pkg) as { scripts?: Record<string, string> }).scripts ?? {} }
229
+ catch (e) { throw new Error(`package.json unparseable — floor tasks cannot register: ${String(e).slice(0, 120)}`) }
230
+ }
231
+ const tasks: TaskDef[] = []
232
+ const disabled: string[] = []
233
+ const unfilled: { id: string; reason: string }[] = []
234
+ const sources: Record<string, FloorSource> = {}
235
+ const notes: string[] = []
236
+ const subs = declaredTestUnits(c.units)
237
+ for (const id of FLOOR_IDS) {
238
+ const override = c.defaults?.[id]
239
+ const description = slotDescription(id, subs.length > 0)
240
+ const slot = slotBody(id, override, scripts)
241
+ if (slot.kind === 'off') { disabled.push(id); continue }
242
+ if (slot.kind === 'override') {
243
+ tasks.push({ description, ...(override as TaskDef), id: (override as TaskDef).id ?? id })
244
+ sources[id] = 'repo.config override'
245
+ continue
246
+ }
247
+ // an override with no body DECORATES the slot: that is how a repo
248
+ // declares what its `test` script reads without restating the command
249
+ const decorate = (b: TaskDef): TaskDef => {
250
+ const t = { description, ...b }
251
+ return override == null || override === false ? t : { ...t, ...override, id }
252
+ }
253
+ if (slot.kind === 'script') { tasks.push(decorate({ id, cmd: scriptCmd(c, id, slot.name, slot.body, notes, subs) })); sources[id] = 'package.json script'; continue }
254
+ const b = builtinFloorTask(id, root, io)
255
+ // a routed script with nothing behind it measures NOTHING, and the
256
+ // reason must say that rather than `no '<id>' script`, which is false
257
+ if (typeof b === 'string') unfilled.push({ id, reason: slot.kind === 'routed-empty' ? `script '${id}' routes to shipkit and there is no '${rawName(id)}' body to run` : b })
258
+ else { tasks.push(decorate(b.task)); sources[id] = b.source }
259
+ }
260
+ const siblings = siblingUnits(subs, io.text)
261
+ for (const t of unitTestTasks(c, root, io, subs, siblings, notes, unfilled)) { tasks.push(t); sources[t.id] = 'unit' }
262
+ for (const t of unitTypecheckTasks(root, io, subs, siblings, unfilled)) { tasks.push(t); sources[t.id] = 'unit' }
263
+ return { tasks, disabled, unfilled, sources, notes }
264
+ }
package/src/core/floor.ts CHANGED
@@ -1,145 +1,28 @@
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'
1
+ // the floor as read from the disk: plain fs behind core/floor-resolve.ts, which
2
+ // owns the resolution. Every caller outside a check resolves here; a check
3
+ // resolves through its context (checkFloorReader) so it stays keyed on its reads.
4
+ import { existsSync } from 'fs'
9
5
  import { join } from 'path'
10
- import { fileURLToPath } from 'url'
11
- import { rawName, routesToShipkit } from './canonical-scripts.ts'
12
- import { resolveTypecheckProjects } from '../ts/tsconfig-projects.ts'
13
- import { parseBunTestScript, suiteFlags } from '../suite/judge-suites.ts'
14
- import type { TaskDef } from './task-runner.ts'
6
+ import { gitBin } from './git-bin.ts'
7
+ import { fsUnitReader } from '../release/units.ts'
8
+ import { resolveFloor as resolveWith, type FloorReader, type FloorResolution } from './floor-resolve.ts'
15
9
  import type { RepoConfig } from '../repo-config.ts'
16
10
 
17
- export const FLOOR_IDS = ['lint', 'typecheck', 'test', 'imports:check', 'cycles'] as const
11
+ export * from './floor-resolve.ts'
18
12
 
19
- export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)'
20
- export type FloorResolution = {
21
- tasks: TaskDef[]
22
- /** slots disabled by `defaults: { x: false }` — always printed */
23
- disabled: string[]
24
- /** slots nothing could fill — always printed, never silent */
25
- unfilled: { id: string; reason: string }[]
26
- /** where each registered slot came from (init prints this) */
27
- sources: Record<string, FloorSource>
28
- /** skip lines about how a slot runs, printed by the gate */
29
- notes?: string[]
30
- }
31
-
32
- /** a package.json body for a slot. A plain `bun test` in the test slot runs
33
- * through the suite runner (parallel, timed against the budget), anything
34
- * else runs as written and the gate says what that costs */
35
- function scriptCmd(c: RepoConfig, id: string, name: string, body: string, notes: string[]): string[] {
36
- if (id !== 'test') return ['bun', 'run', name]
37
- const args = parseBunTestScript(body)
38
- if (args == null) {
39
- notes.push(`skip: suite runner (script '${name}' is not a plain \`bun test\`, so its suites run as written: not in parallel, not timed against a budget)`)
40
- return ['bun', 'run', name]
41
- }
42
- return ['bun', fileURLToPath(import.meta.resolve('rvlib-shipkit/src/suite/suite-cli.ts')), ...suiteFlags(c.tests), '--', ...args]
43
- }
13
+ export const fsFloorReader = (root: string): FloorReader => ({ ...fsUnitReader(root), which: (bin) => Bun.which(bin) })
44
14
 
45
- // zero-config floor (O6): with no script, imports:check/cycles resolve to
46
- // shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc. Returns
47
- // the task, or the reason the slot stays empty.
48
- function builtinFloorTask(id: string, root: string): { task: TaskDef; source: FloorSource } | string {
49
- if (id === 'imports:check' || id === 'cycles') {
50
- let cli: string
51
- // shipkit's OWN module, resolved from this file: no dependency to be
52
- // out of date with, and no resolution that can fail in a consumer tree
53
- cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
54
- return {
55
- task: { id, cmd: id === 'cycles' ? ['bun', cli, 'cycles'] : ['bun', cli, 'imports', '--check'] },
56
- source: 'built-in (shipkit)',
57
- }
58
- }
59
- if (id === 'typecheck') {
60
- const tsconfig = join(root, 'tsconfig.json')
61
- if (!existsSync(tsconfig)) return `no 'typecheck' script and no tsconfig.json`
62
- const local = join(root, 'node_modules/.bin/tsc')
63
- const tsc = existsSync(local) ? local : Bun.which('tsc')
64
- if (tsc == null) return `no 'typecheck' script and tsc not found — add typescript or a typecheck script`
65
- // `tsc --noEmit` over a solution-style config (`files: []` plus
66
- // `references`) compiles ZERO files and exits 0, so the slot reports a
67
- // green nobody measured. only a config that is its OWN single project
68
- // keeps the plain command; everything else goes through the runner,
69
- // which resolves and REPORTS what it checked.
70
- //
71
- // a config that compiles nothing, or references a path that is gone,
72
- // registers the runner too rather than leaving the slot unfilled: an
73
- // unfilled slot is a skip, `shipkit ci` still exits 0, and a repo with
74
- // no typecheck coverage would look the same as a repo that passed.
75
- let plain = false
76
- try {
77
- const projects = resolveTypecheckProjects(tsconfig)
78
- plain = projects.projects.length === 1 && projects.projects[0] === tsconfig && projects.missing.length === 0
79
- } catch { plain = false }
80
- if (plain) return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
81
- const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/typecheck-cli.ts'))
82
- return { task: { id, cmd: ['bun', cli, tsc, tsconfig] }, source: 'built-in (tsc)' }
83
- }
84
- return `no package.json '${id}' script`
15
+ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader = fsFloorReader(root)): FloorResolution {
16
+ return resolveWith(c, root, io)
85
17
  }
86
18
 
87
- // NOTHING here declares a read set on a repo's behalf, and that is a decision
88
- // rather than an omission. A floor slot with no `inputs` is measured every run,
89
- // which costs speed and never correctness (ledger/git-key.ts owns that default).
90
- //
91
- // The tempting shortcut is `['.']`, on the reasoning that the whole tree cannot
92
- // under declare. It can. `['.']` is the TRACKED tree: git-scan force-adds only a
93
- // path a declaration NAMES, so an ignored path enters the key by name and never
94
- // by content. Every floor slot reads ignored content in an ordinary repo, a
95
- // suite through its fixtures and a local database, tsc and the graph walks
96
- // through generated types and node_modules. Shipped for one day, that made the
97
- // gate print `ok: test (replayed)` over a suite that exits 1
98
- // (tests/floor-cache-safety.test.ts holds the repro).
99
- //
100
- // Speed here is bought by a REPO saying what its own tasks read, in
101
- // `defaults`, which is the only declaration with a real hit rate anyway.
102
- // `shipkit init` scaffolds the line, `shipkit keys --burn-down` names what is
103
- // still undeclared, and the gate prints the cost of it once per run.
104
-
105
- export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
106
- const pkgPath = join(root, 'package.json')
107
- let scripts: Record<string, string> = {}
108
- if (existsSync(pkgPath)) {
109
- // unparseable package.json must NOT silently empty the floor — the
110
- // gate would run only `contracts` and report green
111
- try { scripts = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {} }
112
- catch (e) { throw new Error(`package.json unparseable — floor tasks cannot register: ${String(e).slice(0, 120)}`) }
113
- }
114
- const tasks: TaskDef[] = []
115
- const disabled: string[] = []
116
- const unfilled: { id: string; reason: string }[] = []
117
- const sources: Record<string, FloorSource> = {}
118
- const notes: string[] = []
119
- for (const id of FLOOR_IDS) {
120
- const override = c.defaults?.[id]
121
- if (override === false) { disabled.push(id); continue }
122
- // a FULL override brings its own body and replaces the slot. One that
123
- // brings none DECORATES whatever fills it, which is how a repo declares
124
- // what its `test` script reads without restating the command: the read
125
- // set is repo knowledge, the command is not.
126
- if (override != null && (override.cmd != null || override.logic != null)) {
127
- tasks.push({ ...override, id: override.id ?? id } as TaskDef)
128
- sources[id] = 'repo.config override'
129
- continue
130
- }
131
- // ONE WAY IN: `<id>` routes to `shipkit ci --only <id>`, so the TOOL
132
- // body lives at `<id>:raw` and that is what the gate runs. Running the
133
- // routed script here would spawn the gate from inside itself, forever
134
- const decorate = (t: TaskDef): TaskDef => (override == null ? t : { ...t, ...override, id })
135
- const raw = scripts[rawName(id)]
136
- if (raw != null) { tasks.push(decorate({ id, cmd: scriptCmd(c, id, rawName(id), raw, notes) })); sources[id] = 'package.json script'; continue }
137
- if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push(decorate({ id, cmd: scriptCmd(c, id, id, scripts[id]!, notes) })); sources[id] = 'package.json script'; continue }
138
- const b = builtinFloorTask(id, root)
139
- // a routed script with nothing behind it measures NOTHING, and the
140
- // reason must say that rather than `no '<id>' script`, which is false
141
- 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 })
142
- else { tasks.push(decorate(b.task)); sources[id] = b.source }
143
- }
144
- return { tasks, disabled, unfilled, sources, notes }
19
+ /** every file git sees here (tracked, or untracked and not ignored), repo
20
+ * relative: the listing test discovery and the root test key read. Outside
21
+ * git, a disk walk with installs and dot dirs left out */
22
+ export function listRepoFiles(root: string): string[] {
23
+ const p = Bun.spawnSync([gitBin(), 'ls-files', '-co', '--exclude-standard', '-z'], { cwd: root, stdout: 'pipe', stderr: 'ignore' })
24
+ if (p.exitCode === 0) return p.stdout.toString().split('\0').filter((f) => f !== '' && existsSync(join(root, f))).sort()
25
+ try {
26
+ return [...new Bun.Glob('**/*').scanSync({ cwd: root, onlyFiles: true, dot: false })].map((f) => f.replace(/\\/g, '/')).filter((f) => !f.split('/').includes('node_modules')).sort()
27
+ } catch { return [] }
145
28
  }
@@ -0,0 +1,24 @@
1
+ // the git executable to spawn, resolved once per process.
2
+ //
3
+ // on macOS `git` on PATH is usually /usr/bin/git, an xcrun shim that looks up
4
+ // the active developer dir on every call: measured 15ms a spawn against 5ms for
5
+ // the binary it forwards to. a gate spawns git a dozen times, so the shim is
6
+ // most of what a cached gate costs. `git --exec-path` names the real
7
+ // git-core directory, which holds the real `git`, and that is what we spawn.
8
+ // anything unexpected keeps plain `git`, which is always correct, only slower.
9
+ import { existsSync } from 'fs'
10
+ import { join } from 'path'
11
+
12
+ let resolved: string | null = null
13
+
14
+ export function gitBin(): string {
15
+ if (resolved != null) return resolved
16
+ resolved = 'git'
17
+ if (process.platform !== 'darwin') return resolved
18
+ try {
19
+ const p = Bun.spawnSync(['git', '--exec-path'], { stdout: 'pipe', stderr: 'ignore' })
20
+ const dir = p.exitCode === 0 ? p.stdout.toString().trim() : ''
21
+ if (dir !== '' && existsSync(join(dir, 'git'))) resolved = join(dir, 'git')
22
+ } catch {}
23
+ return resolved
24
+ }
@@ -0,0 +1,108 @@
1
+ // where a directory sits in git: its git dir, the common dir, the work tree
2
+ // root and its prefix inside it. One rev-parse answers all four, and the answer
3
+ // is remembered per directory, because one gate asks it from a dozen places
4
+ // (store, run record, scan, nesting, three rules) and each ask is a process
5
+ // spawn. A directory with no `.git` in reach is answered without asking. Every remembered answer is re-validated with a few stats on each hit,
6
+ // so a `git init` or a deleted repo is seen at once in a long-lived process.
7
+ import { existsSync, realpathSync, statSync } from 'fs'
8
+ import { dirname, join, resolve } from 'path'
9
+ import { gitBin } from './git-bin.ts'
10
+
11
+ export type GitRoot = {
12
+ /** absolute git dir of this work tree (per worktree) */
13
+ gitDir: string
14
+ /** absolute common dir, shared by every linked worktree */
15
+ commonDir: string
16
+ /** absolute work tree root, realpath as git prints it */
17
+ top: string
18
+ /** the directory relative to `top`, posix: '' at the root, else ending in '/' */
19
+ prefix: string
20
+ }
21
+
22
+ type Probe = { root: GitRoot | null; error: string }
23
+
24
+ const known = new Map<string, Probe>()
25
+
26
+ /** a positive answer holds while its git dir exists and no `.git` appeared
27
+ * between the directory and the work tree root (a repo nested in after the
28
+ * fact). A negative one holds while no `.git` exists at or above it, so an
29
+ * answer git gave despite a `.git` in reach is never remembered at all. */
30
+ function stillValid(dir: string, p: Probe): boolean {
31
+ if (p.root != null && !existsSync(join(p.root.gitDir, 'HEAD'))) return false
32
+ let cur: string
33
+ try {
34
+ cur = realpathSync(dir)
35
+ } catch {
36
+ return false
37
+ }
38
+ for (;;) {
39
+ if (p.root != null && cur === p.root.top) return true
40
+ if (existsSync(join(cur, '.git'))) return false
41
+ const up = dirname(cur)
42
+ if (up === cur) return p.root == null
43
+ cur = up
44
+ }
45
+ }
46
+
47
+ /** the facts, or git's own words for why there are none */
48
+ export function probeGitRoot(at: string): Probe {
49
+ // absolute, so a relative ask never reads an answer given for another cwd
50
+ const dir = resolve(at)
51
+ const hit = known.get(dir)
52
+ if (hit != null && stillValid(dir, hit)) return hit
53
+ const p = ask(dir)
54
+ if (stillValid(dir, p)) known.set(dir, p)
55
+ else known.delete(dir)
56
+ return p
57
+ }
58
+
59
+ /** the variables that let git find a repo with no `.git` in reach */
60
+ const LOCATING_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR']
61
+
62
+ /** git's discovery walks up looking for `.git`. With none at or above and no
63
+ * variable pointing elsewhere, its answer is already known, and a gate or a
64
+ * test suite that probes fresh temp dirs would spawn a process per dir for it */
65
+ function nothingInReach(dir: string): boolean {
66
+ if (LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')) return false
67
+ let cur: string
68
+ try {
69
+ cur = realpathSync(dir)
70
+ } catch {
71
+ return false
72
+ }
73
+ for (;;) {
74
+ if (existsSync(join(cur, '.git'))) return false
75
+ const up = dirname(cur)
76
+ if (up === cur) return true
77
+ cur = up
78
+ }
79
+ }
80
+
81
+ function ask(dir: string): Probe {
82
+ if (nothingInReach(dir)) return { root: null, error: 'fatal: not a git repository (or any of the parent directories): .git' }
83
+ let p: ReturnType<typeof Bun.spawnSync>
84
+ try {
85
+ p = Bun.spawnSync([gitBin(), 'rev-parse', '--path-format=absolute', '--absolute-git-dir', '--git-common-dir', '--show-toplevel', '--show-prefix'], {
86
+ cwd: dir,
87
+ stdout: 'pipe',
88
+ stderr: 'pipe',
89
+ })
90
+ } catch (e) {
91
+ return { root: null, error: e instanceof Error ? e.message : String(e) }
92
+ }
93
+ if (p.exitCode !== 0) return { root: null, error: p.stderr?.toString().trim() ?? `git rev-parse exited ${p.exitCode}` }
94
+ const [gitDir = '', commonDir = '', top = '', prefix = ''] = (p.stdout?.toString() ?? '').split('\n')
95
+ if (gitDir === '' || commonDir === '' || top === '') return { root: null, error: `git rev-parse printed no work tree for ${dir}` }
96
+ return { root: { gitDir, commonDir, top, prefix }, error: '' }
97
+ }
98
+
99
+ /** the facts, or null outside a git work tree */
100
+ export const gitRoot = (dir: string): GitRoot | null => probeGitRoot(dir).root
101
+
102
+ /** a LINKED worktree, where `.git` is a file pointing at the main checkout
103
+ * rather than a directory. Cheap on purpose: it runs on every check pass and
104
+ * every hub tick, and spawning git to learn it would cost more than the
105
+ * answer is worth. */
106
+ export function isLinkedWorktree(root: string): boolean {
107
+ try { return statSync(join(root, '.git')).isFile() } catch { return false }
108
+ }