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
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
// A contract folder typed without its leading dot is INVISIBLE, in every
|
|
2
|
-
// direction at once: the scanner that harvests it looks for the dotted name,
|
|
3
|
-
// the gitignore policy covers the dotted name, and the repo that filed
|
|
4
|
-
// something into it gets silence rather than an error. Files sat unread for a
|
|
5
|
-
// day that way, and nothing anywhere was red.
|
|
6
|
-
//
|
|
7
|
-
// One character, no ambiguity, and nothing about it is a decision: it is a
|
|
8
|
-
// typo, so the fix pass renames it and lands it, and no agent is told. A WARN a
|
|
9
|
-
// machine can repair is a design bug, and an attention surface carries only
|
|
10
|
-
// what needs taste or hands.
|
|
11
|
-
//
|
|
12
|
-
// The names come from the SK004 policy block, which is the one table that
|
|
13
|
-
// already lists the `.rv-*` contract folders. The TRACKED contract dirs
|
|
14
|
-
// (`.rv-identity`, `.rv-assets`) are not in it, and a rename that untracks a
|
|
15
|
-
// tracked folder is not a repair, so the ignore is checked against THIS REPO
|
|
16
|
-
// before anything moves: the venue names in that block carry "ignored until the
|
|
17
|
-
// listing is live, override below the block", so a repo with a live listing
|
|
18
|
-
// tracks `.rv-itch/` on purpose and a rename there would delete its files from
|
|
19
|
-
// the index. `git check-ignore` answers for the repo instead of a regex
|
|
20
|
-
// answering for a comment.
|
|
21
|
-
import { readdirSync, renameSync, rmdirSync } from 'fs'
|
|
22
|
-
import { join } from 'path'
|
|
23
|
-
import { POLICY_LINES } from '../rules/SK004-gitignore-policy.ts'
|
|
24
|
-
import { machineCommit } from './machine-commit.ts'
|
|
25
|
-
import { nestedIn } from './nesting.ts'
|
|
26
|
-
|
|
27
|
-
/** the always-ignored `.rv-*` contract folders, from the policy that owns them */
|
|
28
|
-
export const contractDirNames = (): string[] =>
|
|
29
|
-
POLICY_LINES.filter((l) => /^\.rv-[a-z-]+\/$/.test(l)).map((l) => l.slice(0, -1))
|
|
30
|
-
|
|
31
|
-
/** the shape of a name, ignoring the leading dot, the separators, the case and
|
|
32
|
-
* a trailing plural. Tight on purpose: `rv-reflections`, `rv_reflection` and
|
|
33
|
-
* `RV-Reflections` are the same intent, `reflections` and `docs` are not */
|
|
34
|
-
const shapeOf = (s: string): string => s.toLowerCase().replace(/^\.+/, '').replace(/[-_.]/g, '').replace(/s$/, '')
|
|
35
|
-
|
|
36
|
-
export type DirRepair = { from: string; to: string }
|
|
37
|
-
export type DirRefusal = { from: string; to: string; reason: string }
|
|
38
|
-
export type DirRepairPlan = { repairs: DirRepair[]; refusals: DirRefusal[] }
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* PURE: what to rename, given the names sitting in a repo root.
|
|
42
|
-
*
|
|
43
|
-
* BOTH names present is the one case worth refusing out loud: two folders can
|
|
44
|
-
* hold two files of the same name, and merging them is a choice about which
|
|
45
|
-
* one survives. Nothing else here is a choice.
|
|
46
|
-
*/
|
|
47
|
-
export function planContractDirRepairs(entries: string[], names: string[] = contractDirNames()): DirRepairPlan {
|
|
48
|
-
const present = new Set(entries)
|
|
49
|
-
const repairs: DirRepair[] = []
|
|
50
|
-
const refusals: DirRefusal[] = []
|
|
51
|
-
for (const dotted of names) {
|
|
52
|
-
const shape = shapeOf(dotted)
|
|
53
|
-
for (const entry of entries) {
|
|
54
|
-
if (entry === dotted || shapeOf(entry) !== shape) continue
|
|
55
|
-
if (present.has(dotted)) refusals.push({ from: entry, to: dotted, reason: 'both names exist, and merging them decides which file wins' })
|
|
56
|
-
else repairs.push({ from: entry, to: dotted })
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return { repairs, refusals }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const isEmptyDir = (path: string): boolean => {
|
|
63
|
-
try { return readdirSync(path).length === 0 } catch { return false }
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const dirsIn = (root: string): string[] => {
|
|
67
|
-
try { return readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) } catch { return [] }
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export type DirRepairResult = { renamed: DirRepair[]; removed: string[]; refusals: DirRefusal[]; uncommitted: DirRepair[] }
|
|
71
|
-
|
|
72
|
-
/** does THIS repo's ignore policy cover the dotted name (the index is not
|
|
73
|
-
* consulted: the question is about the patterns, and the path does not exist yet) */
|
|
74
|
-
const isIgnoredHere = (root: string, name: string): boolean =>
|
|
75
|
-
Bun.spawnSync(['git', 'check-ignore', '-q', '--no-index', `${name}/`], { cwd: root }).exitCode === 0
|
|
76
|
-
|
|
77
|
-
/** does git track anything under this folder today */
|
|
78
|
-
const isTracked = (root: string, name: string): boolean => {
|
|
79
|
-
const r = Bun.spawnSync(['git', 'ls-files', '--', name], { cwd: root })
|
|
80
|
-
return r.exitCode === 0 && r.stdout.toString().trim() !== ''
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Apply the plan. An EMPTY undotted folder is removed rather than renamed:
|
|
85
|
-
* there is nothing to carry over, git never tracked it, and the dotted name is
|
|
86
|
-
* created by whoever files the first note.
|
|
87
|
-
*
|
|
88
|
-
* A rename UNTRACKS whatever the undotted folder held, and that is the intended
|
|
89
|
-
* end state rather than a side effect: the dotted name is gitignored by
|
|
90
|
-
* contract, so the repo was only tracking those files by accident of the
|
|
91
|
-
* missing dot. Hence a plain rename plus a commit of the OLD path: the commit
|
|
92
|
-
* records the files leaving the index, the files themselves stay on disk under
|
|
93
|
-
* the name every scanner reads, and `git commit -- <old>` matches nothing when
|
|
94
|
-
* git never tracked them, which lands no commit and is the right answer there.
|
|
95
|
-
*/
|
|
96
|
-
export function repairContractDirs(root: string): DirRepairResult {
|
|
97
|
-
const plan = planContractDirRepairs(dirsIn(root))
|
|
98
|
-
const renamed: DirRepair[] = []
|
|
99
|
-
const removed: string[] = []
|
|
100
|
-
const uncommitted: DirRepair[] = []
|
|
101
|
-
// a nested repo never untracks: the index and the commit are the parent's
|
|
102
|
-
const nested = plan.repairs.length === 0 ? null : nestedIn(root)
|
|
103
|
-
for (const r of plan.repairs) {
|
|
104
|
-
const from = join(root, r.from)
|
|
105
|
-
const to = join(root, r.to)
|
|
106
|
-
try {
|
|
107
|
-
if (isEmptyDir(from)) {
|
|
108
|
-
rmdirSync(from)
|
|
109
|
-
removed.push(r.from)
|
|
110
|
-
continue
|
|
111
|
-
}
|
|
112
|
-
// renaming TRACKED files into an ignored path takes them out of the
|
|
113
|
-
// index, which is the contract for a reflections folder and data
|
|
114
|
-
// loss for a live venue folder the repo tracks on purpose. So the
|
|
115
|
-
// untracking half only happens where the repo's own ignore policy
|
|
116
|
-
// says those files are local. Untracked files rename freely: there
|
|
117
|
-
// is nothing to lose and nothing to commit
|
|
118
|
-
const tracked = isTracked(root, r.from)
|
|
119
|
-
if (tracked && (nested != null || !isIgnoredHere(root, r.to))) continue
|
|
120
|
-
renameSync(from, to)
|
|
121
|
-
renamed.push(r)
|
|
122
|
-
// a repair that half happened is not the silence that was asked
|
|
123
|
-
// for. the hooks can refuse, SHIPKIT_NO_AUTOCOMMIT can be set, a
|
|
124
|
-
// merge can be in progress: the rename is still the right end
|
|
125
|
-
// state, and the dirt it leaves has to be traceable to this run
|
|
126
|
-
// rather than surfacing as an unexplained refusal in the next bump
|
|
127
|
-
if (tracked && !machineCommit({ anchor: root, message: `chore(machine): rename ${r.from} to ${r.to}`, paths: [from] })) {
|
|
128
|
-
uncommitted.push(r)
|
|
129
|
-
}
|
|
130
|
-
} catch {
|
|
131
|
-
// a permission error or a race is not worth a red gate: the folder
|
|
132
|
-
// stays where it is and the next run tries again
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return { renamed, removed, refusals: plan.refusals, uncommitted }
|
|
136
|
-
}
|
package/src/core/task-runner.ts
DELETED
|
@@ -1,339 +0,0 @@
|
|
|
1
|
-
// Task runner — dep-DAG scheduler with wire-protocol output (wire.ts).
|
|
2
|
-
// Plain mode only by design: one `ok:`/`FAIL:` line per finished task +
|
|
3
|
-
// final summary; greppable by humans and agents alike. Semantics:
|
|
4
|
-
// - a dep must PASS to unblock; a filtered-out dep counts as satisfied;
|
|
5
|
-
// a failed/skipped dep skips dependents transitively (note says why).
|
|
6
|
-
// Deps double as serialization guards (document racy edges at the def).
|
|
7
|
-
// - subprocess tasks: full log streamed to <logDir>/run-<pid>-<ts>/<id>.log, last non-empty
|
|
8
|
-
// line kept for the FAIL summary; SIGTERM at timeoutMs, SIGKILL +2s.
|
|
9
|
-
// - logic tasks run in-process against a CheckCtx: WARN never flips the
|
|
10
|
-
// exit code, FAIL does. Their lines print under the task line.
|
|
11
|
-
// - bail/SIGINT KILL in-flight children (SIGTERM, SIGKILL +2s); a killed
|
|
12
|
-
// task reports 'skipped (killed …)', never a fake fail.
|
|
13
|
-
// - sharp edge, by design: `onlyIds` on a task whose race-guard dep is
|
|
14
|
-
// optIn/skipped-group drops that dep (filtered = satisfied) — explicit
|
|
15
|
-
// filters win over guards.
|
|
16
|
-
import { createWriteStream, mkdirSync, readdirSync, rmSync, type WriteStream } from 'fs'
|
|
17
|
-
import { basename, join } from 'path'
|
|
18
|
-
import { CheckCtx } from './check-ctx.ts'
|
|
19
|
-
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
20
|
-
import { formatLine, type Line } from './wire.ts'
|
|
21
|
-
|
|
22
|
-
export type TaskDef = {
|
|
23
|
-
id: string
|
|
24
|
-
cmd?: string[]
|
|
25
|
-
logic?: (t: CheckCtx) => unknown
|
|
26
|
-
deps?: string[]
|
|
27
|
-
env?: Record<string, string>
|
|
28
|
-
cwd?: string
|
|
29
|
-
group?: string
|
|
30
|
-
/** excluded unless listed in RunOpts.withIds */
|
|
31
|
-
optIn?: boolean
|
|
32
|
-
timeoutMs?: number
|
|
33
|
-
/** CONTENT population: the repo-relative paths this task reads. Declaring
|
|
34
|
-
* them is what makes the task keyable (ledger/git-key.ts). Undeclared is
|
|
35
|
-
* the DEFAULT and means never cached, so forgetting a whole task costs
|
|
36
|
-
* speed and never correctness. A list MISSING a path the command reads is
|
|
37
|
-
* the one that costs correctness: the key cannot move when that path
|
|
38
|
-
* changes, so the answer replays over a tree it no longer describes. Over
|
|
39
|
-
* declaring costs a re-run, under declaring is a false green. `['.']` is
|
|
40
|
-
* the whole TRACKED tree, and it is NOT a catch-all: an ignored path
|
|
41
|
-
* enters a key by name and never by content, so a task reading a
|
|
42
|
-
* gitignored fixture, a built dir or a .env under declares with `['.']`
|
|
43
|
-
* exactly as it would with a narrow list. A task that reads the network,
|
|
44
|
-
* a keychain or the clock declares `ttl`, never `inputs`. A task carries no code
|
|
45
|
-
* closure, so the script it runs belongs in here too.
|
|
46
|
-
* Declaring both this and `ttl` is a registration failure. */
|
|
47
|
-
inputs?: string[]
|
|
48
|
-
/** WORLD population: this task reads the network, a keychain, $PATH or the
|
|
49
|
-
* clock, so nothing on disk describes its answer and a clean pass is
|
|
50
|
-
* remembered for this long (e.g. '24h'). */
|
|
51
|
-
ttl?: string
|
|
52
|
-
/** a DECIDED never-cacheable, with the reason. Says somebody looked and
|
|
53
|
-
* nothing can describe this task, which takes it off the burn-down list
|
|
54
|
-
* that undeclared units land on. */
|
|
55
|
-
never?: string
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** every field a `TaskDef` may carry. Declared as a value so `defineRepo` can
|
|
59
|
-
* refuse a typo inside a `defaults` slot: a slot with no `cmd` DECORATES the
|
|
60
|
-
* resolved task, so a misspelled field is not a type error at the call site
|
|
61
|
-
* and would land as a task that is quietly never cached. Add a field above,
|
|
62
|
-
* add it here. */
|
|
63
|
-
export const TASK_DEF_KEYS = ['id', 'cmd', 'logic', 'deps', 'env', 'cwd', 'group', 'optIn', 'timeoutMs', 'inputs', 'ttl', 'never'] as const
|
|
64
|
-
|
|
65
|
-
export type TaskResult = {
|
|
66
|
-
id: string
|
|
67
|
-
status: 'pass' | 'fail' | 'skipped'
|
|
68
|
-
exitCode: number | null
|
|
69
|
-
durationMs: number
|
|
70
|
-
logPath: string | null
|
|
71
|
-
lastLine: string
|
|
72
|
-
note: string
|
|
73
|
-
lines: Line[]
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export type RunOpts = {
|
|
77
|
-
tasks: TaskDef[]
|
|
78
|
-
logDir: string
|
|
79
|
-
cwd?: string
|
|
80
|
-
maxParallel?: number
|
|
81
|
-
bail?: boolean
|
|
82
|
-
skipGroups?: string[]
|
|
83
|
-
onlyIds?: string[]
|
|
84
|
-
withIds?: string[]
|
|
85
|
-
/** ids `--only` may name that are NOT tasks: discovered checks and rules.
|
|
86
|
-
* The flag addresses all three surfaces, so a check name must not come
|
|
87
|
-
* back as `unknown task id` from the one surface that does not own it. */
|
|
88
|
-
otherIds?: string[]
|
|
89
|
-
/** print sink — tests capture, production defaults to console.log */
|
|
90
|
-
print?: (line: string) => void
|
|
91
|
-
color?: boolean
|
|
92
|
-
/** per-task completion tap (the TUI streams results in as they land) —
|
|
93
|
-
* fires for pass/fail/dep-skip; bail/interrupt leftovers never finish,
|
|
94
|
-
* callers sweep their own pending state after runTasks resolves */
|
|
95
|
-
onTaskFinish?: (r: TaskResult) => void
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export type RunSummary = { ok: boolean; results: TaskResult[]; interrupted: boolean }
|
|
99
|
-
|
|
100
|
-
type Live = TaskDef & TaskResult & { startedAt: number }
|
|
101
|
-
|
|
102
|
-
const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '').replace(/\r/g, '')
|
|
103
|
-
|
|
104
|
-
// only-mode keeps the transitive dep closure — running `--only typecheck`
|
|
105
|
-
// with a lint race-guard dep must still run lint first
|
|
106
|
-
function closure(tasks: TaskDef[], ids: string[]): Set<string> {
|
|
107
|
-
const byId = new Map(tasks.map((t) => [t.id, t]))
|
|
108
|
-
const keep = new Set<string>()
|
|
109
|
-
const visit = (id: string): void => {
|
|
110
|
-
if (keep.has(id)) return
|
|
111
|
-
keep.add(id)
|
|
112
|
-
for (const d of byId.get(id)?.deps ?? []) visit(d)
|
|
113
|
-
}
|
|
114
|
-
for (const id of ids) visit(id)
|
|
115
|
-
return keep
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function selectTasks(o: RunOpts): { selected: TaskDef[]; errors: string[] } {
|
|
119
|
-
const errors: string[] = []
|
|
120
|
-
const ids = new Set(o.tasks.map((t) => t.id))
|
|
121
|
-
if (ids.size !== o.tasks.length) errors.push('duplicate task ids')
|
|
122
|
-
for (const t of o.tasks) {
|
|
123
|
-
if ((t.cmd == null) === (t.logic == null)) errors.push(`task ${t.id}: exactly one of cmd/logic required`)
|
|
124
|
-
for (const d of t.deps ?? []) if (!ids.has(d)) errors.push(`task ${t.id}: unknown dep '${d}'`)
|
|
125
|
-
}
|
|
126
|
-
const elsewhere = new Set((o.otherIds ?? []).map((i) => i.toLowerCase()))
|
|
127
|
-
for (const id of [...(o.onlyIds ?? []), ...(o.withIds ?? [])])
|
|
128
|
-
if (!ids.has(id) && !elsewhere.has(id.toLowerCase())) errors.push(`unknown task id '${id}'`)
|
|
129
|
-
// cycle check — a dep cycle must die loud at select time, not hang the loop
|
|
130
|
-
const state = new Map<string, 0 | 1>()
|
|
131
|
-
const byId = new Map(o.tasks.map((t) => [t.id, t]))
|
|
132
|
-
const walk = (id: string, trail: string[]): void => {
|
|
133
|
-
if (state.get(id) === 1) return
|
|
134
|
-
if (state.get(id) === 0) { errors.push(`dep cycle: ${[...trail, id].join(' → ')}`); return }
|
|
135
|
-
state.set(id, 0)
|
|
136
|
-
for (const d of byId.get(id)?.deps ?? []) walk(d, [...trail, id])
|
|
137
|
-
state.set(id, 1)
|
|
138
|
-
}
|
|
139
|
-
for (const t of o.tasks) walk(t.id, [])
|
|
140
|
-
if (errors.length > 0) return { selected: [], errors }
|
|
141
|
-
|
|
142
|
-
const withSet = new Set(o.withIds ?? [])
|
|
143
|
-
const skipGroups = new Set(o.skipGroups ?? [])
|
|
144
|
-
let selected = o.tasks.filter((t) => !(t.optIn === true && !withSet.has(t.id)) && !(t.group != null && skipGroups.has(t.group)))
|
|
145
|
-
if (o.onlyIds != null && o.onlyIds.length > 0) {
|
|
146
|
-
const keep = closure(o.tasks, o.onlyIds)
|
|
147
|
-
selected = selected.filter((t) => keep.has(t.id))
|
|
148
|
-
}
|
|
149
|
-
return { selected, errors }
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** THIS run's log dir. It used to be `logDir` itself, wiped with rmSync at the
|
|
153
|
-
* top of every run: a second agent starting a gate deleted the log files the
|
|
154
|
-
* first was still streaming into, so the surviving FAIL line pointed at a
|
|
155
|
-
* path that no longer existed. Now each run owns `run-<pid>-<started>/` and
|
|
156
|
-
* writes nowhere else.
|
|
157
|
-
*
|
|
158
|
-
* Old run dirs are swept on the way in, and only ones nothing can be using:
|
|
159
|
-
* the pid must not be running AND the dir must be older than the grace
|
|
160
|
-
* window, so a recycled pid cannot cost a live run its logs. */
|
|
161
|
-
function openRunDir(base: string): string {
|
|
162
|
-
// the pid plus the millisecond separates two PROCESSES; the counter
|
|
163
|
-
// separates two runTasks calls inside one (the TUI can start a second
|
|
164
|
-
// before the clock ticks), so no run ever writes into another's dir
|
|
165
|
-
const dir = join(base, `run-${process.pid}-${Date.now()}-${runSeq++}`)
|
|
166
|
-
mkdirSync(dir, { recursive: true })
|
|
167
|
-
const cutoff = Date.now() - RUN_DIR_KEEP_MS
|
|
168
|
-
try {
|
|
169
|
-
for (const name of readdirSync(base)) {
|
|
170
|
-
// run dirs AND the per-run release survey files, which a killed
|
|
171
|
-
// run leaves behind: both carry the pid and the start time, so one
|
|
172
|
-
// sweep covers both rather than one path leaking forever
|
|
173
|
-
const m = /^(?:run|releases)-(\d+)-(\d+)(?:-\d+)?(?:\.json(?:\.lock)?)?$/.exec(name)
|
|
174
|
-
if (m == null || name === basename(dir)) continue
|
|
175
|
-
if (Number(m[2]) > cutoff) continue
|
|
176
|
-
try { process.kill(Number(m[1]), 0); continue } catch (e) { if ((e as { code?: string }).code === 'EPERM') continue }
|
|
177
|
-
rmSync(join(base, name), { recursive: true, force: true })
|
|
178
|
-
}
|
|
179
|
-
} catch {
|
|
180
|
-
// an unreadable log base is a housekeeping miss, never a gate failure
|
|
181
|
-
}
|
|
182
|
-
return dir
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** how long a finished run's logs stay readable, and the pid-reuse guard */
|
|
186
|
-
const RUN_DIR_KEEP_MS = 6 * 60 * 60 * 1000
|
|
187
|
-
let runSeq = 0
|
|
188
|
-
|
|
189
|
-
export async function runTasks(o: RunOpts): Promise<RunSummary> {
|
|
190
|
-
const print = o.print ?? ((l: string) => console.log(l))
|
|
191
|
-
const color = o.color ?? (process.stdout.isTTY && process.env.NO_COLOR == null)
|
|
192
|
-
const sel = selectTasks(o)
|
|
193
|
-
if (sel.errors.length > 0) {
|
|
194
|
-
for (const e of sel.errors) print(formatLine({ kind: 'fail', text: e }, color))
|
|
195
|
-
return { ok: false, results: [], interrupted: false }
|
|
196
|
-
}
|
|
197
|
-
const selectedIds = new Set(sel.selected.map((t) => t.id))
|
|
198
|
-
const runDir = openRunDir(o.logDir)
|
|
199
|
-
|
|
200
|
-
const tasks: Live[] = sel.selected.map((t) => ({
|
|
201
|
-
...t,
|
|
202
|
-
status: 'skipped', exitCode: null, durationMs: 0, startedAt: 0,
|
|
203
|
-
logPath: t.cmd != null ? join(runDir, `${t.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
|
|
204
|
-
lastLine: '', note: '', lines: [],
|
|
205
|
-
}))
|
|
206
|
-
const byId = new Map(tasks.map((t) => [t.id, t]))
|
|
207
|
-
const queued = new Set(tasks.map((t) => t.id))
|
|
208
|
-
const done = new Map<string, Live>()
|
|
209
|
-
const running = new Map<string, Promise<void>>()
|
|
210
|
-
const cap = o.maxParallel ?? Math.max(2, Math.floor((navigator.hardwareConcurrency || 4) / 2))
|
|
211
|
-
let interrupted = false
|
|
212
|
-
let bailed = false
|
|
213
|
-
const procs = new Map<string, ReturnType<typeof Bun.spawn>>()
|
|
214
|
-
const killed = new Map<string, string>() // task id → reason
|
|
215
|
-
const killAll = (reason: string): void => {
|
|
216
|
-
for (const [id, proc] of procs) {
|
|
217
|
-
killed.set(id, reason)
|
|
218
|
-
try { proc.kill() } catch {}
|
|
219
|
-
setTimeout(() => { try { proc.kill('SIGKILL') } catch {} }, 2000)
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const depState = (t: Live): 'ready' | 'blocked' | 'dead' => {
|
|
224
|
-
for (const d of t.deps ?? []) {
|
|
225
|
-
if (!selectedIds.has(d)) continue // filtered dep = satisfied
|
|
226
|
-
const dep = done.get(d)
|
|
227
|
-
if (dep == null) return 'blocked'
|
|
228
|
-
if (dep.status !== 'pass') { t.note = `dep ${d} ${dep.status}`; return 'dead' }
|
|
229
|
-
}
|
|
230
|
-
return 'ready'
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const finish = (t: Live, status: Live['status'], code: number | null): void => {
|
|
234
|
-
t.status = status
|
|
235
|
-
t.exitCode = code
|
|
236
|
-
t.durationMs = t.startedAt > 0 ? Date.now() - t.startedAt : 0
|
|
237
|
-
done.set(t.id, t)
|
|
238
|
-
o.onTaskFinish?.({ id: t.id, status: t.status, exitCode: t.exitCode, durationMs: t.durationMs, logPath: t.logPath, lastLine: t.lastLine, note: t.note, lines: t.lines })
|
|
239
|
-
const dur = ` (${(t.durationMs / 1000).toFixed(1)}s)`
|
|
240
|
-
if (status === 'pass') {
|
|
241
|
-
// WARNs nag and skips stay visible (exempt contracts) even on pass
|
|
242
|
-
const surfaced = t.lines.filter((l) => l.kind === 'warn' || l.kind === 'fix-warn' || l.kind === 'skip')
|
|
243
|
-
print(formatLine({ kind: 'ok', text: `${t.id}${dur}` }, color))
|
|
244
|
-
for (const l of surfaced) print(formatLine(l, color))
|
|
245
|
-
} else if (status === 'fail') {
|
|
246
|
-
print(formatLine({ kind: 'fail', text: `${t.id}${dur}${t.lastLine !== '' ? ` — ${t.lastLine}` : ''}` }, color))
|
|
247
|
-
for (const l of t.lines.filter((l) => l.kind !== 'ok' && l.kind !== 'note')) print(formatLine(l, color))
|
|
248
|
-
if (t.logPath != null) print(formatLine({ kind: 'fix-fail', text: `full log: ${t.logPath}` }, color))
|
|
249
|
-
if (o.bail && !bailed) { bailed = true; killAll('bail') }
|
|
250
|
-
}
|
|
251
|
-
// skipped tasks report only in the summary — dep noise stays low
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const startTask = (t: Live): void => {
|
|
255
|
-
queued.delete(t.id)
|
|
256
|
-
t.startedAt = Date.now()
|
|
257
|
-
const p = (async (): Promise<void> => {
|
|
258
|
-
if (t.logic != null) {
|
|
259
|
-
const ctx = new CheckCtx(new Map())
|
|
260
|
-
try { await t.logic(ctx) } catch (e) { ctx.fail(`${t.id} threw: ${String(e).slice(0, 200)}`) }
|
|
261
|
-
t.lines = ctx.lines
|
|
262
|
-
// headline prefers the last FAIL — an exempt/skip line landing
|
|
263
|
-
// last must not masquerade as the failure
|
|
264
|
-
const last = ctx.lines.filter((l) => l.kind === 'fail').at(-1) ?? ctx.lines.at(-1)
|
|
265
|
-
t.lastLine = last != null ? last.text.slice(0, 200) : ''
|
|
266
|
-
finish(t, ctx.failed ? 'fail' : 'pass', ctx.failed ? 1 : 0)
|
|
267
|
-
return
|
|
268
|
-
}
|
|
269
|
-
const cmd = t.cmd!
|
|
270
|
-
const ms = t.timeoutMs ?? 600_000
|
|
271
|
-
// log stream opens only once the child actually spawned — a
|
|
272
|
-
// missing binary must not leave a dangling open on the log dir
|
|
273
|
-
let out: WriteStream | null = null
|
|
274
|
-
const r = await spawnWithTimeout(cmd, {
|
|
275
|
-
timeoutMs: ms,
|
|
276
|
-
env: { ...process.env, ...t.env },
|
|
277
|
-
cwd: t.cwd ?? o.cwd,
|
|
278
|
-
onSpawn: (proc) => {
|
|
279
|
-
out = createWriteStream(t.logPath!, { flags: 'a' })
|
|
280
|
-
procs.set(t.id, proc)
|
|
281
|
-
},
|
|
282
|
-
onChunk: (text) => {
|
|
283
|
-
out?.write(text)
|
|
284
|
-
for (const raw of text.split(/\r?\n/)) {
|
|
285
|
-
const s = stripAnsi(raw).trim()
|
|
286
|
-
if (s !== '') t.lastLine = s.slice(0, 200)
|
|
287
|
-
}
|
|
288
|
-
},
|
|
289
|
-
})
|
|
290
|
-
procs.delete(t.id)
|
|
291
|
-
// TS narrows `out` to its initializer across the await (assignment
|
|
292
|
-
// happens in the onSpawn closure) — widen explicitly
|
|
293
|
-
;(out as WriteStream | null)?.end()
|
|
294
|
-
// missing binary / spawn throw surface as a wire FAIL, never a crash
|
|
295
|
-
if (r.spawnFailed) { t.lastLine = r.out; finish(t, 'fail', -1); return }
|
|
296
|
-
const killReason = killed.get(t.id)
|
|
297
|
-
if (killReason != null) { t.note = `killed (${killReason})`; finish(t, 'skipped', r.code); return }
|
|
298
|
-
if (r.timedOut) t.lastLine = `timeout after ${ms}ms — ${t.lastLine}`
|
|
299
|
-
finish(t, r.code === 0 ? 'pass' : 'fail', r.code)
|
|
300
|
-
})().finally(() => running.delete(t.id))
|
|
301
|
-
running.set(t.id, p)
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const onSigint = (): void => { interrupted = true; killAll('interrupt') }
|
|
305
|
-
process.on('SIGINT', onSigint)
|
|
306
|
-
try {
|
|
307
|
-
while (queued.size > 0 || running.size > 0) {
|
|
308
|
-
if (interrupted || bailed) {
|
|
309
|
-
for (const id of [...queued]) { const t = byId.get(id)!; t.note = bailed ? 'bailed' : 'interrupted'; queued.delete(id); done.set(id, t) }
|
|
310
|
-
await Promise.all(running.values())
|
|
311
|
-
break
|
|
312
|
-
}
|
|
313
|
-
let started = false
|
|
314
|
-
for (const id of [...queued]) {
|
|
315
|
-
if (running.size >= cap) break
|
|
316
|
-
const t = byId.get(id)!
|
|
317
|
-
const st = depState(t)
|
|
318
|
-
if (st === 'dead') { queued.delete(id); finish(t, 'skipped', null); started = true }
|
|
319
|
-
else if (st === 'ready') { startTask(t); started = true }
|
|
320
|
-
}
|
|
321
|
-
if (running.size > 0) await Promise.race(running.values())
|
|
322
|
-
else if (!started && queued.size > 0) {
|
|
323
|
-
// nothing running, nothing startable — impossible unless a bug: die loud
|
|
324
|
-
for (const id of [...queued]) print(formatLine({ kind: 'fail', text: `${id}: scheduler stuck (unsatisfiable deps?)` }, color))
|
|
325
|
-
break
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
} finally {
|
|
329
|
-
process.off('SIGINT', onSigint)
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const results = tasks.map((t): TaskResult => ({ id: t.id, status: t.status, exitCode: t.exitCode, durationMs: t.durationMs, logPath: t.logPath, lastLine: t.lastLine, note: t.note, lines: t.lines }))
|
|
333
|
-
const failed = results.filter((r) => r.status === 'fail')
|
|
334
|
-
const skipped = results.filter((r) => r.status === 'skipped')
|
|
335
|
-
if (failed.length > 0) print(formatLine({ kind: 'fail', text: `${failed.length}/${results.length} failed: ${failed.map((r) => r.id).join(', ')}` }, color))
|
|
336
|
-
if (skipped.length > 0) print(formatLine({ kind: 'note', text: `skip: ${skipped.map((r) => `${r.id}${r.note !== '' ? ` (${r.note})` : ''}`).join(', ')}` }, color))
|
|
337
|
-
if (failed.length === 0 && !interrupted) print(formatLine({ kind: 'ok', text: `all ${results.filter((r) => r.status === 'pass').length} tasks green` }, color))
|
|
338
|
-
return { ok: failed.length === 0 && !interrupted, results, interrupted }
|
|
339
|
-
}
|
package/src/ledger/gate-units.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
// EVERY UNIT THE GATE RUNS, with what it declared about its own cacheability.
|
|
2
|
-
//
|
|
3
|
-
// Three registries feed one list, because a key layer that can only see tasks
|
|
4
|
-
// would leave the 25 checks and the 32 rules uncached forever, and those are
|
|
5
|
-
// most of the wall time. The declaration lives with the unit (a `TaskDef`
|
|
6
|
-
// field, a `Rule` field, an `export const inputs` in a check file), never in a
|
|
7
|
-
// table over here: a table beside the registry is a second source of truth
|
|
8
|
-
// that drifts the first time somebody adds a check.
|
|
9
|
-
import { relative } from 'path'
|
|
10
|
-
import { allTasks, type RepoConfig } from '../repo-config.ts'
|
|
11
|
-
import type { TaskDef } from '../core/task-runner.ts'
|
|
12
|
-
import { discoverChecks } from '../core/discover-checks.ts'
|
|
13
|
-
import { RULES } from '../rules/rule-registry.ts'
|
|
14
|
-
import { ruleCacheExtra } from '../rules/rule.ts'
|
|
15
|
-
import type { UnitDecl } from './git-key.ts'
|
|
16
|
-
|
|
17
|
-
/** the env var a repo's own suite reads to widen itself. It lives HERE because
|
|
18
|
-
* it rewrites every task's env, which is part of the key: a run that asked for
|
|
19
|
-
* the quick tier and one that asked for the full tier are different questions,
|
|
20
|
-
* and a surface that renders keys without applying it renders a tier nobody
|
|
21
|
-
* ran. */
|
|
22
|
-
export const FULL_TESTS_ENV = 'SHIPKIT_TEST_FULL'
|
|
23
|
-
|
|
24
|
-
export type TestTier = 'quick' | 'full'
|
|
25
|
-
|
|
26
|
-
/** the tier this process is asking for, absent an explicit choice */
|
|
27
|
-
export function envTier(): TestTier {
|
|
28
|
-
return process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick'
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** the repo's tasks as the gate will actually run them at `tier`. */
|
|
32
|
-
export function tieredTasks(c: RepoConfig, root: string, tier: TestTier): TaskDef[] {
|
|
33
|
-
const tasks = allTasks(c, root).tasks
|
|
34
|
-
return tier === 'full' ? tasks.map((t) => ({ ...t, env: { ...t.env, [FULL_TESTS_ENV]: '1' } })) : tasks
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** every task, rule and discovered check, in the order the gate meets them.
|
|
38
|
-
*
|
|
39
|
-
* `tasks` overrides what the repo resolves to, and the gate MUST pass what it
|
|
40
|
-
* is actually about to run: `--full` rewrites every task's env, and a key
|
|
41
|
-
* built from the unmodified list would let a quick run replay into a full one
|
|
42
|
-
* under one identity. */
|
|
43
|
-
export function gateUnits(root: string, c: RepoConfig, tasks?: TaskDef[]): UnitDecl[] {
|
|
44
|
-
return gateInventory(root, c, tasks).map((r) => r.decl)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** one gate unit with everything a reader asks about it, not only what the
|
|
48
|
-
* key layer asks. The cache half stays in `decl` so `UnitDecl` never grows
|
|
49
|
-
* fields the key layer must then decide to ignore. */
|
|
50
|
-
export type UnitRow = {
|
|
51
|
-
decl: UnitDecl
|
|
52
|
-
/** the display name an operator reads (a rule's title, else the id) */
|
|
53
|
-
title: string
|
|
54
|
-
/** does `shipkit ci` run it? false = PROACTIVE: `shipkit check` reports it,
|
|
55
|
-
* the gate never does, and it may sit red for months without anything
|
|
56
|
-
* being wrong with the change in front of you. */
|
|
57
|
-
gate: boolean
|
|
58
|
-
/** can a failure stop you? null = the unit never said AND the repo set no
|
|
59
|
-
* `checkDefaults.blocking`, which the check runner treats as fatal. */
|
|
60
|
-
blocking: boolean | null
|
|
61
|
-
/** where the answer is owned: a rule's wiki page, a check's file, a task's
|
|
62
|
-
* command. Null when the unit names none. */
|
|
63
|
-
owner: string | null
|
|
64
|
-
/** the group a task was filed under, for the few that declare one */
|
|
65
|
-
group: string | null
|
|
66
|
-
/** excluded from a default run unless named with `--with` */
|
|
67
|
-
optIn: boolean
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** every unit the gate knows, with its metadata. `gateUnits` is this list
|
|
71
|
-
* narrowed to the cache declaration, so the three registries are walked in
|
|
72
|
-
* exactly one place and a surface can never enumerate a different set than
|
|
73
|
-
* the one the key layer keyed. */
|
|
74
|
-
export function gateInventory(root: string, c: RepoConfig, tasks?: TaskDef[]): UnitRow[] {
|
|
75
|
-
const out: UnitRow[] = []
|
|
76
|
-
for (const t of tasks ?? allTasks(c, root).tasks) {
|
|
77
|
-
out.push({
|
|
78
|
-
decl: {
|
|
79
|
-
id: t.id,
|
|
80
|
-
kind: 'task',
|
|
81
|
-
inputs: t.inputs,
|
|
82
|
-
ttl: t.ttl,
|
|
83
|
-
never: t.never,
|
|
84
|
-
argv: t.cmd,
|
|
85
|
-
extra: t.env == null ? undefined : JSON.stringify(Object.entries(t.env).sort()),
|
|
86
|
-
},
|
|
87
|
-
title: t.id,
|
|
88
|
-
// an optIn task is off the default run, which is the same shape as
|
|
89
|
-
// a check that opted out of the gate
|
|
90
|
-
gate: t.optIn !== true,
|
|
91
|
-
// a task IS the build: it has no non-blocking mode to declare
|
|
92
|
-
blocking: true,
|
|
93
|
-
owner: t.cmd == null ? null : t.cmd.join(' '),
|
|
94
|
-
group: t.group ?? null,
|
|
95
|
-
optIn: t.optIn === true,
|
|
96
|
-
})
|
|
97
|
-
}
|
|
98
|
-
for (const r of [...RULES, ...(c.rules ?? [])]) {
|
|
99
|
-
out.push({
|
|
100
|
-
decl: {
|
|
101
|
-
id: r.id,
|
|
102
|
-
kind: 'rule',
|
|
103
|
-
inputs: r.inputs,
|
|
104
|
-
code: r.code,
|
|
105
|
-
ttl: r.ttl,
|
|
106
|
-
never: r.never,
|
|
107
|
-
extra: ruleCacheExtra(r, { root, config: c }),
|
|
108
|
-
},
|
|
109
|
-
title: r.title,
|
|
110
|
-
gate: true,
|
|
111
|
-
blocking: r.blocking,
|
|
112
|
-
owner: r.owner,
|
|
113
|
-
group: null,
|
|
114
|
-
optIn: false,
|
|
115
|
-
})
|
|
116
|
-
}
|
|
117
|
-
for (const d of discoverChecks(root)) {
|
|
118
|
-
const rel = relative(root, d.path).replace(/\\/g, '/')
|
|
119
|
-
out.push({
|
|
120
|
-
decl: {
|
|
121
|
-
id: d.name,
|
|
122
|
-
kind: 'check',
|
|
123
|
-
inputs: d.inputs ?? undefined,
|
|
124
|
-
ttl: d.ttl ?? undefined,
|
|
125
|
-
never: d.never ?? undefined,
|
|
126
|
-
code: [rel],
|
|
127
|
-
argv: ['bun', rel],
|
|
128
|
-
},
|
|
129
|
-
title: d.name,
|
|
130
|
-
gate: d.gate,
|
|
131
|
-
blocking: d.blocking ?? c.checkDefaults?.blocking ?? null,
|
|
132
|
-
owner: rel,
|
|
133
|
-
group: null,
|
|
134
|
-
optIn: false,
|
|
135
|
-
})
|
|
136
|
-
}
|
|
137
|
-
return out
|
|
138
|
-
}
|