rvlib-shipkit 0.16.0 → 0.17.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0
4
+
5
+ **Correction to 0.16.0.** Its notes and its `--frozen` flag description claimed some combination of flags made `shipkit ci` write nothing. None does: STATUS.md is refreshed on every run unless a caller passes `status: false`. If you wired `ci --no-fix --frozen` into CI expecting a read-only run, it leaves STATUS.md behind, and `--json` is the only output that writes nothing. 0.16.0 also flipped rule SK005 from warning to BLOCKING without saying so: a `link:` dependency that no longer resolves, or a declared dependency that is not installed, now fails the gate.
6
+
7
+ - **`bump --all` takes a kind per unit.** Write `## Unreleased (minor)` on a unit's changelog heading and a batch release gives each unit the kind it actually needs, in ONE gate. A unit whose heading declares nothing refuses rather than being guessed into a version number. Passing a kind on the command line still applies it to the whole selection, and promoting strips the marker. `shipkit bump <unit>` with no kind works the same way.
8
+ - **`notReleasable` can no longer cover a declared release unit.** An entry naming a container that holds one excused every future package under it while the rule reported green. The two lists state opposite intents, so overlap fails.
9
+ - **A release gates fresh.** SK005 caches a clean pass for a day to rate-limit its registry call, but its blocking branches are local facts about whether the tree can import its own dependencies. Those are measured at release time now, not replayed.
10
+ - **The bump rehearsal reads `git fetch`.** Offline or with an expired credential it could compare against stale remote refs and still report a clean merge. It warns and closes inconclusive instead.
11
+ - New pre-commit gate **root-identity**: the name in the root `package.json` and `repo.config.ts`, index against HEAD, plus staged `.bak`/`.orig`/`.rej`. A fixture builder that escapes its temp dir overwrites those files and every other gate still passes, because a gutted repo lints fine. A deliberate rename passes with `SHIPKIT_ALLOW_IDENTITY_CHANGE=1`.
12
+ - `.rv-private/` joins the SK004 gitignore policy. It holds the leak guard's own keyword file, which was committable in every repo until now.
13
+ - `ci --frozen` exists as a flag, and no TUI panel writes STATUS.md any more, so opening the dashboard cannot overwrite a full gate's verdict with a tasks-only one.
14
+
3
15
  ## 0.16.0
4
16
 
5
17
  Upgrading from 0.14.0 lands straight here: 0.15.0 was stamped and never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,32 +15,41 @@ export class BumpCommand extends Command {
15
15
  ['a feature release', 'shipkit bump minor'],
16
16
  ['a breaking release', 'shipkit bump major'],
17
17
  ['one sub-unit (ids: shipkit releases)', 'shipkit bump minor rvlib-shipkit'],
18
- ['every unit that has Unreleased notes, root last', 'shipkit bump patch --all'],
18
+ ['a whole mixed batch in ONE gate, each unit at the kind its heading declares', 'shipkit bump --all'],
19
+ ['every unit that has Unreleased notes, all at the same kind', 'shipkit bump patch --all'],
20
+ ['one unit, kind from its own heading', 'shipkit bump rvlib-shipkit'],
19
21
  ['rehearse the whole thing', 'shipkit bump minor --dry-run'],
20
22
  ['bump and commit, release later', 'shipkit bump patch --no-release'],
21
23
  ['from a feature branch, refuse rather than merge', 'shipkit bump patch --no-merge'],
22
24
  ],
23
25
  })
24
26
 
25
- kind = Option.String({ required: true })
27
+ // both positionals are optional so `bump --all` can read each unit's own
28
+ // kind. The kinds are a closed set of three words, so a first positional
29
+ // that is not one of them is a unit id, never an ambiguity
30
+ kind = Option.String({ required: false })
26
31
  unit = Option.String({ required: false })
27
32
  dryRun = Option.Boolean('--dry-run', false, { description: 'rehearse: run every preflight, the gate and the prepublish scripts, then stop before any version is written. the gate can rewrite files, and the merge is probed rather than performed, so the release branch never moves' })
28
33
  noRelease = Option.Boolean('--no-release', false, { description: 'stop after the version commit, do not tag or publish' })
