rvlib-shipkit 0.13.0 → 0.16.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 +163 -0
- package/README.md +6 -22
- package/package.json +2 -2
- package/src/assets/kits/identity-kit.ts +1 -1
- package/src/assets/panels/geometry-rects.ts +3 -17
- package/src/assets/server/studio-server.ts +1 -1
- package/src/cli/cmd_Bump.ts +52 -0
- package/src/cli/cmd_Check.ts +2 -2
- package/src/cli/cmd_Ci.ts +10 -2
- package/src/cli/cmd_Commands.ts +126 -0
- package/src/cli/cmd_Hooks.ts +66 -0
- package/src/cli/cmd_Mv.ts +29 -6
- package/src/cli/cmd_Release.ts +41 -0
- package/src/cli/cmd_Releases.ts +42 -0
- package/src/cli/cmd_Rules.ts +20 -6
- package/src/cli/cmd_Start.ts +2 -2
- package/src/cli.ts +14 -1
- package/src/collections/posts.ts +1 -1
- package/src/collections/providers.ts +1 -1
- package/src/contracts/contract-types.ts +5 -2
- package/src/contracts/contracts-engine.ts +13 -3
- package/src/contracts/manifests/discord.contract.ts +2 -2
- package/src/contracts/manifests/identity.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +17 -2
- package/src/core/canonical-scripts.ts +9 -14
- package/src/core/check-suite.ts +1 -1
- package/src/core/discover-checks.ts +21 -6
- package/src/core/run-check.ts +69 -21
- package/src/core/run-ci.ts +75 -9
- package/src/core/run-fixers.ts +144 -0
- package/src/core/write-status.ts +14 -5
- package/src/hooks/commit-message.ts +42 -0
- package/src/hooks/leak-guard.ts +0 -0
- package/src/hooks/run-hooks.ts +131 -0
- package/src/init-repo.ts +13 -5
- package/src/reflect/prompt-corpus.ts +3 -4
- package/src/release/changelog.ts +53 -0
- package/src/release/git-facts.ts +133 -0
- package/src/release/run-bump.ts +419 -0
- package/src/release/run-release.ts +267 -0
- package/src/release/run-releases.ts +230 -0
- package/src/release/unit-gate.ts +67 -0
- package/src/release/units.ts +246 -0
- package/src/repo-config.ts +66 -2
- package/src/rules/SK001-package-json-shape.ts +39 -17
- package/src/rules/SK002-contracts-fulfillness.ts +1 -0
- package/src/rules/SK003-claude-md-shape.ts +2 -1
- package/src/rules/SK004-gitignore-policy.ts +1 -0
- package/src/rules/SK005-rvlib-versions.ts +22 -3
- package/src/rules/SK006-npm-publish-safety.ts +1 -0
- package/src/rules/SK007-github-visibility.ts +5 -1
- package/src/rules/SK008-reviewer-agent.ts +99 -0
- package/src/rules/SK009-commit-ai-tells.ts +66 -0
- package/src/rules/SK010-undeclared-release-unit.ts +114 -0
- package/src/rules/rule-registry.ts +28 -6
- package/src/rules/rule.ts +8 -1
- package/src/rules/run-fix.ts +15 -8
- package/src/serve/hub-app/col-card.tsx +2 -3
- package/src/serve/hub-app/hub-app.tsx +6 -8
- package/src/serve/hub-app/hub-st.ts +4 -7
- package/src/serve/hub-app/media-view.tsx +2 -2
- package/src/serve/run-serve.ts +4 -5
- package/src/serve/serve-hub.ts +2 -3
- package/src/shipkit.ts +9 -1
- package/src/tui/checks-panel.ts +1 -1
- package/src/tui/run-tui.ts +2 -3
- package/src/tui/tasks-panel.ts +10 -4
- package/src/tui/tui-store.ts +2 -2
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// a repo is N releasable units. The ROOT unit is mandatory, never declared,
|
|
2
|
+
// and configured by `release`. Sub-units are optional, exist only as entries
|
|
3
|
+
// in `units[]`, and are configured by their own entry. Nothing is scanned
|
|
4
|
+
// into existence: an undeclared package is caught by rule SK010, so
|
|
5
|
+
// resolution stays deterministic and a forgotten package still cannot hide.
|
|
6
|
+
//
|
|
7
|
+
// This module is THE source of unit facts. Every surface that used to
|
|
8
|
+
// rediscover packages (drift probes, per-lib tests, per-lib typecheck, the
|
|
9
|
+
// stranded-tag probe) reads it instead of walking the tree with its own rules.
|
|
10
|
+
import { existsSync, readFileSync, statSync } from 'fs'
|
|
11
|
+
import { basename, join, relative } from 'path'
|
|
12
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
13
|
+
|
|
14
|
+
export type ReleaseUnitDecl = {
|
|
15
|
+
/** repo-relative directory holding the unit's package.json — the only required key */
|
|
16
|
+
dir: string
|
|
17
|
+
/** default: basename(dir) */
|
|
18
|
+
id?: string
|
|
19
|
+
/** tag template, `{name}` and `{version}` substituted (default '{name}@{version}') */
|
|
20
|
+
tag?: string
|
|
21
|
+
/** repo-relative changelog (default '<dir>/CHANGELOG.md') */
|
|
22
|
+
changelog?: string
|
|
23
|
+
/** publish this sub-unit to npm. REQUIRED unless its package.json says
|
|
24
|
+
* `private: true`: the npm target is declared, never inferred, and the
|
|
25
|
+
* ambiguous package REFUSES rather than defaulting to the quiet answer.
|
|
26
|
+
* `true` on a private package.json is a resolution FAILURE */
|
|
27
|
+
npm?: boolean
|
|
28
|
+
/** cut a GitHub release for this unit's tag (default false for sub-units) */
|
|
29
|
+
githubRelease?: boolean
|
|
30
|
+
/** repo-relative tsconfig for the unit's own typecheck (default '<dir>/tsconfig.json' when it exists) */
|
|
31
|
+
tsconfig?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const UNIT_DECL_KEYS: readonly string[] = ['dir', 'id', 'tag', 'changelog', 'npm', 'githubRelease', 'tsconfig']
|
|
35
|
+
|
|
36
|
+
/** a unit, fully resolved: no optionals, no defaults left to re-apply downstream */
|
|
37
|
+
export type ReleaseUnit = {
|
|
38
|
+
id: string
|
|
39
|
+
/** absolute */
|
|
40
|
+
dir: string
|
|
41
|
+
/** repo-relative git pathspec, '' for the root */
|
|
42
|
+
rel: string
|
|
43
|
+
name: string
|
|
44
|
+
/** package.json version — THE only version source */
|
|
45
|
+
version: string
|
|
46
|
+
/** absolute path to the versioned changelog */
|
|
47
|
+
changelog: string
|
|
48
|
+
/** tag template, `{name}` and `{version}` unsubstituted — bump renders the NEXT tag from it */
|
|
49
|
+
tagTemplate: string
|
|
50
|
+
/** rendered tag for the current version */
|
|
51
|
+
tag: string
|
|
52
|
+
/** the tag template with `{version}` replaced by `*` — feeds the stranded-tag probe */
|
|
53
|
+
tagGlob: string
|
|
54
|
+
targets: { npm: boolean; tag: boolean; githubRelease: boolean }
|
|
55
|
+
/** the unit's OWN package.json `private: true`, never the repo's visibility */
|
|
56
|
+
isPrivate: boolean
|
|
57
|
+
/** absolute path, or null when the unit has no tsconfig of its own */
|
|
58
|
+
tsconfig: string | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** a unit that could not be described, and why. Scoped by id because the two
|
|
62
|
+
* cases differ: a broken DECLARED unit is a repo-wide declaration bug (rule
|
|
63
|
+
* SK010), while a root package that is not release-shaped only matters the
|
|
64
|
+
* day someone releases it. */
|
|
65
|
+
export type UnitError = { id: string; message: string }
|
|
66
|
+
|
|
67
|
+
export type UnitResolution = {
|
|
68
|
+
/** every unit that resolved — a broken sibling never hides the working ones */
|
|
69
|
+
units: ReleaseUnit[]
|
|
70
|
+
/** non-empty means no release of the named unit may proceed */
|
|
71
|
+
errors: UnitError[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const formatUnitError = (e: UnitError): string => `unit '${e.id}': ${e.message}`
|
|
75
|
+
|
|
76
|
+
const ROOT_TAG = 'v{version}'
|
|
77
|
+
const SUB_TAG = '{name}@{version}'
|
|
78
|
+
|
|
79
|
+
const render = (template: string, p: { name: string; version: string }): string =>
|
|
80
|
+
template.replaceAll('{name}', p.name).replaceAll('{version}', p.version)
|
|
81
|
+
|
|
82
|
+
type Pkg = { name?: string; version?: string; private?: boolean; scripts?: Record<string, string> }
|
|
83
|
+
|
|
84
|
+
function readPkg(dir: string): { pkg: Pkg } | { error: string } {
|
|
85
|
+
const p = join(dir, 'package.json')
|
|
86
|
+
if (!existsSync(p)) return { error: `no package.json in ${dir}` }
|
|
87
|
+
try { return { pkg: JSON.parse(readFileSync(p, 'utf8')) as Pkg } }
|
|
88
|
+
catch (e) { return { error: `${p} is unparseable (${String(e).slice(0, 120)})` } }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** every releasable unit, root first then the declared sub-units in declared
|
|
92
|
+
* order. Release ORDER is different (releaseOrder below) — the root ships
|
|
93
|
+
* last because it depends on its sub-units, never the reverse. */
|
|
94
|
+
export function resolveUnits(root: string, config: RepoConfig): UnitResolution {
|
|
95
|
+
const errors: UnitError[] = []
|
|
96
|
+
const units: ReleaseUnit[] = []
|
|
97
|
+
const rc = config.release ?? {}
|
|
98
|
+
|
|
99
|
+
const rootPkg = readPkg(root)
|
|
100
|
+
if ('error' in rootPkg) errors.push({ id: 'root', message: `${rootPkg.error} — release reads name and version from it` })
|
|
101
|
+
else {
|
|
102
|
+
const u = build({
|
|
103
|
+
root,
|
|
104
|
+
id: 'root',
|
|
105
|
+
dir: root,
|
|
106
|
+
pkg: rootPkg.pkg,
|
|
107
|
+
tagTemplate: rc.tag ?? ROOT_TAG,
|
|
108
|
+
changelogRel: rc.changelog ?? 'CHANGELOG.md',
|
|
109
|
+
npm: rc.npm,
|
|
110
|
+
// the root IS the repo: its package.json private flag is the whole
|
|
111
|
+
// declaration, and a public root package publishes by default
|
|
112
|
+
npmWhenUndeclared: 'from-private-flag',
|
|
113
|
+
githubRelease: rc.githubRelease ?? true,
|
|
114
|
+
tsconfigRel: rc.tsconfig,
|
|
115
|
+
checkFolderName: false,
|
|
116
|
+
})
|
|
117
|
+
if ('error' in u) errors.push(...u.error)
|
|
118
|
+
else units.push(u.unit)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const seen = new Set<string>(['root'])
|
|
122
|
+
for (const decl of config.units ?? []) {
|
|
123
|
+
const id = decl.id ?? (typeof decl.dir === 'string' ? basename(decl.dir) : '?')
|
|
124
|
+
const bad = Object.keys(decl).filter((k) => !UNIT_DECL_KEYS.includes(k))
|
|
125
|
+
if (bad.length > 0) { errors.push({ id, message: `unknown key(s) ${bad.join(', ')} (known: ${UNIT_DECL_KEYS.join(', ')})` }); continue }
|
|
126
|
+
if (typeof decl.dir !== 'string' || decl.dir === '') { errors.push({ id, message: 'every units[] entry needs a `dir`' }); continue }
|
|
127
|
+
const dir = join(root, decl.dir)
|
|
128
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) { errors.push({ id, message: `'${decl.dir}' is not a directory` }); continue }
|
|
129
|
+
if (seen.has(id)) { errors.push({ id, message: `duplicate unit id — give '${decl.dir}' an explicit id` }); continue }
|
|
130
|
+
seen.add(id)
|
|
131
|
+
const pkg = readPkg(dir)
|
|
132
|
+
if ('error' in pkg) { errors.push({ id, message: pkg.error }); continue }
|
|
133
|
+
const u = build({
|
|
134
|
+
root,
|
|
135
|
+
id,
|
|
136
|
+
dir,
|
|
137
|
+
pkg: pkg.pkg,
|
|
138
|
+
tagTemplate: decl.tag ?? SUB_TAG,
|
|
139
|
+
changelogRel: decl.changelog ?? `${decl.dir}/CHANGELOG.md`,
|
|
140
|
+
npm: decl.npm,
|
|
141
|
+
npmWhenUndeclared: 'refuse',
|
|
142
|
+
githubRelease: decl.githubRelease ?? false,
|
|
143
|
+
tsconfigRel: decl.tsconfig,
|
|
144
|
+
// only the DEFAULT tag renders the package name into a git tag, so
|
|
145
|
+
// only there does the folder have to agree with it
|
|
146
|
+
checkFolderName: decl.tag == null,
|
|
147
|
+
})
|
|
148
|
+
if ('error' in u) errors.push(...u.error)
|
|
149
|
+
else units.push(u.unit)
|
|
150
|
+
}
|
|
151
|
+
return { units, errors }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function build(p: {
|
|
155
|
+
root: string
|
|
156
|
+
id: string
|
|
157
|
+
dir: string
|
|
158
|
+
pkg: Pkg
|
|
159
|
+
tagTemplate: string
|
|
160
|
+
changelogRel: string
|
|
161
|
+
npm: boolean | undefined
|
|
162
|
+
/** how an UNDECLARED npm target resolves. The root IS the repo, so its
|
|
163
|
+
* package.json private flag is the whole declaration. A sub-unit has no
|
|
164
|
+
* such declaration, so a non-private package with no `npm` key is
|
|
165
|
+
* ambiguous and REFUSES. */
|
|
166
|
+
npmWhenUndeclared: 'from-private-flag' | 'refuse'
|
|
167
|
+
githubRelease: boolean
|
|
168
|
+
tsconfigRel: string | undefined
|
|
169
|
+
checkFolderName: boolean
|
|
170
|
+
}): { unit: ReleaseUnit } | { error: UnitError[] } {
|
|
171
|
+
const errors: UnitError[] = []
|
|
172
|
+
const at = (message: string): UnitError => ({ id: p.id, message })
|
|
173
|
+
const name = p.pkg.name ?? ''
|
|
174
|
+
const version = p.pkg.version ?? ''
|
|
175
|
+
if (name === '' || version === '') errors.push(at('package.json needs both name and version'))
|
|
176
|
+
// under the default template the package NAME becomes the git tag, so the
|
|
177
|
+
// folder it lives in must be findable from that tag. A scope satisfies it
|
|
178
|
+
// (`@acme/thing` in `thing/`), and an explicit `tag` opts out entirely.
|
|
179
|
+
const base = basename(p.dir)
|
|
180
|
+
if (p.checkFolderName && name !== '' && name !== base && !name.endsWith(`/${base}`))
|
|
181
|
+
errors.push(at(`package name '${name}' must equal the folder name '${base}' (a scope is fine: '@scope/${base}') — or declare an explicit \`tag\` for this unit`))
|
|
182
|
+
const isPrivate = p.pkg.private === true
|
|
183
|
+
// npm's own unconditional refusal. Declaring npm:true on top of it is a
|
|
184
|
+
// contradiction, not a preference — surface it instead of silently winning
|
|
185
|
+
if (p.npm === true && isPrivate)
|
|
186
|
+
errors.push(at('npm: true but package.json says private: true — drop one, npm refuses the publish either way'))
|
|
187
|
+
// "declared, never inferred" cuts BOTH ways: the package nobody decided
|
|
188
|
+
// about must refuse, not quietly resolve to no-publish. A silent false
|
|
189
|
+
// here is a lib that bumps, tags and cuts a release while publishing
|
|
190
|
+
// nothing, and the only trace is one note line in a long log.
|
|
191
|
+
if (p.npm == null && !isPrivate && p.npmWhenUndeclared === 'refuse')
|
|
192
|
+
errors.push(at('package.json is not private and this units[] entry does not say `npm` — add `npm: true` to publish it, or `private: true` to its package.json to keep it internal'))
|
|
193
|
+
if (errors.length > 0) return { error: errors }
|
|
194
|
+
const declaredNpm = p.npm ?? (p.npmWhenUndeclared === 'from-private-flag' && !isPrivate)
|
|
195
|
+
const tsconfig = p.tsconfigRel != null ? join(p.root, p.tsconfigRel) : join(p.dir, 'tsconfig.json')
|
|
196
|
+
return {
|
|
197
|
+
unit: {
|
|
198
|
+
id: p.id,
|
|
199
|
+
dir: p.dir,
|
|
200
|
+
rel: relative(p.root, p.dir),
|
|
201
|
+
name,
|
|
202
|
+
version,
|
|
203
|
+
changelog: join(p.root, p.changelogRel),
|
|
204
|
+
tagTemplate: p.tagTemplate,
|
|
205
|
+
tag: render(p.tagTemplate, { name, version }),
|
|
206
|
+
tagGlob: render(p.tagTemplate, { name, version: '*' }),
|
|
207
|
+
// the `!isPrivate` is a belt over the declaration: a private
|
|
208
|
+
// package never publishes, whatever anything else says
|
|
209
|
+
targets: { npm: declaredNpm && !isPrivate, tag: true, githubRelease: p.githubRelease },
|
|
210
|
+
isPrivate,
|
|
211
|
+
tsconfig: existsSync(tsconfig) ? tsconfig : null,
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** publish order: sub-units first, the root last. The root depends on what
|
|
217
|
+
* ships inside it, so a consumer that sees the root tag can already resolve
|
|
218
|
+
* every sub-unit it names. */
|
|
219
|
+
export function releaseOrder(units: ReleaseUnit[]): ReleaseUnit[] {
|
|
220
|
+
return [...units.filter((u) => u.id !== 'root'), ...units.filter((u) => u.id === 'root')]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const unitById = (units: ReleaseUnit[], id: string): ReleaseUnit | undefined => units.find((u) => u.id === id)
|
|
224
|
+
|
|
225
|
+
/** the tag this unit WOULD carry at another version — bump needs it before it
|
|
226
|
+
* writes, to refuse on a tag that already exists */
|
|
227
|
+
export const renderTag = (unit: ReleaseUnit, version: string): string => render(unit.tagTemplate, { name: unit.name, version })
|
|
228
|
+
|
|
229
|
+
/** every tag shape this repo produces, as git globs — the stranded-tag probe
|
|
230
|
+
* reads this instead of hardcoding one prefix and going blind to the others */
|
|
231
|
+
export const tagGlobs = (units: ReleaseUnit[]): string[] => [...new Set(units.map((u) => u.tagGlob))]
|
|
232
|
+
|
|
233
|
+
/** scripts that must pass before a version is tagged: npm's own lifecycle
|
|
234
|
+
* hooks plus any `prepublish:<name>` the unit defines. */
|
|
235
|
+
export function prepublishScripts(scripts: Record<string, string>): string[] {
|
|
236
|
+
return Object.keys(scripts)
|
|
237
|
+
.filter((k) => k === 'prepublishOnly' || k === 'prepublish' || k.startsWith('prepublish:'))
|
|
238
|
+
.sort()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** the unit's own prepublish hooks, read from ITS package.json (a sub-unit's
|
|
242
|
+
* hooks are invisible to the root's) */
|
|
243
|
+
export function unitPrepublishScripts(unit: ReleaseUnit): string[] {
|
|
244
|
+
const p = readPkg(unit.dir)
|
|
245
|
+
return 'error' in p ? [] : prepublishScripts(p.pkg.scripts ?? {})
|
|
246
|
+
}
|
package/src/repo-config.ts
CHANGED
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
// `defaults: { x: false }` prints a skipped-by-config line — never silence.
|
|
10
10
|
// The `contracts` task is always present when any contract applies.
|
|
11
11
|
import { existsSync, readFileSync } from 'fs'
|
|
12
|
-
import { join } from 'path'
|
|
12
|
+
import { isAbsolute, join, normalize } from 'path'
|
|
13
13
|
import { fileURLToPath } from 'url'
|
|
14
14
|
import { activeContracts, applyContractsResult, contractsConfigFrom } from './contracts/apply-contracts.ts'
|
|
15
15
|
import { evaluateContracts } from './contracts/contracts-engine.ts'
|
|
16
16
|
import type { ContractManifest } from './contracts/contract-types.ts'
|
|
17
17
|
import { contractsFulfillness } from './rules/SK002-contracts-fulfillness.ts'
|
|
18
|
+
import { BUILTIN_RULE_IDS, validateRules } from './rules/rule-registry.ts'
|
|
19
|
+
import type { Rule } from './rules/rule.ts'
|
|
20
|
+
import type { HooksConfig } from './hooks/run-hooks.ts'
|
|
21
|
+
import type { ReleaseConfig } from './release/run-release.ts'
|
|
22
|
+
import { UNIT_DECL_KEYS, type ReleaseUnitDecl } from './release/units.ts'
|
|
18
23
|
import type { TaskDef } from './core/task-runner.ts'
|
|
19
24
|
import type { TuiPanelDef } from './tui/tui-panel.ts'
|
|
20
25
|
import type { MdGraph } from './wiki/md-graph.ts'
|
|
@@ -62,6 +67,47 @@ export type RepoConfig = {
|
|
|
62
67
|
/** declared github visibility INTENT (absent = 'private') — rule SK007
|
|
63
68
|
* fails loud when `gh repo view` disagrees in either direction */
|
|
64
69
|
visibility?: 'private' | 'public'
|
|
70
|
+
/** the reviewer agent type this repo mandates: `.claude/agents/<name>.md`,
|
|
71
|
+
* whose system prompt points at `brief` (an md file holding the standing
|
|
72
|
+
* review framing — one home, shared across repos). `tools` overrides the
|
|
73
|
+
* read-only allowlist SK008 enforces (default: Read, Grep, Glob, Bash).
|
|
74
|
+
* Rule SK008 lints it, `check --fix SK008` writes it. Absent = no
|
|
75
|
+
* mandated reviewer. */
|
|
76
|
+
reviewer?: { name: string; brief: string; tools?: string[] }
|
|
77
|
+
/** repo-local rules, run by `shipkit check` beside the built-in SK ones and
|
|
78
|
+
* listed by `shipkit rules`. Same shape, same id-tagging, same fix pointers;
|
|
79
|
+
* the repo picks its own id prefix (SK is reserved). This is how a repo's
|
|
80
|
+
* own invariants become DISCOVERABLE instead of living as anonymous
|
|
81
|
+
* failures inside a hand-rolled lint script. */
|
|
82
|
+
rules?: Rule[]
|
|
83
|
+
/** `shipkit release`, branch, tag shape, changelog, npm + GitHub release.
|
|
84
|
+
* Absent is fine: the defaults (main, `v{version}`, CHANGELOG.md, npm only
|
|
85
|
+
* when public, GitHub release on) fit most repos. release/run-release.ts */
|
|
86
|
+
release?: ReleaseConfig
|
|
87
|
+
/** releasable packages INSIDE this repo, beside the mandatory root unit.
|
|
88
|
+
* Most repos have none and declare nothing. Nothing is scanned into
|
|
89
|
+
* existence: a package nobody declared fails rule SK010 with the entry to
|
|
90
|
+
* paste. release/units.ts */
|
|
91
|
+
units?: ReleaseUnitDecl[]
|
|
92
|
+
/** repo-relative dirs whose `package.json` is deliberately NOT releasable
|
|
93
|
+
* (an electron shell, a cloudflare worker, a fixture, an examples app).
|
|
94
|
+
* Rule SK010 needs an achievable green: without this, a package that
|
|
95
|
+
* cannot be a release unit leaves the gate permanently red. Declaring it
|
|
96
|
+
* here is a statement, so the package still cannot hide. One entry names
|
|
97
|
+
* ONE package dir (SK010 fails an entry with no package.json), and the
|
|
98
|
+
* repo root is refused outright: a single line that excuses everything
|
|
99
|
+
* would turn a blocking rule into a false green. */
|
|
100
|
+
notReleasable?: string[]
|
|
101
|
+
/** git-hook gates: built-in leak guard + commit-message predicate, plus the
|
|
102
|
+
* repo's own commands. `shipkit hooks list` enumerates them, so what blocks
|
|
103
|
+
* a commit is answerable without reading shell. hooks/run-hooks.ts */
|
|
104
|
+
hooks?: HooksConfig
|
|
105
|
+
/** what a discovered `*.check.ts` means when the FILE does not say.
|
|
106
|
+
* Most repos are uniform (every probe reports the world), and repeating
|
|
107
|
+
* the same line in every file is one fact written N times. Declare it
|
|
108
|
+
* once here; a file that differs overrides with its own
|
|
109
|
+
* `export const blocking = true`. With neither, the check fails loud. */
|
|
110
|
+
checkDefaults?: { blocking?: boolean }
|
|
65
111
|
/** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
|
|
66
112
|
tui?: { panels?: TuiPanelDef[] }
|
|
67
113
|
/** the `shipkit serve` hub: port + optional graph hooks */
|
|
@@ -69,21 +115,39 @@ export type RepoConfig = {
|
|
|
69
115
|
logDir?: string
|
|
70
116
|
}
|
|
71
117
|
|
|
72
|
-
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', 'gitignore', 'visibility', 'tui', 'serve', 'logDir'])
|
|
118
|
+
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', 'gitignore', 'visibility', 'reviewer', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir'])
|
|
73
119
|
|
|
74
120
|
export function defineRepo(c: RepoConfig): RepoConfig {
|
|
75
121
|
if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
|
|
76
122
|
for (const v of c.venues ?? []) if (!/^[a-z0-9-]+$/.test(v)) throw new Error(`defineRepo: venue '${v}' must be kebab-case`)
|
|
77
123
|
if (c.visibility != null && c.visibility !== 'private' && c.visibility !== 'public')
|
|
78
124
|
throw new Error(`defineRepo: visibility '${String(c.visibility)}' — 'private' or 'public' only`)
|
|
125
|
+
// a half-declared reviewer would lint a path like `.claude/agents/.md`
|
|
126
|
+
if (c.reviewer != null && (!/^[a-z0-9][a-z0-9-]*$/.test(c.reviewer.name ?? '') || (c.reviewer.brief ?? '') === ''))
|
|
127
|
+
throw new Error(`defineRepo: reviewer needs { name: kebab-case, brief: path to the standing brief }`)
|
|
79
128
|
// unknown keys die loud — a typo'd or removed key silently changing the
|
|
80
129
|
// gate is exactly the never-silence failure mode (A23)
|
|
81
130
|
for (const k of Object.keys(c))
|
|
82
131
|
if (!KNOWN_KEYS.has(k))
|
|
83
132
|
throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : ''}`)
|
|
133
|
+
// a typo'd unit key would silently drop a target and release the wrong shape
|
|
134
|
+
for (const u of c.units ?? []) {
|
|
135
|
+
if (typeof u.dir !== 'string' || u.dir === '') throw new Error(`defineRepo: every units[] entry needs a 'dir'`)
|
|
136
|
+
for (const k of Object.keys(u))
|
|
137
|
+
if (!UNIT_DECL_KEYS.includes(k)) throw new Error(`defineRepo: units['${u.dir}'] unknown key '${k}' (known: ${UNIT_DECL_KEYS.join(', ')})`)
|
|
138
|
+
}
|
|
139
|
+
for (const d of c.notReleasable ?? []) {
|
|
140
|
+
if (typeof d !== 'string' || d === '') throw new Error(`defineRepo: every notReleasable entry is a repo-relative dir path`)
|
|
141
|
+
const rel = normalize(d).replace(/\/+$/, '')
|
|
142
|
+
if (isAbsolute(d) || rel === '.' || rel === '' || rel === '..' || rel.startsWith('../'))
|
|
143
|
+
throw new Error(`defineRepo: notReleasable '${d}' is the repo root or outside it, and would excuse every package at once — name one package dir per entry`)
|
|
144
|
+
}
|
|
84
145
|
for (const k of Object.keys(c.defaults ?? {}))
|
|
85
146
|
if (!(FLOOR_IDS as readonly string[]).includes(k))
|
|
86
147
|
throw new Error(`defineRepo: defaults.${k} is not a floor task (floor: ${FLOOR_IDS.join(', ')})`)
|
|
148
|
+
// repo rules are validated against the built-ins, so a bad id, a duplicate,
|
|
149
|
+
// or an SK squatter dies at config load, not silently mid-check
|
|
150
|
+
if (c.rules != null) validateRules(c.rules, { label: 'defineRepo: rules', taken: BUILTIN_RULE_IDS })
|
|
87
151
|
return c
|
|
88
152
|
}
|
|
89
153
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// SK001 — canonical package.json shape (meta/rv-conventions/package-json.md):
|
|
2
2
|
// private:true, no license, capability-required script names FAIL, banned
|
|
3
|
-
// aliases + missing baseline
|
|
4
|
-
//
|
|
5
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
3
|
+
// aliases + missing baseline scripts WARN. Blocking, so it runs in `shipkit
|
|
4
|
+
// ci` like every other rule. Pure local fs, no TTL.
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
6
6
|
import { join } from 'path'
|
|
7
7
|
import { canonicalBodies, conditionalBodies, inferBin } from '../core/canonical-scripts.ts'
|
|
8
8
|
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
@@ -18,7 +18,6 @@ const OWNER = 'meta/rv-conventions/package-json.md'
|
|
|
18
18
|
// folder must not be told to rename)
|
|
19
19
|
const ALIASES: Record<string, { canonical: string; bodyMatch: RegExp }> = {
|
|
20
20
|
'serve': { canonical: 'hub', bodyMatch: /(shipkit|cli\.ts) serve\b/ },
|
|
21
|
-
'identity': { canonical: 'assets:identity', bodyMatch: /assets(:| )identity\b/ },
|
|
22
21
|
'pretty': { canonical: 'fmt', bodyMatch: /./ },
|
|
23
22
|
'check-types': { canonical: 'typecheck', bodyMatch: /./ },
|
|
24
23
|
}
|
|
@@ -69,35 +68,55 @@ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConf
|
|
|
69
68
|
const missing = required.filter((r) => !has(r.name))
|
|
70
69
|
for (const m of missing) t.fail(`package.json: missing script '${m.name}' (${m.why})`)
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
// `test` is asked for only once the repo HAS tests: the fixer deliberately
|
|
72
|
+
// refuses to scaffold it before that (a fixer never manufactures a
|
|
73
|
+
// failure), so nagging for it would be a WARN no machine may clear
|
|
74
|
+
const baseline = BASELINE.filter((n) => n !== 'test' || hasTestFiles(root))
|
|
75
|
+
const missingBaseline = baseline.filter((n) => !has(n))
|
|
73
76
|
if (missingBaseline.length > 0)
|
|
74
77
|
t.warn(`package.json: missing baseline script(s): ${missingBaseline.join(', ')}`)
|
|
75
78
|
|
|
76
79
|
if (!t.failed && !t.warned)
|
|
77
|
-
t.ok(`package.json shape ok (private, no license, ${required.length} required
|
|
80
|
+
t.ok(`package.json shape ok (private, no license, ${required.length} required script(s) present)`)
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
// capability → required script names (FAIL); repo.config.ts existence is
|
|
81
|
-
// implied — this rule only runs under `shipkit check
|
|
82
|
-
|
|
84
|
+
// implied — this rule only runs under `shipkit check`.
|
|
85
|
+
//
|
|
86
|
+
// a shipkit verb is never on this list. `shipkit ci`, `check`, `contracts`,
|
|
87
|
+
// `tui`, `mv`, `assets`, `asks` are typed as themselves, and a package.json
|
|
88
|
+
// row that only forwards to one is a second name for one capability
|
|
89
|
+
function requiredScripts(root: string, _c: RepoConfig): Array<{ name: string; why: string }> {
|
|
83
90
|
const required: Array<{ name: string; why: string }> = [
|
|
84
|
-
|
|
85
|
-
{ name: 'check', why: 'shipkit repo' },
|
|
86
|
-
{ name: 'contracts', why: 'shipkit repo' },
|
|
87
|
-
{ name: 'tui', why: 'shipkit repo' },
|
|
91
|
+
// watch-mode serve — the ONE shipkit surface the bare verb cannot give
|
|
88
92
|
{ name: 'hub', why: 'shipkit repo' },
|
|
89
|
-
{ name: 'mv', why: 'shipkit repo' },
|
|
90
93
|
]
|
|
91
|
-
if (existsSync(join(root, '.rv-assets/kit.ts'))) required.push({ name: 'assets', why: '.rv-assets/kit.ts exists' })
|
|
92
|
-
if (existsSync(join(root, '.rv-identity/brand.ts'))) required.push({ name: 'assets:identity', why: '.rv-identity/brand.ts exists' })
|
|
93
94
|
if (existsSync(join(root, '.rv-comfy'))) required.push({ name: 'comfy:up', why: '.rv-comfy/ exists' })
|
|
94
95
|
if (existsSync(join(root, 'deploy.sh'))) required.push({ name: 'up', why: 'deploy.sh exists' })
|
|
95
|
-
if (existsSync(join(root, c.asks?.dir ?? 'asks'))) required.push({ name: 'asks', why: 'ask queue exists' })
|
|
96
96
|
return required
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
const TEST_SKIP = new Set(['node_modules', 'dist', 'tmp', 'references', 'coverage'])
|
|
100
|
+
|
|
101
|
+
/** any `*.test.ts` in the repo, so the test script is only scaffolded once it
|
|
102
|
+
* has something to run */
|
|
103
|
+
export function hasTestFiles(root: string): boolean {
|
|
104
|
+
const walk = (dir: string, depth: number): boolean => {
|
|
105
|
+
if (depth > 4) return false
|
|
106
|
+
let entries
|
|
107
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return false }
|
|
108
|
+
for (const e of entries) {
|
|
109
|
+
if (e.isDirectory()) { if (!TEST_SKIP.has(e.name) && !e.name.startsWith('.') && walk(join(dir, e.name), depth + 1)) return true }
|
|
110
|
+
else if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(e.name)) return true
|
|
111
|
+
}
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
return walk(root, 0)
|
|
115
|
+
}
|
|
116
|
+
|
|
99
117
|
export const packageJsonShape: Rule = {
|
|
100
118
|
id: 'SK001',
|
|
119
|
+
blocking: true,
|
|
101
120
|
title: 'package.json',
|
|
102
121
|
owner: OWNER,
|
|
103
122
|
lint: (t: CheckCtx, env: RuleEnv) => evaluatePackageJson(t, env.root, env.config),
|
|
@@ -141,7 +160,10 @@ export const packageJsonShape: Rule = {
|
|
|
141
160
|
pkg.scripts[m.name] = body
|
|
142
161
|
changes.push(`script '${m.name}' added`)
|
|
143
162
|
}
|
|
144
|
-
|
|
163
|
+
// only once the repo HAS tests. `bun test` exits non-zero when it finds
|
|
164
|
+
// none, so scaffolding this into a repo without any turns the gate red
|
|
165
|
+
// on work the fixer itself did. a fixer must never manufacture a failure.
|
|
166
|
+
if (pkg.scripts['test'] == null && hasTestFiles(env.root)) { pkg.scripts['test'] = 'bun test'; changes.push(`script 'test' added`) }
|
|
145
167
|
if (changes.length > 0) {
|
|
146
168
|
const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
|
|
147
169
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SK003 — the CLAUDE.md two-line contract (meta/agent-folder.md): CLAUDE.md
|
|
2
2
|
// is an include index, never a doc; `@./agent/index.md` comes LAST (include
|
|
3
3
|
// order is SEMANTIC — base context first, repo docs after so they
|
|
4
|
-
// override
|
|
4
|
+
// override); a
|
|
5
5
|
// public repo's `@./CLAUDE.local.md` line-1 indirection must be gitignored.
|
|
6
6
|
// The lib stays generic: no personal include path is asserted — `shipkit
|
|
7
7
|
// start --include` flags own which base include a machine wires in. A repo
|
|
@@ -17,6 +17,7 @@ const LOCAL_LINE = '@./CLAUDE.local.md'
|
|
|
17
17
|
|
|
18
18
|
export const claudeMdShape: Rule = {
|
|
19
19
|
id: 'SK003',
|
|
20
|
+
blocking: true,
|
|
20
21
|
title: 'CLAUDE.md',
|
|
21
22
|
owner: 'meta/agent-folder.md',
|
|
22
23
|
lint: async (t, env) => {
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
// SK005 — rvlib-* dependency freshness, and the self-update that keeps
|
|
2
2
|
// every repo converging on the current toolkit (a stale repo silently
|
|
3
3
|
// missing months of rules is the failure; machine-clearable drift clears
|
|
4
|
-
// ITSELF).
|
|
4
|
+
// ITSELF).
|
|
5
|
+
//
|
|
6
|
+
// BLOCKING, and the two axes do not fight: every FAIL branch here is about
|
|
7
|
+
// THIS repo (a dep the tree cannot import, a half-written package.json), so
|
|
8
|
+
// it stops you. Everything about the WORLD leaves by another door — version
|
|
9
|
+
// drift WARNs, an unreachable registry SKIPs — and a warning never blocks.
|
|
10
|
+
// Per rvlib-* dep:
|
|
5
11
|
// link: → the symlink must resolve (a broken link dies cryptically at
|
|
6
12
|
// import time otherwise) — nothing to update, code is live
|
|
7
13
|
// semver → installed vs npm latest; NEWER LIVE → this lint runs
|
|
8
14
|
// `bun update --latest` itself, loudly, guarded:
|
|
9
|
-
// - SHIPKIT_FROZEN=1 (`check --frozen`) → WARN,
|
|
15
|
+
// - SHIPKIT_FROZEN=1 (`check --frozen`, `ci --frozen`) → WARN,
|
|
16
|
+
// never touch
|
|
10
17
|
// - package.json/lockfile git-dirty (or no git) → WARN deferred,
|
|
11
18
|
// a tool must not mix its writes into human-staged work
|
|
19
|
+
// - the update itself failing splits by what it LEFT: a half-
|
|
20
|
+
// written package.json/lockfile is this repo and FAILs, an
|
|
21
|
+
// untouched tree is the registry and only WARNs
|
|
12
22
|
// registry unreachable → loud skip, never red (offline is not a defect)
|
|
13
23
|
// Committing the bump stays with the working agent — shipkit never commits.
|
|
14
24
|
// Clean passes cache 24h (rule ttl), so the registry is fetched at most
|
|
@@ -38,6 +48,7 @@ async function latestVersion(t: CheckCtx, name: string): Promise<string> {
|
|
|
38
48
|
|
|
39
49
|
export const rvlibVersions: Rule = {
|
|
40
50
|
id: 'SK005',
|
|
51
|
+
blocking: true,
|
|
41
52
|
title: 'rvlib versions',
|
|
42
53
|
owner: 'build/shipkit.md',
|
|
43
54
|
ttl: '24h',
|
|
@@ -84,7 +95,15 @@ export const rvlibVersions: Rule = {
|
|
|
84
95
|
const names = stale.map((s) => s.name)
|
|
85
96
|
const up = await t.exec(['bun', 'update', '--latest', ...names], { cwd: env.root, timeoutMs: 120_000 })
|
|
86
97
|
if (up.code !== 0) {
|
|
87
|
-
|
|
98
|
+
// which axis the failure sits on is measurable, so measure it: the
|
|
99
|
+
// tree is either half-written (this repo, blocks) or untouched, and
|
|
100
|
+
// a registry drop mid-download must never hard-red a healthy repo
|
|
101
|
+
const after = await t.exec(['git', 'status', '--porcelain', '--', 'package.json', 'bun.lock', 'bun.lockb'], { cwd: env.root })
|
|
102
|
+
const half = after.code !== 0 || after.out.trim() !== ''
|
|
103
|
+
const why = `bun update --latest ${names.join(' ')} exited ${up.code}${up.timedOut ? ', timed out' : ''}`
|
|
104
|
+
const tail = up.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''
|
|
105
|
+
if (half) t.fail(`self-update failed (${why}) and package.json/lockfile are half-written — \`git status\`, then restore or finish it by hand: ${tail}`)
|
|
106
|
+
else t.warn(`self-update failed (${why}) and nothing was written — the registry or the network, not this repo. Retry with: bun update --latest ${names.join(' ')}: ${tail}`)
|
|
88
107
|
return
|
|
89
108
|
}
|
|
90
109
|
for (const s of stale) {
|
|
@@ -20,6 +20,7 @@ type Pkg = { name?: string; private?: boolean; publishConfig?: unknown; scripts?
|
|
|
20
20
|
|
|
21
21
|
export const npmPublishSafety: Rule = {
|
|
22
22
|
id: 'SK006',
|
|
23
|
+
blocking: true,
|
|
23
24
|
title: 'npm publish safety',
|
|
24
25
|
owner: 'meta/rv-conventions/package-json.md',
|
|
25
26
|
lint: (t, env) => {
|
|
@@ -17,6 +17,10 @@ import type { Rule } from './rule.ts'
|
|
|
17
17
|
|
|
18
18
|
export const githubVisibility: Rule = {
|
|
19
19
|
id: 'SK007',
|
|
20
|
+
// blocking despite being measured over the network: the fact is about THIS
|
|
21
|
+
// REPO (it is public while it declares itself private), and every
|
|
22
|
+
// unverifiable path above is a skip, so an offline machine never goes red
|
|
23
|
+
blocking: true,
|
|
20
24
|
title: 'github visibility',
|
|
21
25
|
owner: 'meta/contracts.md',
|
|
22
26
|
ttl: '7d',
|
|
@@ -30,7 +34,7 @@ export const githubVisibility: Rule = {
|
|
|
30
34
|
if (remotes.code !== 0) return skip(`git says: ${firstLine(remotes.out) || `exit ${remotes.code}`}`)
|
|
31
35
|
const names = remotes.out.trim().split('\n').filter((l) => l !== '')
|
|
32
36
|
if (names.length === 0) return skip('no git remote')
|
|
33
|
-
//
|
|
37
|
+
// prefer a `github`-named remote over `origin` when both exist
|
|
34
38
|
const pick = ['github', 'origin'].find((n) => names.includes(n)) ?? names[0]!
|
|
35
39
|
const url = await t.exec(['git', 'remote', 'get-url', pick], { cwd: env.root })
|
|
36
40
|
if (url.code !== 0) return skip(`git says: ${firstLine(url.out)}`)
|