rvlib-shipkit 0.14.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +6 -22
  3. package/package.json +1 -1
  4. package/src/assets/kits/identity-kit.ts +1 -1
  5. package/src/assets/panels/geometry-rects.ts +3 -17
  6. package/src/assets/server/studio-server.ts +1 -1
  7. package/src/cli/cmd_Bump.ts +52 -0
  8. package/src/cli/cmd_Check.ts +2 -2
  9. package/src/cli/cmd_Ci.ts +10 -2
  10. package/src/cli/cmd_Commands.ts +126 -0
  11. package/src/cli/cmd_Hooks.ts +66 -0
  12. package/src/cli/cmd_Mv.ts +29 -6
  13. package/src/cli/cmd_Release.ts +41 -0
  14. package/src/cli/cmd_Releases.ts +42 -0
  15. package/src/cli/cmd_Rules.ts +20 -6
  16. package/src/cli/cmd_Start.ts +2 -2
  17. package/src/cli.ts +14 -1
  18. package/src/collections/posts.ts +1 -1
  19. package/src/collections/providers.ts +1 -1
  20. package/src/contracts/manifests/discord.contract.ts +1 -1
  21. package/src/contracts/manifests/itch.contract.ts +2 -4
  22. package/src/core/canonical-scripts.ts +9 -14
  23. package/src/core/check-suite.ts +1 -1
  24. package/src/core/discover-checks.ts +21 -6
  25. package/src/core/run-check.ts +69 -21
  26. package/src/core/run-ci.ts +75 -9
  27. package/src/core/run-fixers.ts +144 -0
  28. package/src/core/write-status.ts +14 -5
  29. package/src/hooks/commit-message.ts +42 -0
  30. package/src/hooks/leak-guard.ts +0 -0
  31. package/src/hooks/run-hooks.ts +131 -0
  32. package/src/init-repo.ts +10 -5
  33. package/src/reflect/prompt-corpus.ts +3 -4
  34. package/src/release/changelog.ts +53 -0
  35. package/src/release/git-facts.ts +133 -0
  36. package/src/release/run-bump.ts +419 -0
  37. package/src/release/run-release.ts +267 -0
  38. package/src/release/run-releases.ts +230 -0
  39. package/src/release/unit-gate.ts +67 -0
  40. package/src/release/units.ts +246 -0
  41. package/src/repo-config.ts +56 -2
  42. package/src/rules/SK001-package-json-shape.ts +39 -17
  43. package/src/rules/SK002-contracts-fulfillness.ts +1 -0
  44. package/src/rules/SK003-claude-md-shape.ts +2 -1
  45. package/src/rules/SK004-gitignore-policy.ts +1 -0
  46. package/src/rules/SK005-rvlib-versions.ts +22 -3
  47. package/src/rules/SK006-npm-publish-safety.ts +1 -0
  48. package/src/rules/SK007-github-visibility.ts +5 -1
  49. package/src/rules/SK008-reviewer-agent.ts +1 -0
  50. package/src/rules/SK009-commit-ai-tells.ts +66 -0
  51. package/src/rules/SK010-undeclared-release-unit.ts +114 -0
  52. package/src/rules/rule-registry.ts +26 -6
  53. package/src/rules/rule.ts +8 -1
  54. package/src/rules/run-fix.ts +15 -8
  55. package/src/serve/hub-app/col-card.tsx +2 -3
  56. package/src/serve/hub-app/hub-app.tsx +6 -8
  57. package/src/serve/hub-app/hub-st.ts +4 -7
  58. package/src/serve/hub-app/media-view.tsx +2 -2
  59. package/src/serve/run-serve.ts +4 -5
  60. package/src/serve/serve-hub.ts +2 -3
  61. package/src/shipkit.ts +9 -1
  62. package/src/tui/checks-panel.ts +1 -1
  63. package/src/tui/run-tui.ts +2 -3
  64. package/src/tui/tasks-panel.ts +10 -4
  65. 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
+ }
@@ -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'
@@ -69,6 +74,40 @@ export type RepoConfig = {
69
74
  * Rule SK008 lints it, `check --fix SK008` writes it. Absent = no
70
75
  * mandated reviewer. */
71
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 }
72
111
  /** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
73
112
  tui?: { panels?: TuiPanelDef[] }
74
113
  /** the `shipkit serve` hub: port + optional graph hooks */
@@ -76,7 +115,7 @@ export type RepoConfig = {
76
115
  logDir?: string
77
116
  }
78
117
 
79
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', 'gitignore', 'visibility', 'reviewer', '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'])
80
119
 
81
120
  export function defineRepo(c: RepoConfig): RepoConfig {
82
121
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -91,9 +130,24 @@ export function defineRepo(c: RepoConfig): RepoConfig {
91
130
  for (const k of Object.keys(c))
92
131
  if (!KNOWN_KEYS.has(k))
93
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
+ }
94
145
  for (const k of Object.keys(c.defaults ?? {}))
95
146
  if (!(FLOOR_IDS as readonly string[]).includes(k))
96
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 })
97
151
  return c
98
152
  }