29
34
  noMerge = Option.Boolean('--no-merge', false, { description: 'refuse on a feature branch instead of merging it into the release branch' })
30
- all = Option.Boolean('--all', false, { description: 'bump every unit whose changelog carries Unreleased notes, in declared order, root last. The kind applies to ALL of them, so a batch mixing a typo fix and a break wants one bump per unit instead' })
35
+ all = Option.Boolean('--all', false, { description: 'bump every unit whose changelog carries Unreleased notes, in declared order, root last. Given a kind it applies to ALL of them. Given none, each unit takes the kind from its own "## Unreleased (minor)" heading, and a unit whose heading is silent refuses' })
31
36
 
32
37
  async execute(): Promise<number> {
33
- if (this.kind !== 'major' && this.kind !== 'minor' && this.kind !== 'patch') {
38
+ const KINDS = ['major', 'minor', 'patch']
39
+ // `bump rvlib-shipkit` names a unit and leaves the kind to its heading
40
+ const kind = this.kind != null && KINDS.includes(this.kind) ? (this.kind as BumpKind) : null
41
+ const unit = kind == null && this.kind != null ? this.kind : this.unit
42
+ if (kind == null && this.kind != null && this.unit != null) {
34
43
  this.context.stderr.write(`FAIL: bump takes major, minor or patch (got '${this.kind}')\n`)
35
44
  return 1
36
45
  }
37
46
  try {
38
47
  const config = await loadRepoConfig(process.cwd())
39
- return await runBump(process.cwd(), config, this.kind as BumpKind, {
48
+ return await runBump(process.cwd(), config, kind, {
40
49
  dryRun: this.dryRun,
41
50
  noRelease: this.noRelease,
42
51
  noMerge: this.noMerge,
43
- unit: this.unit,
52
+ unit,
44
53
  all: this.all,
45
54
  print: (l) => this.context.stdout.write(`${l}\n`),
46
55
  })
package/src/cli/cmd_Ci.ts CHANGED
@@ -21,9 +21,9 @@ export class CiCommand extends Command {
21
21
 
22
22
  bail = Option.Boolean('--bail', false, { description: 'stop at the first failure' })
23
23
  json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
24
- fix = Option.Boolean('--fix', true, { description: 'repair the mechanical slots before gating. Default on. --no-fix skips the repair pass, checks can still write, add --frozen for a run that writes nothing. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
24
+ fix = Option.Boolean('--fix', true, { description: 'repair the mechanical slots before gating. Default on. --no-fix skips the repair pass, checks can still write. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
25
25
  fixRules = Option.Boolean('--fix-rules', true, { description: 'apply the structural rule autofixes too. Default on. --no-fix-rules leaves repo config alone' })
26
- frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining rules (SK005 never touches package.json). With --no-fix, the run writes nothing' })
26
+ frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining rules (SK005 never touches package.json). STATUS.md is still refreshed, so no combination of flags makes the run write nothing' })
27
27
  skip = Option.Array('--skip', [], { description: 'skip a task group (repeatable)' })
28
28
  withIds = Option.Array('--with', [], { description: 'include an opt-in task (repeatable)' })
29
29
  only = Option.Array('--only', [], { description: 'run only this task + its dep closure (repeatable)' })
@@ -27,6 +27,9 @@ export type CiOpts = {
27
27
  fix?: boolean
28
28
  /** also apply the structural SK rule fixers during the fix pass */
29
29
  fixRules?: boolean
30
+ /** bust every check's TTL cache. A release gates with this on: a blocking
31
+ * rule replaying a clean pass from hours ago is not a gate. */
32
+ fresh?: boolean
30
33
  /** report-only for self-maintaining rules and checks (SHIPKIT_FROZEN
31
34
  * travels to child checks). The release path passes it: a rule that
32
35
  * rewrites package.json between the gate and `npm publish` packs a tree
@@ -93,7 +96,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
93
96
  // declares whether it blocks, and that is the only question about it.
94
97
  const checks = o.skipChecks === true
95
98
  ? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
96
- : await collectChecks(root, { status: false, config: c, frozen: o.frozen })
99
+ : await collectChecks(root, { status: false, config: c, frozen: o.frozen, fresh: o.fresh })
97
100
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
98
101
  for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
99
102
  for (const u of checks.undeclared) {
@@ -0,0 +1,64 @@
1
+ // a commit must not silently change WHICH REPO this is.
2
+ //
3
+ // the failure this exists for: a fixture builder ran with its cwd on the real
4
+ // repo instead of a temp dir, overwrote the root package.json and
5
+ // repo.config.ts with a throwaway shape, and committed. Every other gate
6
+ // passed, because a gutted repo still lints, still typechecks and still has a
7
+ // valid package.json. Only the NAME betrayed it.
8
+ import { existsSync } from 'fs'
9
+ import { join } from 'path'
10
+
11
+ export type IdentityOutcome =
12
+ | { status: 'skipped'; reason: string }
13
+ | { status: 'clean' }
14
+ | { status: 'changed'; changes: string[]; scratch: string[] }
15
+
16
+ /** scratch files a refactor leaves behind. They are never a deliberate commit,
17
+ * and one of them rode in with the fixture accident. */
18
+ const SCRATCH = /\.(bak|orig|rej)$/
19
+
20
+ const git = (root: string, args: string[]): { code: number; out: string } => {
21
+ const p = Bun.spawnSync(['git', ...args], { cwd: root, env: process.env })
22
+ return { code: p.exitCode, out: p.stdout.toString() }
23
+ }
24
+
25
+ /** the `name:` a repo.config.ts declares, read textually: importing it would
26
+ * run the file, and the staged version is exactly the one not to trust. */
27
+ export function configName(source: string): string | null {
28
+ return source.match(/\bname\s*:\s*['"`]([^'"`]+)['"`]/)?.[1] ?? null
29
+ }
30
+
31
+ export function packageName(source: string): string | null {
32
+ try { return (JSON.parse(source) as { name?: string }).name ?? null }
33
+ catch { return null }
34
+ }
35
+
36
+ /** compare the INDEX against HEAD: what is about to be committed, versus what
37
+ * the repo already agreed it is. Absent on either side is not a change, so
38
+ * adding or removing a file is left to the rules that own that. */
39
+ function identityDrift(root: string, path: string, read: (s: string) => string | null): string | null {
40
+ const staged = git(root, ['show', `:${path}`])
41
+ const head = git(root, ['show', `HEAD:${path}`])
42
+ if (staged.code !== 0 || head.code !== 0) return null
43
+ const before = read(head.out)
44
+ const after = read(staged.out)
45
+ if (before == null || after == null || before === after) return null
46
+ return `${path}: name '${before}' → '${after}'`
47
+ }
48
+
49
+ export function runRootIdentity(root: string): IdentityOutcome {
50
+ if (git(root, ['rev-parse', '--verify', 'HEAD']).code !== 0)
51
+ return { status: 'skipped', reason: 'no HEAD yet — nothing to compare the first commit against' }
52
+ if (process.env['SHIPKIT_ALLOW_IDENTITY_CHANGE'] === '1')
53
+ return { status: 'skipped', reason: 'SHIPKIT_ALLOW_IDENTITY_CHANGE=1' }
54
+
55
+ const changes = [
56
+ identityDrift(root, 'package.json', packageName),
57
+ ...(existsSync(join(root, 'repo.config.ts')) ? [identityDrift(root, 'repo.config.ts', configName)] : []),
58
+ ].filter((c): c is string => c != null)
59
+
60
+ const added = git(root, ['diff', '--cached', '--name-only', '--diff-filter=A'])
61
+ const scratch = added.out.split('\n').map((l) => l.trim()).filter((l) => l !== '' && SCRATCH.test(l))
62
+
63
+ return changes.length === 0 && scratch.length === 0 ? { status: 'clean' } : { status: 'changed', changes, scratch }
64
+ }
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
7
7
  import { join } from 'path'
8
8
  import { commitMessageOffenses } from './commit-message.ts'
9
9
  import { runLeakGuard } from './leak-guard.ts'
10
+ import { runRootIdentity } from './root-identity.ts'
10
11
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
11
12
  import type { RepoConfig } from '../repo-config.ts'
12
13
 
@@ -20,7 +21,7 @@ export type HooksConfig = {
20
21
  /** keyword file for the leak guard (default '.rv-private/banned-keywords.txt') */
21
22
  keywordFile?: string
22
23
  /** disable a built-in gate, visible in `hooks list`, never silent */
23
- disable?: ('leak-guard' | 'commit-message')[]
24
+ disable?: ('leak-guard' | 'commit-message' | 'root-identity')[]
24
25
  }
25
26
 
26
27
  const HOOK_DIR = 'hooks'
@@ -66,6 +67,22 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
66
67
  ],
67
68
  })
68
69
  }
70
+ if (!(h.disable ?? []).includes('root-identity')) {
71
+ const r = runRootIdentity(root)
72
+ if (r.status === 'skipped') out.push({ id: 'root-identity', ok: true, lines: [`skip: root-identity (${r.reason})`] })
73
+ else if (r.status === 'clean') out.push({ id: 'root-identity', ok: true, lines: ['ok: root-identity unchanged'] })
74
+ else out.push({
75
+ id: 'root-identity',
76
+ ok: false,
77
+ lines: [
78
+ `FAIL: this commit changes WHICH REPO this is, which is almost always a fixture that escaped its temp dir`,
79
+ ...r.changes.map((c) => ` ${c}`),
80
+ ...r.scratch.map((s) => ` adds a scratch file: ${s}`),
81
+ ` fix: restore the real files (git checkout HEAD -- package.json repo.config.ts) and drop the scratch files`,
82
+ ` a deliberate rename: SHIPKIT_ALLOW_IDENTITY_CHANGE=1 git commit ...`,
83
+ ],
84
+ })
85
+ }
69
86
  for (const c of h.preCommit ?? []) {
70
87
  const r = await spawnWithTimeout(c.cmd, { cwd: root, timeoutMs: 120_000 })
71
88
  out.push({
@@ -123,6 +140,7 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
123
140
  const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) ? '' : ` (INACTIVE, ${kw} absent)`
124
141
  return [
125
142
  `pre-commit leak-guard${leakState} — staged content + paths vs ${kw}`,
143
+ `pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
126
144
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
127
145
  `commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution`,
128
146
  `commit-msg leak-guard${leakState} — the message text`,
@@ -28,11 +28,28 @@ export function extractChangelogSection(p: { changelog: string; version: string
28
28
  export const hasVersionSection = (changelog: string, version: string): boolean =>
29
29
  changelog.split('\n').some((l) => changelogHeading(version).test(l.trim()))
30
30
 
31
- const UNRELEASED = /^##\s*\[?unreleased\]?\s*$/i
31
+ // the heading may carry the kind this release IS: `## Unreleased (minor)`.
32
+ // Whoever writes the notes knows whether they broke something, and that is
33
+ // the only moment the answer is cheap. A batch release then needs no single
34
+ // kind imposed across units that changed by different amounts.
35
+ const UNRELEASED = /^##\s*\[?unreleased\]?\s*(?:\((major|minor|patch)\))?\s*$/i
36
+
37
+ export type ReleaseKind = 'major' | 'minor' | 'patch'
32
38
 
33
39
  export const hasUnreleasedHeading = (changelog: string): boolean =>
34
40
  changelog.split('\n').some((l) => UNRELEASED.test(l.trim()))
35
41
 
42
+ /** the kind declared on the Unreleased heading, or null when it says nothing.
43
+ * Null is never guessed into a kind: `--all` refuses and names the unit,
44
+ * because a wrong version number reaches a registry and cannot be recalled. */
45
+ export function declaredKind(changelog: string): ReleaseKind | null {
46
+ for (const l of changelog.split('\n')) {
47
+ const m = l.trim().match(UNRELEASED)
48
+ if (m != null) return (m[1]?.toLowerCase() as ReleaseKind | undefined) ?? null
49
+ }
50
+ return null
51
+ }
52
+
36
53
  /** rename the Unreleased heading to the new version. Returns the new text, or
37
54
  * an error when there is no such section or it carries no notes: a version
38
55
  * with empty notes is a version nobody can read later. */
@@ -28,7 +28,7 @@
28
28
  // that cannot roll back is a lie.
29
29
  import { existsSync, readFileSync, writeFileSync } from 'fs'
30
30
  import { relative } from 'path'
31
- import { hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
31
+ import { declaredKind, hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
32
32
  import { deriveRemote, dirtyPaths, git as gitAt, mergeProbe, remoteTags, tagExistsLocally } from './git-facts.ts'
33
33
  import { unitBlockers } from './unit-gate.ts'
34
34
  import { formatUnitError, releaseOrder, renderTag, resolveUnits, unitById, unitPrepublishScripts, type ReleaseUnit } from './units.ts'
@@ -145,7 +145,12 @@ function probeMergeIntoReleaseBranch(
145
145
  p: { from: string; to: string; remote: string; print: (l: string) => void },
146
146
  ): MergeRehearsal {
147
147
  const { from, to, remote, print } = p
148
- git(['fetch', remote, '--quiet'])
148
+ // a rehearsal's ONLY product is this answer, so it must not be computed
149
+ // from a remote ref that never refreshed. Offline, an expired credential
150
+ // or a declined prompt all land here.
151
+ const fetched = git(['fetch', remote, '--quiet'])
152
+ const stale = fetched.code !== 0
153
+ if (stale) print(`WARN could not fetch '${remote}' (git fetch exited ${fetched.code}) — the remote refs below may be stale`)
149
154
  const remoteRef = `${remote}/${to}`
150
155
  const localExists = git(['rev-parse', '--quiet', '--verify', to]).code === 0
151
156
  const remoteExists = git(['rev-parse', '--quiet', '--verify', remoteRef]).code === 0
@@ -173,12 +178,14 @@ function probeMergeIntoReleaseBranch(
173
178
  return { refuse: false, inconclusive: true }
174
179
  }
175
180
  print(`note: '${from}' merges cleanly into '${to}' (checked in the object database, no ref moved)`)
176
- return { refuse: false, inconclusive: false }
181
+ // a clean answer computed from refs that never refreshed proves less than
182
+ // it says, so the rehearsal declines to close clean
183
+ return { refuse: false, inconclusive: stale }
177
184
  }
178
185
 
179
- type Selected = { unit: ReleaseUnit; version: string; changelogRel: string }
186
+ type Selected = { unit: ReleaseUnit; version: string; changelogRel: string; kind: BumpKind }
180
187
 
181
- export async function runBump(root: string, config: RepoConfig, kind: BumpKind, o: BumpOpts = {}): Promise<number> {
188
+ export async function runBump(root: string, config: RepoConfig, kind: BumpKind | null, o: BumpOpts = {}): Promise<number> {
182
189
  const print = o.print ?? ((l: string) => console.log(l))
183
190
  if (o.all === true && o.unit != null) { print('FAIL: pass a unit or --all, never both'); return 1 }
184
191
 
@@ -283,12 +290,22 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind,
283
290
  const blockers: string[] = []
284
291
  const selected: Selected[] = []
285
292
  for (const unit of picked) {
286
- let version: string
287
- try { version = nextVersion(unit.version, kind) }
288
- catch (e) { blockers.push(`unit '${unit.id}': ${e instanceof Error ? e.message : String(e)}`); continue }
289
293
  const changelogRel = relative(root, unit.changelog)
290
294
  if (!existsSync(unit.changelog)) { blockers.push(`unit '${unit.id}': ${changelogRel} not found — a release states what changed`); continue }
291
- const promoted = promoteUnreleased(readFileSync(unit.changelog, 'utf8'), version)
295
+ const source = readFileSync(unit.changelog, 'utf8')
296
+ // an explicit kind on the command line wins for the whole selection.
297
+ // Without one, each unit answers for itself from its own heading, and
298
+ // a unit that never said is a refusal: guessing a version number
299
+ // publishes it, and a published number cannot be recalled.
300
+ const unitKind = kind ?? declaredKind(source)
301
+ if (unitKind == null) {
302
+ blockers.push(`unit '${unit.id}': ${changelogRel} does not say what kind of release this is — write "## Unreleased (minor)", or pass a kind that applies to every selected unit`)
303
+ continue
304
+ }
305
+ let version: string
306
+ try { version = nextVersion(unit.version, unitKind) }
307
+ catch (e) { blockers.push(`unit '${unit.id}': ${e instanceof Error ? e.message : String(e)}`); continue }
308
+ const promoted = promoteUnreleased(source, version)
292
309
  if ('error' in promoted) { blockers.push(`unit '${unit.id}': ${promoted.error}`); continue }
293
310
  const tag = renderTag(unit, version)
294
311
  if (tagExistsLocally(root, tag)) {
@@ -300,7 +317,7 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind,
300
317
  continue
301
318
  }
302
319
  blockers.push(...unitBlockers(root, config, unit))
303
- selected.push({ unit, version, changelogRel })
320
+ selected.push({ unit, version, changelogRel, kind: unitKind })
304
321
  }
305
322
  // everything the release needs that does not depend on the new version.
306
323
  // HEAD is the release branch by now, so the branch check applies — except
@@ -312,7 +329,7 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind,
312
329
  }))
313
330
  if (blockers.length > 0) { for (const b of blockers) print(`FAIL: ${b}`); rollBack(); return 1 }
314
331
 
315
- for (const s of selected) print(`note: ${s.unit.id} ${s.unit.version} → ${s.version} (${kind})`)
332
+ for (const s of selected) print(`note: ${s.unit.id} ${s.unit.version} → ${s.version} (${s.kind})`)
316
333
 
317
334
  // phase 2: ONE gate for the whole repo. The repo is one worktree, so N
318
335
  // gates cost N times as much and prove the same thing. Frozen, and fatal
@@ -92,6 +92,10 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
92
92
  fix: o.fix ?? false,
93
93
  frozen: true,
94
94
  status: false,
95
+ // a release must not gate on a clean pass cached hours ago: SK005's
96
+ // blocking branches are local facts (a link: that stopped resolving, a
97
+ // dep nobody installed) and a TTL replays them as green
98
+ fresh: true,
95
99
  config,
96
100
  failOnAdvisory: config.release?.requireHealthy === true,
97
101
  print: (l) => print(` ${l}`),
@@ -18,6 +18,9 @@ const END = '# <<< shipkit gitignore policy'
18
18
  const CONTRIB = '# repo-contributed lines (repo.config.ts gitignore:)'
19
19
 
20
20
  const POLICY_LINES = [
21
+ // the leak guard reads .rv-private/banned-keywords.txt: the file listing
22
+ // what must never be committed cannot itself be committable
23
+ '.rv-private/',
21
24
  '.rv-reflections/',
22
25
  '.rv-social/',
23
26
  '.rv-journal/',
@@ -98,7 +98,18 @@ export const undeclaredReleaseUnit: Rule = {
98
98
  return
99
99
  }
100
100
  const excused = decls.map((d) => join(env.root, d))
101
- const claimed = [...units.filter((u) => u.rel !== '').map((u) => u.dir), ...excused]
101
+ const declaredDirs = units.filter((u) => u.rel !== '').map((u) => u.dir)
102
+ // an excuse that also covers a RELEASE UNIT is the kill switch one
103
+ // level down: `notReleasable: ['libs']` beside `units: [libs/a]` reads
104
+ // as green while every new package under libs/ hides. The two lists
105
+ // describe opposite intents, so they must not overlap.
106
+ const overlapping = decls.filter((d) => declaredDirs.some((u) => u === join(env.root, d) || u.startsWith(`${join(env.root, d)}/`)))
107
+ if (overlapping.length > 0) {
108
+ for (const d of overlapping)
109
+ t.fail(`notReleasable '${d}' also covers a declared release unit, so it excuses every package under it while reading as green`, `repo.config notReleasable: name the dirs that are NOT release units, never a container holding one`)
110
+ return
111
+ }
112
+ const claimed = [...declaredDirs, ...excused]
102
113
  const orphans = pkgs.filter((d) => !covered(claimed, d))
103
114
  if (orphans.length === 0) {
104
115
  // PACKAGES, not entries: one entry over a nested tree excuses more