rvlib-shipkit 0.38.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
|
@@ -0,0 +1,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/hooks/leak-guard.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
import { createHash } from 'crypto'
|
|
19
19
|
import { existsSync, readFileSync } from 'fs'
|
|
20
20
|
import { basename, join } from 'path'
|
|
21
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
22
|
+
import { KEYWORD_FILE, MACHINE_PRIVATE } from '../core/layout.ts'
|
|
21
23
|
|
|
22
24
|
export type LeakHit = { where: string; pattern: string; file?: string }
|
|
23
25
|
export type LeakWaiver = LeakHit & { reason: string; source: string }
|
|
@@ -227,11 +229,11 @@ export function leakAllowProblems(entries: unknown, where: string, tracked: bool
|
|
|
227
229
|
|
|
228
230
|
/** the machine-global list every repo's guard also reads: one private list
|
|
229
231
|
* per machine, never copied into a repo (a copy rots, and a not-yet-ignored
|
|
230
|
-
*
|
|
232
|
+
* private folder would make the list itself committable). */
|
|
231
233
|
export function globalKeywordFile(): string | null {
|
|
232
234
|
const home = process.env.HOME ?? ''
|
|
233
235
|
if (home === '') return null
|
|
234
|
-
const p = join(home,
|
|
236
|
+
const p = join(home, MACHINE_PRIVATE, 'banned-keywords.txt')
|
|
235
237
|
return existsSync(p) ? p : null
|
|
236
238
|
}
|
|
237
239
|
|
|
@@ -240,7 +242,7 @@ export function globalKeywordFile(): string | null {
|
|
|
240
242
|
export function globalAllowFile(): string | null {
|
|
241
243
|
const home = process.env.HOME ?? ''
|
|
242
244
|
if (home === '') return null
|
|
243
|
-
const p = join(home,
|
|
245
|
+
const p = join(home, MACHINE_PRIVATE, 'leak-allow.json')
|
|
244
246
|
return existsSync(p) ? p : null
|
|
245
247
|
}
|
|
246
248
|
|
|
@@ -263,7 +265,7 @@ export function loadAllowFile(path: string, tracked: boolean): { entries: Loaded
|
|
|
263
265
|
}
|
|
264
266
|
|
|
265
267
|
export type LeakGuardOpts = {
|
|
266
|
-
/** path to the keyword file, relative to root (default
|
|
268
|
+
/** path to the keyword file, relative to root (default .shipkit/private/banned-keywords.txt) */
|
|
267
269
|
keywordFile?: string
|
|
268
270
|
/** extra text to scan (a commit message, a release note) */
|
|
269
271
|
text?: { label: string; content: string }[]
|
|
@@ -334,10 +336,10 @@ export function addedLinesByFile(diff: string): { file: string; content: string
|
|
|
334
336
|
}
|
|
335
337
|
|
|
336
338
|
export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResult {
|
|
337
|
-
const rel = o.keywordFile ??
|
|
339
|
+
const rel = o.keywordFile ?? KEYWORD_FILE
|
|
338
340
|
const file = join(root, rel)
|
|
339
341
|
const sources = [existsSync(file) ? file : null, globalKeywordFile()].filter((f): f is string => f != null)
|
|
340
|
-
if (sources.length === 0) return { status: 'skipped', reason: `${rel} not found and no
|
|
342
|
+
if (sources.length === 0) return { status: 'skipped', reason: `${rel} not found and no machine-global banned-keywords.txt — no keyword list, no leak guard` }
|
|
341
343
|
const patterns = parsePatterns(sources.map((f) => readFileSync(f, 'utf8')).join('\n'))
|
|
342
344
|
const count = patterns.substrings.length + patterns.words.length + patterns.regexes.length
|
|
343
345
|
if (count === 0) return { status: 'skipped', reason: `keyword file(s) have no patterns (${sources.join(', ')})` }
|
|
@@ -358,11 +360,11 @@ export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResu
|
|
|
358
360
|
|
|
359
361
|
const targets: ScanTarget[] = []
|
|
360
362
|
if (o.staged !== false) {
|
|
361
|
-
const diff = Bun.spawnSync([
|
|
363
|
+
const diff = Bun.spawnSync([gitBin(), 'diff', '--cached'], { cwd: root, maxBuffer: 256 * 1024 * 1024 })
|
|
362
364
|
if (diff.exitCode === 0)
|
|
363
365
|
for (const f of addedLinesByFile(diff.stdout.toString())) targets.push({ where: 'staged content', file: f.file, content: f.content })
|
|
364
366
|
// a filename can leak as loudly as a line of code
|
|
365
|
-
const names = Bun.spawnSync([
|
|
367
|
+
const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
|
|
366
368
|
if (names.exitCode === 0) for (const n of names.stdout.toString().split('\n')) if (n.trim() !== '') targets.push({ where: 'staged path', file: n, content: n })
|
|
367
369
|
}
|
|
368
370
|
for (const t of o.text ?? []) targets.push({ where: t.label, content: t.content })
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// the PreToolUse guard for ONE WAY IN (core/one-way-in-policy.ts owns the
|
|
3
|
-
// table and the wording).
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// table and the wording). It refuses a raw runner with the shipkit command to
|
|
4
|
+
// type instead: `permissions.deny` already blocks these, but a bare deny tells
|
|
5
|
+
// the model only that it was denied, and a refusal with no replacement just
|
|
6
|
+
// sends it looking for another way in. It also refuses a gate piped into
|
|
7
|
+
// another command, whose exit code the pipe hides.
|
|
7
8
|
//
|
|
8
9
|
// It runs before EVERY Bash call, so it stays a leaf: one tiny import, one
|
|
9
10
|
// small json read, no clipanion, no repo config. Anything it cannot decide it
|
|
10
11
|
// waves through, because a guard that fails closed on its own bugs would wedge
|
|
11
12
|
// a repo with no way to tell why, and `permissions.deny` is still the floor
|
|
12
13
|
// underneath it. Measured at ~10ms per call.
|
|
13
|
-
import { deniedHeads, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
|
|
14
|
+
import { deniedHeads, pipedGateIn, pipedGateRefusal, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
|
|
14
15
|
|
|
15
16
|
const input = await Bun.stdin.text()
|
|
16
17
|
let command = ''
|
|
@@ -21,6 +22,13 @@ try {
|
|
|
21
22
|
} catch {
|
|
22
23
|
process.exit(0)
|
|
23
24
|
}
|
|
25
|
+
// a piped gate is refused whatever the deny list says: it is not a raw
|
|
26
|
+
// runner, it is a verdict whose exit code the tail of the pipe replaces
|
|
27
|
+
const piped = pipedGateIn(command)
|
|
28
|
+
if (piped != null) {
|
|
29
|
+
console.error(pipedGateRefusal(piped))
|
|
30
|
+
process.exit(2)
|
|
31
|
+
}
|
|
24
32
|
// the settings file is the authority on WHICH runners this repo refuses, so
|
|
25
33
|
// deleting one `Bash(...)` line lifts the deny and this refusal together
|
|
26
34
|
const settingsPath = `${process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd()}/.claude/settings.json`
|