99
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 trio WARN. Doctor surface only — never wired
4
- // into the ci gate. Pure local fs, no TTL.
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
- const missingBaseline = BASELINE.filter((n) => !has(n))
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 scripts present)`)
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
- function requiredScripts(root: string, c: RepoConfig): Array<{ name: string; why: string }> {
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
- { name: 'ci', why: 'shipkit repo' },
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
- if (pkg.scripts['test'] == null) { pkg.scripts['test'] = 'bun test'; changes.push(`script 'test' added`) }
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')
@@ -11,6 +11,7 @@ import type { Rule } from './rule.ts'
11
11
 
12
12
  export const contractsFulfillness: Rule = {
13
13
  id: 'SK002',
14
+ blocking: true,
14
15
  title: 'contracts',
15
16
  owner: 'meta/contracts.md',
16
17
  lint: async (t, env) => {
@@ -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; a field bug moved the local include line and inverted it); a
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) => {
@@ -84,6 +84,7 @@ function writeBlock(root: string, config: RuleEnv['config']): void {
84
84
 
85
85
  export const gitignorePolicy: Rule = {
86
86
  id: 'SK004',
87
+ blocking: true,
87
88
  title: '.gitignore',
88
89
  owner: 'meta/contracts.md',
89
90
  lint: (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). Per rvlib-* dep:
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, never touch
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
- t.fail(`self-update failed (bun update --latest ${names.join(' ')} exited ${up.code}${up.timedOut ? ', timed out' : ''}) — package.json/lockfile may be partially written, \`git status\` first: ${up.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}`)
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
- // his repos name it `github`, not `origin` (field catch 2026-07-26)
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)}`)
@@ -69,6 +69,7 @@ That brief overrides any habit of reviewing a diff for correctness alone.
69
69
 
70
70
  export const reviewerAgent: Rule = {
71
71
  id: 'SK008',
72
+ blocking: true,
72
73
  title: 'reviewer agent',
73
74
  owner: 'identity/how-we-work.md',
74
75
  lint: (t, env) => {
@@ -0,0 +1,66 @@
1
+ // SK009 — commit AI tells (identity/how-we-commit.md): a repo that will be
2
+ // seen must not read as AI-driven. Prevention lives at the source (the
3
+ // attribution setting + the standing from-commit-one ban, which binds every
4
+ // repo because history follows a repo public and is never rewritten); this
5
+ // rule is the declared-public tripwire: the FULL git log scanned for AI
6
+ // attribution and dash punctuation, plus the tracked settings file that
7
+ // silences attribution. Declared-private repos idle with an ok that RESTATES
8
+ // the ban — a green line must never teach the wrong rule. The remedy for
9
+ // pushed history is the owner's decision, never an automatic rewrite; only
10
+ // an unpushed tip commit may be amended, and the fails say so.
11
+ import { readFileSync } from 'fs'
12
+ import { join } from 'path'
13
+ import { hasAiAttribution, hasDashPunctuation } from '../hooks/commit-message.ts'
14
+ import type { Rule } from './rule.ts'
15
+
16
+ // the documented silencer: .claude/settings.json attribution.commit/.pr both
17
+ // empty — the harness then never writes the trailer or the PR footer
18
+ const attributionSilenced = (root: string): boolean => {
19
+ try {
20
+ const s = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf8')) as { attribution?: { commit?: string; pr?: string } }
21
+ return s.attribution != null && s.attribution.commit === '' && s.attribution.pr === ''
22
+ } catch {
23
+ return false
24
+ }
25
+ }
26
+
27
+ export const commitAiTells: Rule = {
28
+ id: 'SK009',
29
+ blocking: true,
30
+ title: 'commit ai tells',
31
+ owner: 'identity/how-we-commit.md',
32
+ lint: async (t, env) => {
33
+ const declared = env.config.visibility ?? 'private'
34
+ if (declared !== 'public')
35
+ return t.ok(`declared ${declared} — the no-AI-attribution ban still applies from commit one (identity/how-we-commit.md); the full-history scan arms at visibility: 'public'`)
36
+ // %x1f separates hash from body, %x1e terminates the record — commit
37
+ // bodies are free text, so only unprintable separators are safe
38
+ const log = await t.exec(['git', 'log', '--format=%h%x1f%B%x1e'], { cwd: env.root, timeoutMs: 30_000 })
39
+ if (log.code !== 0) {
40
+ const first = log.out.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 120) ?? `exit ${log.code}`
41
+ return void t.lines.push({ kind: 'skip', text: `commit history unverifiable — git says: ${first}` })
42
+ }
43
+ const commits = log.out
44
+ .split('\x1e')
45
+ .map((r) => r.trim())
46
+ .filter((r) => r !== '')
47
+ .map((r) => {
48
+ const cut = r.indexOf('\x1f')
49
+ return { hash: r.slice(0, cut), body: r.slice(cut + 1) }
50
+ })
51
+ if (commits.length === 0) return void t.lines.push({ kind: 'skip', text: 'commit history unverifiable — no commits yet' })
52
+ const remedy = 'an unpushed TIP commit may be amended; anything deeper is the owner\'s decision (history is never rewritten as a side effect)'
53
+ const sample = (hits: { hash: string }[]): string => hits.slice(0, 5).map((h) => h.hash).join(' ') + (hits.length > 5 ? ' …' : '')
54
+ const attributed = commits.filter((c) => hasAiAttribution(c.body))
55
+ const dashed = commits.filter((c) => hasDashPunctuation(c.body))
56
+ if (attributed.length > 0)
57
+ t.fail(`${attributed.length}/${commits.length} commits carry AI attribution (a trailer naming an assistant / "Generated with" / 🤖) in a declared-public repo — e.g. ${sample(attributed)}; ${remedy}`)
58
+ if (dashed.length > 0)
59
+ t.fail(`${dashed.length}/${commits.length} commit messages carry dash punctuation (em/en dash or inline " - ") in a declared-public repo — e.g. ${sample(dashed)}; ${remedy} (identity/writing-voice.md owns the punctuation rule)`)
60
+ const silenced = attributionSilenced(env.root)
61
+ if (!silenced)
62
+ t.fail(`.claude/settings.json does not silence AI attribution — track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
63
+ if (attributed.length === 0 && dashed.length === 0 && silenced)
64
+ t.ok(`${commits.length} commits scanned, attribution silenced in .claude/settings.json — no AI tells`)
65
+ },
66
+ }