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.
- package/CHANGELOG.md +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- 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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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
|
|
11
|
+
export * from './floor-resolve.ts'
|
|
18
12
|
|
|
19
|
-
export
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|