rvlib-shipkit 0.38.1 → 1.0.1
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 +379 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/derived.ts +1 -1
- 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} +49 -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 +16 -3
- 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 +257 -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 +616 -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 +88 -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 +152 -189
- package/src/core/run-fix.ts +99 -0
- package/src/core/run-fixers.ts +64 -33
- package/src/core/self-update.ts +11 -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/fleet/scan-fleet.ts +16 -0
- package/src/hooks/leak-guard.ts +20 -9
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +38 -26
- 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 +157 -88
- 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 +9 -4
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +209 -15
- 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/scan-md.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,401 @@
|
|
|
1
|
+
// THE MANAGED BLOCK in `repo.config.ts`, so a switch on a page can move a
|
|
2
|
+
// check without a second source of truth.
|
|
3
|
+
//
|
|
4
|
+
// The config is the ONE owner of what runs in this repo. A shadow store the
|
|
5
|
+
// hub kept beside it would rot the first time somebody edited the file by
|
|
6
|
+
// hand, which is the failure mode a control room cannot afford, so the hub
|
|
7
|
+
// writes the config itself. It writes only the bytes between two markers, the
|
|
8
|
+
// pattern this toolkit already uses twice (rule SK004 inside `.gitignore`,
|
|
9
|
+
// `shipkit commands --out` inside a docs page).
|
|
10
|
+
//
|
|
11
|
+
// The paranoia, in order:
|
|
12
|
+
// 1. the file must not have moved since the caller read it (`expect`)
|
|
13
|
+
// 2. the block must still MATCH what the config loads, or a hand edit the
|
|
14
|
+
// writer cannot account for would be silently overwritten
|
|
15
|
+
// 3. a `when` or `tests` declared outside the block is refused, because a
|
|
16
|
+
// second declaration is a duplicate key rather than a merge
|
|
17
|
+
// 4. the value is validated the way `defineRepo` validates it, before it
|
|
18
|
+
// reaches the file
|
|
19
|
+
// 5. the result is read back through the SAME loader the cli uses, and the
|
|
20
|
+
// previous bytes are restored when it does not round trip
|
|
21
|
+
//
|
|
22
|
+
// No external formatter runs over the file. The block is rendered in the
|
|
23
|
+
// file's own indentation and quote style, and a formatter the repo scoped to
|
|
24
|
+
// `src/` would land a whole file diff for a one line toggle.
|
|
25
|
+
import { execFileSync } from 'child_process'
|
|
26
|
+
import { createHash } from 'crypto'
|
|
27
|
+
import { existsSync, readFileSync } from 'fs'
|
|
28
|
+
import { join } from 'path'
|
|
29
|
+
import { atomicWrite } from './locked-write.ts'
|
|
30
|
+
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
31
|
+
import { parseWhen } from './when.ts'
|
|
32
|
+
import type { SlowSuite, TestsConfig } from '../suite/judge-suites.ts'
|
|
33
|
+
import { normWord, proseWordsProblems, type ProseAllowWord, type ProseBanWord, type ProseWords } from '../prose/ste.ts'
|
|
34
|
+
|
|
35
|
+
export const GATE_BEGIN = '// >>> shipkit gate — managed block, the hub writes it, edit outside the markers'
|
|
36
|
+
export const GATE_END = '// <<< shipkit gate'
|
|
37
|
+
/** the second managed block, INSIDE the `prose: {` object: the repo's own
|
|
38
|
+
* word lists for rule SK014. Same writer, same paranoia, its own markers */
|
|
39
|
+
export const WORDS_BEGIN = '// >>> shipkit prose words — managed block, the hub writes it, edit outside the markers'
|
|
40
|
+
export const WORDS_END = '// <<< shipkit prose words'
|
|
41
|
+
|
|
42
|
+
/** every key this block owns. A repo declaring one of them outside the
|
|
43
|
+
* markers keeps it, and the block refuses to write rather than duplicate it */
|
|
44
|
+
const OWNED = ['when', 'tests'] as const
|
|
45
|
+
|
|
46
|
+
export type BlockState = 'missing-file' | 'missing-block' | 'stale' | 'current'
|
|
47
|
+
|
|
48
|
+
export type GateBlock = {
|
|
49
|
+
path: string
|
|
50
|
+
/** sha256 of the file as it is right now — the caller hands it back on
|
|
51
|
+
* the write, and a mismatch means somebody else edited it meanwhile */
|
|
52
|
+
hash: string
|
|
53
|
+
state: BlockState
|
|
54
|
+
when: Record<string, string>
|
|
55
|
+
tests: TestsConfig
|
|
56
|
+
/** true when a key this block owns is declared outside the markers */
|
|
57
|
+
declaredOutside: boolean
|
|
58
|
+
/** the prose word lists as the config loads them */
|
|
59
|
+
words: ProseWords
|
|
60
|
+
/** the words block: `no-prose` when the config has no `prose: {` object
|
|
61
|
+
* to write into, and the lists then mean nothing to rule SK014 anyway */
|
|
62
|
+
wordsState: BlockState | 'no-prose'
|
|
63
|
+
/** true when `prose.words` is declared outside the words markers */
|
|
64
|
+
wordsOutside: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type GateEdit = {
|
|
68
|
+
/** check id → the value, or null to drop the entry */
|
|
69
|
+
when?: Record<string, string | null>
|
|
70
|
+
/** suites to declare slow, each with the case `defineRepo` demands */
|
|
71
|
+
slow?: SlowSuite[]
|
|
72
|
+
/** suite files to drop back to the quick tier */
|
|
73
|
+
slowRemove?: string[]
|
|
74
|
+
/** the prose word lists: entries to add or replace (keyed by word), and
|
|
75
|
+
* words to drop from either list */
|
|
76
|
+
words?: WordsEdit
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type WordsEdit = {
|
|
80
|
+
allow?: ProseAllowWord[]
|
|
81
|
+
ban?: ProseBanWord[]
|
|
82
|
+
/** words to drop, from whichever list holds them */
|
|
83
|
+
remove?: string[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const sha = (s: string): string => createHash('sha256').update(s).digest('hex').slice(0, 16)
|
|
87
|
+
|
|
88
|
+
const configPath = (root: string): string => join(root, 'repo.config.ts')
|
|
89
|
+
|
|
90
|
+
/** the file's own indentation check, so the block reads like its neighbours */
|
|
91
|
+
function indentOf(lines: string[]): string {
|
|
92
|
+
for (const l of lines) {
|
|
93
|
+
const m = /^([ \t]+)\S/.exec(l)
|
|
94
|
+
if (m != null) return m[1]!
|
|
95
|
+
}
|
|
96
|
+
return ' '
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const isBegin = (l: string): boolean => l.trim().startsWith('// >>> shipkit gate')
|
|
100
|
+
const isEnd = (l: string): boolean => l.trim().startsWith('// <<< shipkit gate')
|
|
101
|
+
const isWordsBegin = (l: string): boolean => l.trim().startsWith('// >>> shipkit prose words')
|
|
102
|
+
const isWordsEnd = (l: string): boolean => l.trim().startsWith('// <<< shipkit prose words')
|
|
103
|
+
/** the one line the words block can be written under: `prose: {` alone */
|
|
104
|
+
const isProseOpen = (l: string): boolean => /^\s*prose:\s*\{\s*$/.test(l)
|
|
105
|
+
|
|
106
|
+
/** a single quoted TS string literal, escapes and all */
|
|
107
|
+
const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
108
|
+
/** an object key: bare when it is a plain identifier, quoted otherwise */
|
|
109
|
+
const k = (s: string): string => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : q(s))
|
|
110
|
+
|
|
111
|
+
/** the bytes between the markers, markers included */
|
|
112
|
+
export function renderGateBlock(when: Record<string, string>, tests: TestsConfig, pad: string): string[] {
|
|
113
|
+
const out = [`${pad}${GATE_BEGIN}`]
|
|
114
|
+
const ids = Object.keys(when).sort()
|
|
115
|
+
if (ids.length > 0) {
|
|
116
|
+
out.push(`${pad}when: {`)
|
|
117
|
+
for (const id of ids) out.push(`${pad}${pad}${k(id)}: ${q(when[id]!)},`)
|
|
118
|
+
out.push(`${pad}},`)
|
|
119
|
+
}
|
|
120
|
+
const slow = tests.slow ?? []
|
|
121
|
+
const serial = tests.serial ?? []
|
|
122
|
+
if (tests.budgetMs != null || slow.length > 0 || serial.length > 0) {
|
|
123
|
+
out.push(`${pad}tests: {`)
|
|
124
|
+
if (tests.budgetMs != null) out.push(`${pad}${pad}budgetMs: ${tests.budgetMs},`)
|
|
125
|
+
for (const [name, entries, keys] of [['slow', slow, ['file', 'catches', 'whyNotFast']], ['serial', serial, ['file', 'why']]] as const) {
|
|
126
|
+
if (entries.length === 0) continue
|
|
127
|
+
out.push(`${pad}${pad}${name}: [`)
|
|
128
|
+
for (const e of entries) {
|
|
129
|
+
out.push(`${pad}${pad}${pad}{`)
|
|
130
|
+
for (const f of keys) out.push(`${pad}${pad}${pad}${pad}${f}: ${q(String((e as Record<string, unknown>)[f] ?? ''))},`)
|
|
131
|
+
out.push(`${pad}${pad}${pad}},`)
|
|
132
|
+
}
|
|
133
|
+
out.push(`${pad}${pad}],`)
|
|
134
|
+
}
|
|
135
|
+
out.push(`${pad}},`)
|
|
136
|
+
}
|
|
137
|
+
out.push(`${pad}${GATE_END}`)
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** the bytes between the words markers, markers included. Entries sorted by
|
|
142
|
+
* word, so the block is a function of the lists and a reorder by hand reads
|
|
143
|
+
* as a hand edit */
|
|
144
|
+
export function renderWordsBlock(words: ProseWords, pad: string, unit = ' '): string[] {
|
|
145
|
+
const out = [`${pad}${WORDS_BEGIN}`]
|
|
146
|
+
const allow = [...(words.allow ?? [])].sort((a, b) => normWord(a.word).localeCompare(normWord(b.word)))
|
|
147
|
+
const ban = [...(words.ban ?? [])].sort((a, b) => normWord(a.word).localeCompare(normWord(b.word)))
|
|
148
|
+
if (allow.length > 0 || ban.length > 0) {
|
|
149
|
+
out.push(`${pad}words: {`)
|
|
150
|
+
const entry = (e: object, keys: string[]): string => {
|
|
151
|
+
const r = e as Record<string, unknown>
|
|
152
|
+
return `{ ${keys.filter((f) => typeof r[f] === 'string' && r[f] !== '').map((f) => `${f}: ${q(r[f] as string)}`).join(', ')} },`
|
|
153
|
+
}
|
|
154
|
+
const p1 = pad + unit
|
|
155
|
+
const p2 = p1 + unit
|
|
156
|
+
if (allow.length > 0) {
|
|
157
|
+
out.push(`${p1}allow: [`)
|
|
158
|
+
for (const a of allow) out.push(`${p2}${entry(a, ['word', 'reason'])}`)
|
|
159
|
+
out.push(`${p1}],`)
|
|
160
|
+
}
|
|
161
|
+
if (ban.length > 0) {
|
|
162
|
+
out.push(`${p1}ban: [`)
|
|
163
|
+
for (const b of ban) out.push(`${p2}${entry(b, ['word', 'to', 'reason'])}`)
|
|
164
|
+
out.push(`${p1}],`)
|
|
165
|
+
}
|
|
166
|
+
out.push(`${pad}},`)
|
|
167
|
+
}
|
|
168
|
+
out.push(`${pad}${WORDS_END}`)
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** the words the loader read, in the shape the renderer writes: empty
|
|
173
|
+
* strings dropped, so a round trip compares content and not spelling */
|
|
174
|
+
const cleanWords = (w: ProseWords | undefined): ProseWords => {
|
|
175
|
+
const strip = <T extends object>(e: T): T => Object.fromEntries(Object.entries(e).filter(([, v]) => v != null && v !== '')) as T
|
|
176
|
+
const out: ProseWords = {}
|
|
177
|
+
if ((w?.allow ?? []).length > 0) out.allow = w!.allow!.map(strip)
|
|
178
|
+
if ((w?.ban ?? []).length > 0) out.ban = w!.ban!.map(strip)
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** the indentation one level inside the line at `at` */
|
|
183
|
+
const innerPad = (lines: string[], at: number): string => {
|
|
184
|
+
const own = /^([ \t]*)/.exec(lines[at] ?? '')?.[1] ?? ''
|
|
185
|
+
const unit = indentOf(lines)
|
|
186
|
+
return own + unit
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** where the words block sits and what state it is in */
|
|
190
|
+
function locateWords(lines: string[], c: RepoConfig): { state: BlockState | 'no-prose'; b: number; e: number; open: number; pad: string; outside: boolean } {
|
|
191
|
+
const b = lines.findIndex(isWordsBegin)
|
|
192
|
+
const e = lines.findIndex(isWordsEnd)
|
|
193
|
+
const open = lines.findIndex(isProseOpen)
|
|
194
|
+
const words = cleanWords(c.prose?.words)
|
|
195
|
+
if (b !== -1 && e !== -1 && e > b) {
|
|
196
|
+
const pad = /^([ \t]*)/.exec(lines[b]!)?.[1] ?? ''
|
|
197
|
+
const current = lines.slice(b, e + 1).join('\n')
|
|
198
|
+
const expected = renderWordsBlock(words, pad, indentOf(lines)).join('\n')
|
|
199
|
+
return { state: current === expected ? 'current' : 'stale', b, e, open, pad, outside: false }
|
|
200
|
+
}
|
|
201
|
+
if (open === -1) return { state: 'no-prose', b: -1, e: -1, open, pad: '', outside: c.prose?.words != null }
|
|
202
|
+
return { state: 'missing-block', b: -1, e: -1, open, pad: innerPad(lines, open), outside: c.prose?.words != null }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** is repo.config.ts edited and not yet committed? The switch leaves it
|
|
206
|
+
* DIRTY on purpose: a toggle is a change its owner reviews in a git client,
|
|
207
|
+
* never a commit an agent makes for them. So the page has to say so. */
|
|
208
|
+
export function configDirty(root: string): boolean {
|
|
209
|
+
try {
|
|
210
|
+
// stderr is swallowed on purpose: "not a git repository" is an
|
|
211
|
+
// ANSWER here, and printing it into every hub request is noise
|
|
212
|
+
return execFileSync('git', ['status', '--porcelain', '--', 'repo.config.ts'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() !== ''
|
|
213
|
+
} catch {
|
|
214
|
+
// no git, or no repository: dirt is unknowable rather than absent
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** what the block holds today, and whether it still describes the config */
|
|
220
|
+
export async function readGateBlock(root: string): Promise<GateBlock> {
|
|
221
|
+
const path = configPath(root)
|
|
222
|
+
if (!existsSync(path)) return { path, hash: '', state: 'missing-file', when: {}, tests: {}, declaredOutside: false, words: {}, wordsState: 'no-prose', wordsOutside: false }
|
|
223
|
+
const text = readFileSync(path, 'utf8')
|
|
224
|
+
const lines = text.split('\n')
|
|
225
|
+
const b = lines.findIndex(isBegin)
|
|
226
|
+
const e = lines.findIndex(isEnd)
|
|
227
|
+
const c = await loadRepoConfig(root, { fresh: true })
|
|
228
|
+
const when = c.when ?? {}
|
|
229
|
+
const tests = c.tests ?? {}
|
|
230
|
+
const hash = sha(text)
|
|
231
|
+
const w = locateWords(lines, c)
|
|
232
|
+
const wordsPart = { words: cleanWords(c.prose?.words), wordsState: w.state, wordsOutside: w.outside }
|
|
233
|
+
if (b === -1 || e === -1 || e < b) {
|
|
234
|
+
// no block: the keys it owns must be unclaimed, or a write would be a
|
|
235
|
+
// second declaration of the same key rather than an edit of the first
|
|
236
|
+
const declaredOutside = OWNED.some((key) => c[key] != null)
|
|
237
|
+
return { path, hash, state: 'missing-block', when, tests, declaredOutside, ...wordsPart }
|
|
238
|
+
}
|
|
239
|
+
// the block DESCRIBES the config when re-rendering the loaded values
|
|
240
|
+
// reproduces it byte for byte. Anything else (a hand edit, a second
|
|
241
|
+
// declaration merging in, a formatter that reshaped it) is stale, and
|
|
242
|
+
// stale refuses instead of overwriting what somebody wrote
|
|
243
|
+
const pad = indentOf(lines.slice(b + 1, e))
|
|
244
|
+
const current = lines.slice(b, e + 1).join('\n')
|
|
245
|
+
const expected = renderGateBlock(when, tests, pad === '' ? indentOf(lines) : pad).join('\n')
|
|
246
|
+
return { path, hash, state: current === expected ? 'current' : 'stale', when, tests, declaredOutside: false, ...wordsPart }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** does what the loader read back say what was written? BOTH keys the block
|
|
250
|
+
* owns, not just `when`: a `tests` entry that loads and parses differently
|
|
251
|
+
* (a renderer that drops a field, a loader that reshapes one) is exactly
|
|
252
|
+
* what reading the result back exists to catch, and comparing half of the
|
|
253
|
+
* block would let that half through. Key and array ORDER are the renderer's,
|
|
254
|
+
* so the comparison is by content. */
|
|
255
|
+
export function roundTripProblem(wrote: { when: Record<string, string>; tests: TestsConfig; words?: ProseWords }, loaded: RepoConfig): string | null {
|
|
256
|
+
const normWhen = (m: Record<string, string> | undefined): string => JSON.stringify(Object.entries(m ?? {}).sort())
|
|
257
|
+
const normTests = (t: TestsConfig | undefined): string =>
|
|
258
|
+
JSON.stringify({
|
|
259
|
+
budgetMs: t?.budgetMs ?? null,
|
|
260
|
+
slow: (t?.slow ?? []).map((s) => [s.file, s.catches, s.whyNotFast]).sort(),
|
|
261
|
+
serial: (t?.serial ?? []).map((s) => [s.file, s.why]).sort(),
|
|
262
|
+
})
|
|
263
|
+
if (normWhen(loaded.when) !== normWhen(wrote.when)) return `the loader read when ${JSON.stringify(loaded.when ?? {})}`
|
|
264
|
+
if (normTests(loaded.tests) !== normTests(wrote.tests)) return `the loader read tests ${JSON.stringify(loaded.tests ?? {})}`
|
|
265
|
+
if (wrote.words != null) {
|
|
266
|
+
const normWords = (x: ProseWords | undefined): string => {
|
|
267
|
+
const c = cleanWords(x)
|
|
268
|
+
const by = (a: { word: string }, b: { word: string }) => normWord(a.word).localeCompare(normWord(b.word))
|
|
269
|
+
return JSON.stringify({ allow: [...(c.allow ?? [])].sort(by), ban: [...(c.ban ?? [])].sort(by) })
|
|
270
|
+
}
|
|
271
|
+
if (normWords(loaded.prose?.words) !== normWords(wrote.words)) return `the loader read prose.words ${JSON.stringify(loaded.prose?.words ?? {})}`
|
|
272
|
+
}
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** the next word lists: an entry replaces the one with the same word in
|
|
277
|
+
* EITHER list (moving a word from allow to ban is one edit), then `remove`
|
|
278
|
+
* drops words. Judged the way defineRepo judges them */
|
|
279
|
+
export function nextWords(before: ProseWords, edit: WordsEdit): ProseWords {
|
|
280
|
+
const allow = [...(before.allow ?? [])]
|
|
281
|
+
const ban = [...(before.ban ?? [])]
|
|
282
|
+
const drop = (w: string): void => {
|
|
283
|
+
const n = normWord(w)
|
|
284
|
+
for (const list of [allow, ban] as { word: string }[][]) {
|
|
285
|
+
const at = list.findIndex((x) => normWord(x.word) === n)
|
|
286
|
+
if (at !== -1) list.splice(at, 1)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const trimmed = <T extends { word: string }>(e: T): T =>
|
|
290
|
+
Object.fromEntries(Object.entries(e).filter(([, v]) => typeof v === 'string' && v.trim() !== '').map(([key, v]) => [key, (v as string).trim()])) as T
|
|
291
|
+
for (const a of edit.allow ?? []) {
|
|
292
|
+
if (typeof a?.word !== 'string' || normWord(a.word) === '') throw new Error(`prose.words.allow needs a word`)
|
|
293
|
+
drop(a.word)
|
|
294
|
+
allow.push(trimmed({ word: a.word, reason: a.reason }))
|
|
295
|
+
}
|
|
296
|
+
for (const b of edit.ban ?? []) {
|
|
297
|
+
if (typeof b?.word !== 'string' || normWord(b.word) === '') throw new Error(`prose.words.ban needs a word`)
|
|
298
|
+
drop(b.word)
|
|
299
|
+
ban.push(trimmed({ word: b.word, to: b.to, reason: b.reason }))
|
|
300
|
+
}
|
|
301
|
+
for (const w of edit.remove ?? []) drop(w)
|
|
302
|
+
const out: ProseWords = {}
|
|
303
|
+
if (allow.length > 0) out.allow = allow
|
|
304
|
+
if (ban.length > 0) out.ban = ban
|
|
305
|
+
const problems = proseWordsProblems(out)
|
|
306
|
+
if (problems.length > 0) throw new Error(problems[0])
|
|
307
|
+
return out
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** the ONE write path, for the switch, the slow suite form and the prose word
|
|
311
|
+
* lists alike. Each block is rewritten only when the edit touches it, so a
|
|
312
|
+
* word edit never inserts an empty gate block, and the reverse */
|
|
313
|
+
export async function writeGateBlock(root: string, edit: GateEdit, o: { expect?: string } = {}): Promise<GateBlock> {
|
|
314
|
+
const before = await readGateBlock(root)
|
|
315
|
+
if (before.state === 'missing-file') throw new Error(`no repo.config.ts in ${root} — run: shipkit init`)
|
|
316
|
+
if (o.expect != null && o.expect !== before.hash)
|
|
317
|
+
throw new Error(`repo.config.ts changed since you read it, reload the page and set it again, so this edit cannot overwrite one made elsewhere`)
|
|
318
|
+
const touchGate = edit.when != null || edit.slow != null || edit.slowRemove != null
|
|
319
|
+
const touchWords = edit.words != null
|
|
320
|
+
if (!touchGate && !touchWords) throw new Error(`nothing to write: the edit names no when, no slow suite and no prose word`)
|
|
321
|
+
if (touchGate && before.state === 'stale')
|
|
322
|
+
throw new Error(`the shipkit gate block in repo.config.ts no longer matches what the config loads, so it was hand edited or something else declares these keys — reconcile it by hand, then use the switch again`)
|
|
323
|
+
if (touchGate && before.state === 'missing-block' && before.declaredOutside)
|
|
324
|
+
throw new Error(`repo.config.ts declares \`when\` or \`tests\` outside the managed block — move that declaration between the markers first, or the switch would write a duplicate key`)
|
|
325
|
+
if (touchWords && before.wordsState === 'no-prose')
|
|
326
|
+
throw new Error(`repo.config.ts has no \`prose: {\` line to write the word lists under — declare \`prose: { include: [...] }\` first, the lists only matter to a repo rule SK014 checks`)
|
|
327
|
+
if (touchWords && before.wordsState === 'stale')
|
|
328
|
+
throw new Error(`the shipkit prose words block in repo.config.ts no longer matches what the config loads, so it was hand edited — reconcile it by hand, then edit the lists again`)
|
|
329
|
+
if (touchWords && before.wordsState === 'missing-block' && before.wordsOutside)
|
|
330
|
+
throw new Error(`repo.config.ts declares \`prose.words\` outside the managed block — move it between the markers first, or the page would write a duplicate key`)
|
|
331
|
+
|
|
332
|
+
// the next values, validated the way defineRepo validates them, BEFORE
|
|
333
|
+
// anything reaches the file
|
|
334
|
+
const when: Record<string, string> = { ...before.when }
|
|
335
|
+
for (const [id, raw] of Object.entries(edit.when ?? {})) {
|
|
336
|
+
if (raw == null) { delete when[id]; continue }
|
|
337
|
+
const w = parseWhen(raw)
|
|
338
|
+
if (typeof w === 'string') throw new Error(`when.${id} ${w}`)
|
|
339
|
+
when[id] = raw
|
|
340
|
+
}
|
|
341
|
+
const slow = [...(before.tests.slow ?? [])]
|
|
342
|
+
for (const s of edit.slow ?? []) {
|
|
343
|
+
for (const f of ['catches', 'whyNotFast'] as const) {
|
|
344
|
+
if (typeof s[f] !== 'string' || s[f].trim() === '')
|
|
345
|
+
throw new Error(`tests.slow ${s.file} needs ${f}: ${f === 'catches' ? 'the regression this suite catches that no fast test can' : 'why the cost cannot come down, and what you tried'}`)
|
|
346
|
+
}
|
|
347
|
+
if (typeof s.file !== 'string' || s.file.trim() === '') throw new Error(`tests.slow needs a file`)
|
|
348
|
+
const at = slow.findIndex((x) => x.file === s.file)
|
|
349
|
+
if (at === -1) slow.push({ file: s.file, catches: s.catches, whyNotFast: s.whyNotFast })
|
|
350
|
+
else slow[at] = { file: s.file, catches: s.catches, whyNotFast: s.whyNotFast }
|
|
351
|
+
}
|
|
352
|
+
const dropped = new Set(edit.slowRemove ?? [])
|
|
353
|
+
const tests: TestsConfig = { ...before.tests, slow: slow.filter((s) => !dropped.has(s.file)) }
|
|
354
|
+
const words = touchWords ? nextWords(before.words, edit.words!) : undefined
|
|
355
|
+
|
|
356
|
+
const text = readFileSync(before.path, 'utf8')
|
|
357
|
+
let out = text.split('\n')
|
|
358
|
+
if (touchGate) {
|
|
359
|
+
const lines = out
|
|
360
|
+
const b = lines.findIndex(isBegin)
|
|
361
|
+
const e = lines.findIndex(isEnd)
|
|
362
|
+
const pad = b === -1 ? indentOf(lines) : indentOf(lines.slice(b + 1, e)) || indentOf(lines)
|
|
363
|
+
const block = renderGateBlock(when, tests, pad)
|
|
364
|
+
if (b !== -1 && e !== -1) out = [...lines.slice(0, b), ...block, ...lines.slice(e + 1)]
|
|
365
|
+
else {
|
|
366
|
+
// the anchor is the object literal `defineRepo({` opens. Without it
|
|
367
|
+
// there is nothing to write into, so the block is PRINTED for a
|
|
368
|
+
// human to paste rather than guessed at
|
|
369
|
+
const anchor = lines.findIndex((l) => /defineRepo\(\s*\{\s*$/.test(l))
|
|
370
|
+
if (anchor === -1)
|
|
371
|
+
throw new Error(`repo.config.ts has no \`defineRepo({\` line to write into — paste this block inside the config object by hand:\n${block.join('\n')}`)
|
|
372
|
+
out = [...lines.slice(0, anchor + 1), ...block, ...lines.slice(anchor + 1)]
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (words != null) {
|
|
376
|
+
const lines = out
|
|
377
|
+
const b = lines.findIndex(isWordsBegin)
|
|
378
|
+
const e = lines.findIndex(isWordsEnd)
|
|
379
|
+
const unit = indentOf(lines)
|
|
380
|
+
if (b !== -1 && e > b) {
|
|
381
|
+
const pad = /^([ \t]*)/.exec(lines[b]!)?.[1] ?? ''
|
|
382
|
+
out = [...lines.slice(0, b), ...renderWordsBlock(words, pad, unit), ...lines.slice(e + 1)]
|
|
383
|
+
} else {
|
|
384
|
+
const open = lines.findIndex(isProseOpen)
|
|
385
|
+
out = [...lines.slice(0, open + 1), ...renderWordsBlock(words, innerPad(lines, open), unit), ...lines.slice(open + 1)]
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
atomicWrite(before.path, out.join('\n'))
|
|
389
|
+
|
|
390
|
+
// read it back through the loader the cli uses. A block that does not
|
|
391
|
+
// round trip is a broken config, so the previous bytes go back
|
|
392
|
+
try {
|
|
393
|
+
const after = await loadRepoConfig(root, { fresh: true })
|
|
394
|
+
const problem = roundTripProblem({ when, tests, words }, after)
|
|
395
|
+
if (problem != null) throw new Error(`the block did not round trip: ${problem}`)
|
|
396
|
+
} catch (err) {
|
|
397
|
+
atomicWrite(before.path, text)
|
|
398
|
+
throw new Error(`repo.config.ts was left as it was: ${(err as Error).message}`)
|
|
399
|
+
}
|
|
400
|
+
return await readGateBlock(root)
|
|
401
|
+
}
|
package/src/core/when.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// WHERE A CHECK RUNS, in ONE declaration, keyed by check id.
|
|
2
|
+
//
|
|
3
|
+
// The question "does this run, and where" used to be answered in three
|
|
4
|
+
// unrelated places with three vocabularies: `ruleExempt` for a rule, `optIn`
|
|
5
|
+
// for a task, `export const gate` inside a check file. Nothing could read the
|
|
6
|
+
// three together, so no surface could show them on one row and no surface
|
|
7
|
+
// could write one.
|
|
8
|
+
//
|
|
9
|
+
// Four answers, and they are the words on screen:
|
|
10
|
+
// always every gate and every health pass
|
|
11
|
+
// release out of the quick gate, in at `--full` and on the release path
|
|
12
|
+
// health the health pass (`shipkit check`) only, never a gate
|
|
13
|
+
// off:<why> not run at all, and the reason is mandatory
|
|
14
|
+
//
|
|
15
|
+
// The third word says what it MEANS, never which command runs it: a value
|
|
16
|
+
// nobody can read is a switch nobody moves.
|
|
17
|
+
import type { TestTier, CheckRow } from '../ledger/gate-checks.ts'
|
|
18
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
19
|
+
|
|
20
|
+
export type WhenAt = 'always' | 'release' | 'health' | 'off'
|
|
21
|
+
|
|
22
|
+
/** which declaration is answering: the `when` key the switch writes, one of
|
|
23
|
+
* the check file's own `when`, one of the three spellings the key replaces,
|
|
24
|
+
* or the default nobody wrote */
|
|
25
|
+
export type WhenSource = 'when' | 'check-file' | 'ruleExempt' | 'optIn' | 'gate-export' | 'defaults' | 'default'
|
|
26
|
+
|
|
27
|
+
export type WhenDecl = { at: WhenAt; reason: string | null; source: WhenSource }
|
|
28
|
+
|
|
29
|
+
export const WHEN_AT: readonly WhenAt[] = ['always', 'release', 'health', 'off']
|
|
30
|
+
|
|
31
|
+
/** the words a config value may use */
|
|
32
|
+
const WORDS: Record<string, WhenAt> = { always: 'always', release: 'release', health: 'health' }
|
|
33
|
+
|
|
34
|
+
const DEFAULT: WhenDecl = { at: 'always', reason: null, source: 'default' }
|
|
35
|
+
|
|
36
|
+
/** one config value, or the problem with it. A string back is the message
|
|
37
|
+
* `defineRepo` throws, so a bad value dies at config load and never mid gate */
|
|
38
|
+
export function parseWhen(raw: unknown): WhenDecl | string {
|
|
39
|
+
if (typeof raw !== 'string') return `must be a string: always, release, health, or off:<reason>`
|
|
40
|
+
const v = raw.trim()
|
|
41
|
+
if (v === 'off' || v.startsWith('off:')) {
|
|
42
|
+
const reason = v.slice(4).trim()
|
|
43
|
+
if (reason === '') return `off needs a reason ('off:<why this repo turns it off>') — a check nobody can explain is a silenced gate`
|
|
44
|
+
return { at: 'off', reason, source: 'when' }
|
|
45
|
+
}
|
|
46
|
+
const at = WORDS[v]
|
|
47
|
+
if (at == null) return `'${v}' is not a when value (always, release, health, off:<reason>)`
|
|
48
|
+
return { at, reason: null, source: 'when' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** the explicit `when` entry for one id, or null. A value that does not parse
|
|
52
|
+
* is null here: `defineRepo` already refused it, so reaching this with one
|
|
53
|
+
* means the config was never loaded through it */
|
|
54
|
+
export function declaredWhen(c: RepoConfig, id: string): WhenDecl | null {
|
|
55
|
+
const raw = c.when?.[id]
|
|
56
|
+
if (raw == null) return null
|
|
57
|
+
const w = parseWhen(raw)
|
|
58
|
+
return typeof w === 'string' ? null : w
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** a rule: `when` wins, then `ruleExempt`, which is off with its reason */
|
|
62
|
+
export function whenOfRule(c: RepoConfig, id: string): WhenDecl {
|
|
63
|
+
const w = declaredWhen(c, id)
|
|
64
|
+
if (w != null) return w
|
|
65
|
+
const exempt = c.ruleExempt?.[id]
|
|
66
|
+
if (exempt != null) return { at: 'off', reason: exempt, source: 'ruleExempt' }
|
|
67
|
+
return DEFAULT
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** a task: `when` wins, then `optIn`, which means off the default gate and
|
|
71
|
+
* reachable by name (`--with <id>`), which is what `health` means for a task */
|
|
72
|
+
export function whenOfTask(c: RepoConfig, t: { id: string; optIn?: boolean }): WhenDecl {
|
|
73
|
+
const w = declaredWhen(c, t.id)
|
|
74
|
+
if (w != null) return w
|
|
75
|
+
if (t.optIn === true) return { at: 'health', reason: null, source: 'optIn' }
|
|
76
|
+
return DEFAULT
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** a floor slot the repo turned off with `defaults: { <id>: false }`. That
|
|
80
|
+
* declaration REMOVES the task, so it wins over any `when`: a row claiming
|
|
81
|
+
* always over a slot that resolves to nothing is a check the gate never
|
|
82
|
+
* runs and nobody can see. The source is the `defaults` block itself, and
|
|
83
|
+
* the reason names the line, so a page can say which declaration answered */
|
|
84
|
+
export function whenOfDisabledSlot(id: string): WhenDecl {
|
|
85
|
+
return { at: 'off', reason: `defaults.${id}: false`, source: 'defaults' }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** a discovered check: the repo's `when` wins, then the file's own `when`,
|
|
89
|
+
* so a config can move a check whose file the repo does not own */
|
|
90
|
+
export function whenOfCheck(c: RepoConfig, u: { id: string; def: { when?: string } }): WhenDecl {
|
|
91
|
+
const w = declaredWhen(c, u.id)
|
|
92
|
+
if (w != null) return w
|
|
93
|
+
// the legacy spelling still moves a check the repo does not own, and a
|
|
94
|
+
// repo statement wins over the file's own default
|
|
95
|
+
const exempt = c.ruleExempt?.[u.id]
|
|
96
|
+
if (exempt != null) return { at: 'off', reason: exempt, source: 'ruleExempt' }
|
|
97
|
+
if (u.def.when == null) return DEFAULT
|
|
98
|
+
const own = parseWhen(u.def.when)
|
|
99
|
+
// `check()` already refused anything unparseable, so reaching this with a
|
|
100
|
+
// string means the object never went through it
|
|
101
|
+
return typeof own === 'string' ? DEFAULT : { ...own, source: 'check-file' }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** a discovered check: `when` wins, then the file's own `export const gate` */
|
|
105
|
+
export function whenOfLegacyCheck(c: RepoConfig, d: { name: string; gate: boolean }): WhenDecl {
|
|
106
|
+
const w = declaredWhen(c, d.name)
|
|
107
|
+
if (w != null) return w
|
|
108
|
+
if (!d.gate) return { at: 'health', reason: null, source: 'gate-export' }
|
|
109
|
+
return DEFAULT
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** does the GATE run it at this tier? `shipkit check` asks `runsInCheck` */
|
|
113
|
+
export function runsInGate(w: WhenDecl, tier: TestTier): boolean {
|
|
114
|
+
if (w.at === 'off' || w.at === 'health') return false
|
|
115
|
+
return w.at === 'release' ? tier === 'full' : true
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** does the health pass run it? everything but off: `shipkit check` is where
|
|
119
|
+
* a proactive probe belongs, and only off means never */
|
|
120
|
+
export function runsInCheck(w: WhenDecl): boolean {
|
|
121
|
+
return w.at !== 'off'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** the ONE line a surface prints for a check that is off */
|
|
125
|
+
export function offLine(id: string, w: WhenDecl): string {
|
|
126
|
+
return `${id} off: ${w.reason ?? '(no reason)'}`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** ids in `when` that name no check of this repo. `defineRepo` can only check
|
|
130
|
+
* the rule namespace: a task is resolved from the floor and a check is
|
|
131
|
+
* discovered on disk, so this is where the rest is caught, loud */
|
|
132
|
+
export function unknownWhenIds(rows: CheckRow[], c: RepoConfig): string[] {
|
|
133
|
+
const known = new Set(rows.map((r) => r.decl.id))
|
|
134
|
+
return Object.keys(c.when ?? {}).filter((id) => !known.has(id))
|
|
135
|
+
}
|
package/src/core/write-status.ts
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// concurrent runners never interleave — the loser skips with a WARN
|
|
4
4
|
// outcome, never corrupts the file.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { mkdirSync } from 'fs'
|
|
7
|
+
import { dirname, join } from 'path'
|
|
7
8
|
import { atomicWrite, withFileLock } from './locked-write.ts'
|
|
9
|
+
import { LAYOUT } from './layout.ts'
|
|
8
10
|
|
|
9
11
|
export type StatusInput = {
|
|
10
12
|
/** repo name — headline */
|
|
@@ -34,7 +36,8 @@ const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '')
|
|
|
34
36
|
const RUN_SUMMARY = /^FAIL:?\s+\d+\/\d+ failed:/
|
|
35
37
|
|
|
36
38
|
export function writeStatus(root: string, s: StatusInput): StatusOutcome {
|
|
37
|
-
const target = join(root,
|
|
39
|
+
const target = join(root, LAYOUT.status)
|
|
40
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
38
41
|
const r = withFileLock(target, () => {
|
|
39
42
|
const clean = s.lines.map(stripAnsi)
|
|
40
43
|
const fails = clean.filter((l) => l.startsWith('FAIL') && !RUN_SUMMARY.test(l)).length
|
package/src/fleet/scan-fleet.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// so the fix travels through that one code path.
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
14
14
|
import { join } from 'path'
|
|
15
|
+
import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
|
|
15
16
|
|
|
16
17
|
export type Pkg = { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
17
18
|
|
|
@@ -140,3 +141,18 @@ export async function npmLatest(name: string): Promise<string | null> {
|
|
|
140
141
|
if (!res.ok) return null
|
|
141
142
|
return ((await res.json()) as Pkg).version ?? null
|
|
142
143
|
}
|
|
144
|
+
|
|
145
|
+
/** the first release whose SK005 updates through the manager the repo's lock
|
|
146
|
+
* files name. An older one runs bun whatever the lock says */
|
|
147
|
+
export const PM_AWARE_SINCE = '1.0.0'
|
|
148
|
+
|
|
149
|
+
/** why `fleet --update` must not hand this repo its own update, or null.
|
|
150
|
+
* The fleet runs the REPO's shipkit, so what matters is what THAT binary
|
|
151
|
+
* does: one older than PM_AWARE_SINCE updates an npm repo with bun */
|
|
152
|
+
export function updateRefusal(r: FleetRepo): string | null {
|
|
153
|
+
const pm = detectPackageManager(r.dir)
|
|
154
|
+
if (isRefusal(pm)) return `cannot tell which package manager installs it: ${pm.refused}`
|
|
155
|
+
const own = r.deps.find((d) => d.name === 'rvlib-shipkit')?.installed
|
|
156
|
+
if (pm.name === 'bun' || own == null || !isBehind(own, PM_AWARE_SINCE)) return null
|
|
157
|
+
return `its shipkit ${own} updates with bun whatever the lock says, and it installs with ${pm.name}. Run: cd ${r.dir} && ${pm.update(r.behind.map((d) => d.name)).join(' ')}`
|
|
158
|
+
}
|