rvlib-shipkit 0.35.8 → 0.37.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.
@@ -0,0 +1,142 @@
1
+ // The fleet: every repo on this disk that depends on the toolkit, and how far
2
+ // behind each one is.
3
+ //
4
+ // Why a scan and not a registry: `local-projects.md` only holds repos whose
5
+ // `docsBase` points at a wiki, which is 4 of the 10 that actually install the
6
+ // libs. A repo that never reported is exactly the repo most likely to be
7
+ // stale, so a registry is the wrong source for this question.
8
+ //
9
+ // This module DISCOVERS and REPORTS. It never updates: rule SK005 owns the
10
+ // update, with the lease, the dirty-tree guard and the frozen guard that took
11
+ // three bugs to get right. `--update` runs each repo's own `shipkit check`,
12
+ // so the fix travels through that one code path.
13
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
14
+ import { join } from 'path'
15
+
16
+ export type Pkg = { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
17
+
18
+ export type FleetDep = {
19
+ name: string
20
+ /** the range in package.json, or `link:…` for a source-linked dep */
21
+ pin: string
22
+ /** what node_modules actually carries, null when nothing is installed */
23
+ installed: string | null
24
+ /** npm latest, null when the registry was not consulted or not reachable */
25
+ latest: string | null
26
+ }
27
+
28
+ /** a jump that changes the MAJOR, which is a porting job rather than a bump.
29
+ *
30
+ * Deliberately NOT the semver rule. On a `0.x` package npm treats every
31
+ * minor as breaking, and by that rule every repo here is breaking, including
32
+ * the `0.24 → 0.35` shipkit jumps that rule SK005 already performs by itself
33
+ * every day. A flag that fires on everything is a flag nobody reads. What
34
+ * survives is the case a human has to own: `rvlib-pb-mobx 0.2.0 → 2.0.0`. */
35
+ export function isBreaking(installed: string, latest: string): boolean {
36
+ const major = (v: string) => Number(v.split('-')[0]!.split('.')[0]) || 0
37
+ return major(installed) !== major(latest)
38
+ }
39
+
40
+ export type FleetRepo = {
41
+ name: string
42
+ dir: string
43
+ deps: FleetDep[]
44
+ /** a repo pointing at a checkout rather than a published version. Never
45
+ * updated: the source IS the version, and that is a deliberate choice. */
46
+ linked: boolean
47
+ /** whether this repo can run the update itself (it has a shipkit binary) */
48
+ hasShipkit: boolean
49
+ behind: FleetDep[]
50
+ /** the subset of `behind` whose jump crosses a breaking boundary */
51
+ breaking: FleetDep[]
52
+ }
53
+
54
+ const readPkg = (dir: string): Pkg | null => {
55
+ const p = join(dir, 'package.json')
56
+ if (!existsSync(p)) return null
57
+ try {
58
+ return JSON.parse(readFileSync(p, 'utf8')) as Pkg
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ const installedVersion = (dir: string, name: string): string | null => readPkg(join(dir, 'node_modules', name))?.version ?? null
65
+
66
+ /** semver-ish compare, enough to answer "is this one behind that one". A
67
+ * prerelease tag sorts before its release, which is the safe direction: it
68
+ * reports behind rather than pretending parity. */
69
+ export function isBehind(installed: string, latest: string): boolean {
70
+ const part = (v: string) => v.split('-')[0]!.split('.').map((n) => Number(n) || 0)
71
+ const [a, b] = [part(installed), part(latest)]
72
+ for (let i = 0; i < 3; i++) {
73
+ if ((a[i] ?? 0) < (b[i] ?? 0)) return true
74
+ if ((a[i] ?? 0) > (b[i] ?? 0)) return false
75
+ }
76
+ return installed.includes('-') && !latest.includes('-')
77
+ }
78
+
79
+ export function scanFleet(root: string, o: { prefix?: string } = {}): FleetRepo[] {
80
+ const prefix = o.prefix ?? 'rvlib-'
81
+ const out: FleetRepo[] = []
82
+ let entries: string[]
83
+ try {
84
+ entries = readdirSync(root)
85
+ } catch {
86
+ return out
87
+ }
88
+ for (const e of entries.sort()) {
89
+ if (e.startsWith('.')) continue
90
+ const dir = join(root, e)
91
+ try {
92
+ if (!statSync(dir).isDirectory()) continue
93
+ } catch {
94
+ continue
95
+ }
96
+ const pkg = readPkg(dir)
97
+ if (pkg == null) continue
98
+ const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
99
+ const names = Object.keys(all).filter((n) => n.startsWith(prefix))
100
+ if (names.length === 0) continue
101
+ const deps: FleetDep[] = names.sort().map((name) => ({ name, pin: all[name]!, installed: installedVersion(dir, name), latest: null }))
102
+ out.push({
103
+ name: e,
104
+ dir,
105
+ deps,
106
+ linked: deps.some((d) => d.pin.startsWith('link:') || d.pin.startsWith('file:')),
107
+ hasShipkit: existsSync(join(dir, 'node_modules', 'rvlib-shipkit', 'src', 'cli.ts')),
108
+ behind: [],
109
+ breaking: [],
110
+ })
111
+ }
112
+ return out
113
+ }
114
+
115
+ /** npm latest per PACKAGE, fetched once and shared across every repo that
116
+ * depends on it. Ten repos on one lib is one request, not ten. */
117
+ export async function resolveLatest(repos: FleetRepo[], fetchLatest: (name: string) => Promise<string | null>): Promise<Map<string, string | null>> {
118
+ const names = new Set<string>()
119
+ for (const r of repos) for (const d of r.deps) if (!d.pin.startsWith('link:') && !d.pin.startsWith('file:')) names.add(d.name)
120
+ const latest = new Map<string, string | null>()
121
+ await Promise.all(
122
+ [...names].map(async (n) => {
123
+ try {
124
+ latest.set(n, await fetchLatest(n))
125
+ } catch {
126
+ latest.set(n, null)
127
+ }
128
+ }),
129
+ )
130
+ for (const r of repos) {
131
+ for (const d of r.deps) d.latest = latest.get(d.name) ?? null
132
+ r.behind = r.deps.filter((d) => d.installed != null && d.latest != null && isBehind(d.installed, d.latest))
133
+ r.breaking = r.behind.filter((d) => isBreaking(d.installed!, d.latest!))
134
+ }
135
+ return latest
136
+ }
137
+
138
+ export async function npmLatest(name: string): Promise<string | null> {
139
+ const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(10_000) })
140
+ if (!res.ok) return null
141
+ return ((await res.json()) as Pkg).version ?? null
142
+ }
@@ -101,7 +101,7 @@ const samePath = (a: string, b: string): boolean => {
101
101
  }
102
102
  }
103
103
 
104
- type RepoView = { nameWithOwner?: string; isPrivate?: boolean; url?: string; sshUrl?: string }
104
+ type RepoView = { nameWithOwner?: string; isPrivate?: boolean; sshUrl?: string }
105
105
 
106
106
  /** phase one: this dir IS a git work tree root. false means the caller must
107
107
  * not publish anything (a subdirectory of another repo, or git refused), and
@@ -194,7 +194,7 @@ export async function publishRepo(root: string, opts: PublishOpts = {}): Promise
194
194
  const login = firstGhLine(user.out)
195
195
  if (!NAME_RE.test(login)) return void p(`WARN no remote wired: gh did not answer with a login (${login})`)
196
196
  const target = `${login}/${name}`
197
- const viewArgs = ['repo', 'view', target, '--json', 'nameWithOwner,isPrivate,url,sshUrl']
197
+ const viewArgs = ['repo', 'view', target, '--json', 'nameWithOwner,isPrivate,sshUrl']
198
198
 
199
199
  let view = await gh(viewArgs)
200
200
  let created = false
@@ -217,9 +217,15 @@ export async function publishRepo(root: string, opts: PublishOpts = {}): Promise
217
217
  }
218
218
 
219
219
  const info = carveGhJson<RepoView>(view.out)
220
- const proto = await gh(['config', 'get', 'git_protocol'])
221
- const url = (firstGhLine(proto.out) === 'ssh' ? info?.sshUrl : info?.url) ?? info?.url ?? info?.sshUrl
222
- if (url == null) return void p(`WARN no remote wired: gh answer unreadable (${firstGhLine(view.out)})`)
220
+ // ALWAYS the ssh url gh gave, never the https one and never `gh config
221
+ // get git_protocol`: an https origin re-asks for a credential on every
222
+ // push, and a machine answering `https` there wires a remote that reports
223
+ // success and fails at the first push. An answer carrying no sshUrl is
224
+ // unreadable, not an invitation to build one: this module knows no host,
225
+ // and a guessed `github.com` remote against an enterprise gh is the same
226
+ // silent failure one host over
227
+ const url = info?.sshUrl
228
+ if (url == null) return void p(`WARN no remote wired: gh answered without an ssh url (${firstGhLine(view.out)})`)
223
229
 
224
230
  const add = await git(['remote', 'add', 'origin', url])
225
231
  if (add.code !== 0) return void p(`WARN no remote wired: git remote add failed (${firstGhLine(add.out)})`)
@@ -4,10 +4,23 @@
4
4
  //
5
5
  // the keyword file is deliberately untracked (a list of things to grep for
6
6
  // is itself private). absent file → loud skip, never a silent pass.
7
+ //
8
+ // a keyword matches as a SUBSTRING, which is what catches a private name
9
+ // glued into a hostname or an identifier. The cost is a false positive a repo
10
+ // cannot escape: a four letter row lands inside a dependency name or inside a
11
+ // base64 integrity hash in a lockfile, and that lockfile is uncommittable
12
+ // forever. A guard that cries wolf is a guard people answer with --no-verify,
13
+ // which protects nothing. Two answers, in this order. `w:<word>` rows match on
14
+ // word boundaries, which makes the PATTERN smarter rather than the guard
15
+ // looser. An ALLOWLIST waives a known-good match in a named context, and every
16
+ // waiver that fires is PRINTED with its reason, so a reader sees what was let
17
+ // through and why.
18
+ import { createHash } from 'crypto'
7
19
  import { existsSync, readFileSync } from 'fs'
8
- import { join } from 'path'
20
+ import { basename, join } from 'path'
9
21
 
10
- export type LeakHit = { where: string; pattern: string }
22
+ export type LeakHit = { where: string; pattern: string; file?: string }
23
+ export type LeakWaiver = LeakHit & { reason: string; source: string }
11
24
 
12
25
  /** what a hit REPORTS. The matched string is private by construction, and a
13
26
  * gate that prints it verbatim puts it in a terminal, a CI log and any paste
@@ -15,16 +28,66 @@ export type LeakHit = { where: string; pattern: string }
15
28
  export const maskPattern = (p: string): string =>
16
29
  p.startsWith('re:') || p.length <= 6 ? p : `${p.slice(0, 3)}…${p.slice(-2)} (${p.length} chars)`
17
30
 
31
+ /** a stable name for a pattern that does not CARRY it, so a tracked allowlist
32
+ * points at a private keyword without becoming a copy of the private keyword
33
+ * list. Printed beside every hit, which is how a waiver gets written without
34
+ * anyone typing the word.
35
+ *
36
+ * Honest scope: this stops the list being READ off a tracked file. It is no
37
+ * defence against a targeted guess, because a short keyword has a small
38
+ * search space and whoever already suspects the word can confirm it. */
39
+ export const patternFingerprint = (pattern: string): string =>
40
+ 'fp:' + createHash('sha256').update(pattern.toLowerCase()).digest('hex').slice(0, 12)
41
+
42
+ /** one known-good match a repo (or this machine) waives.
43
+ *
44
+ * `paths` alone waives every pattern in those files. `patterns` alone waives
45
+ * those patterns everywhere. The PAIR is the shape to reach for: this
46
+ * pattern, in these files, while the same pattern still blocks anywhere else.
47
+ * `inHashes` narrows further, to a match that lands inside a content hash. */
48
+ export type LeakAllowEntry = {
49
+ /** why this match is known good. Printed every time the waiver fires, so
50
+ * a reader months later can judge it. Required. */
51
+ reason: string
52
+ /** staged paths this entry covers: a repo-relative path, a bare basename
53
+ * ('bun.lock'), or a 'dir/' prefix. Absent = every target, the commit
54
+ * message included. */
55
+ paths?: string[]
56
+ /** the patterns this entry waives, named by FINGERPRINT (`fp:` + 12 hex,
57
+ * printed beside every hit). Absent = every pattern, on `paths` alone.
58
+ * A TRACKED source (repo.config.ts) takes the fingerprint form only: a
59
+ * literal there puts the private keyword into git. */
60
+ patterns?: string[]
61
+ /** waive only a match that falls inside a content hash (an npm `sha512-…`
62
+ * integrity field, a 40+ char base64 run). The bytes of a content address
63
+ * are arithmetic, not prose, so a keyword inside one is noise. The same
64
+ * keyword on a normal line of the same file still blocks. */
65
+ inHashes?: boolean
66
+ }
67
+
18
68
  export type LeakGuardResult =
19
69
  | { status: 'skipped'; reason: string }
20
- | { status: 'clean'; patterns: number }
21
- | { status: 'hit'; hits: LeakHit[]; patterns: number }
70
+ | { status: 'clean'; patterns: number; waived: LeakWaiver[]; notes: string[] }
71
+ | { status: 'hit'; hits: LeakHit[]; patterns: number; waived: LeakWaiver[]; notes: string[] }
72
+
73
+ export type ParsedPatterns = {
74
+ substrings: string[]
75
+ words: { word: string; re: RegExp }[]
76
+ regexes: { row: string; re: RegExp }[]
77
+ }
78
+
79
+ const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
22
80
 
23
81
  /** Parse the keyword file: blank lines and `#` comments dropped, `re:<pattern>`
24
- * rows become case-insensitive regexes, everything else a case-insensitive
25
- * substring. */
26
- export function parsePatterns(text: string): { substrings: string[]; regexes: { row: string; re: RegExp }[] } {
82
+ * rows become case-insensitive regexes, `w:<word>` rows match on word
83
+ * boundaries, everything else a case-insensitive substring.
84
+ *
85
+ * A `w:` row REPORTS as the bare word, so its mask and its fingerprint are
86
+ * the same with or without the prefix: adding the prefix to a row never
87
+ * invalidates a waiver already written against it. */
88
+ export function parsePatterns(text: string): ParsedPatterns {
27
89
  const substrings: string[] = []
90
+ const words: { word: string; re: RegExp }[] = []
28
91
  const regexes: { row: string; re: RegExp }[] = []
29
92
  for (const raw of text.split('\n')) {
30
93
  const row = raw.trim()
@@ -32,24 +95,134 @@ export function parsePatterns(text: string): { substrings: string[]; regexes: {
32
95
  if (row.startsWith('re:')) {
33
96
  try { regexes.push({ row, re: new RegExp(row.slice(3), 'i') }) }
34
97
  catch { /* a malformed row must not disable the whole guard */ }
98
+ } else if (row.startsWith('w:')) {
99
+ const word = row.slice(2).trim().toLowerCase()
100
+ // \b is ascii-only in JS, which lets an accented name match inside
101
+ // a longer accented word: the boundary is spelled in unicode
102
+ if (word !== '') words.push({ word, re: new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(word)}(?![\\p{L}\\p{N}_])`, 'giu') })
35
103
  } else substrings.push(row.toLowerCase())
36
104
  }
37
- return { substrings, regexes }
105
+ return { substrings, words, regexes }
38
106
  }
39
107
 
40
- function scan(haystack: string, where: string, p: ReturnType<typeof parsePatterns>, hits: LeakHit[]): void {
41
- const lower = haystack.toLowerCase()
42
- for (const s of p.substrings) if (lower.includes(s)) hits.push({ where, pattern: s })
43
- for (const r of p.regexes) if (r.re.test(haystack)) hits.push({ where, pattern: r.row })
108
+ type Span = [start: number, end: number]
109
+
110
+ // a substring row is matched by a case-insensitive REGEX rather than by
111
+ // indexOf over a lowercased copy: one unicode character (İ) lowercases into
112
+ // two, which slides every offset after it, and the offsets are what decide
113
+ // whether a match sits inside a content hash. Compiled once per keyword.
114
+ const subRe = new Map<string, RegExp>()
115
+ const substringRegex = (s: string): RegExp => {
116
+ const hit = subRe.get(s)
117
+ if (hit != null) return hit
118
+ const re = new RegExp(escapeRegex(s), 'gi')
119
+ subRe.set(s, re)
120
+ return re
44
121
  }
45
122
 
46
- export type LeakGuardOpts = {
47
- /** path to the keyword file, relative to root (default '.rv-private/banned-keywords.txt') */
48
- keywordFile?: string
49
- /** extra text to scan (a commit message, a release note) */
50
- text?: { label: string; content: string }[]
51
- /** scan the staged diff + staged paths (default true) */
52
- staged?: boolean
123
+ /** every occurrence of every pattern, keyed by the id a hit reports. */
124
+ function occurrences(haystack: string, p: ParsedPatterns): Map<string, Span[]> {
125
+ const found = new Map<string, Span[]>()
126
+ const push = (id: string, span: Span): void => {
127
+ const prev = found.get(id)
128
+ if (prev == null) found.set(id, [span])
129
+ else prev.push(span)
130
+ }
131
+ for (const s of p.substrings) for (const m of haystack.matchAll(substringRegex(s))) push(s, [m.index, m.index + m[0].length])
132
+ for (const w of p.words) for (const m of haystack.matchAll(w.re)) push(w.word, [m.index, m.index + m[0].length])
133
+ for (const r of p.regexes) {
134
+ const g = new RegExp(r.re.source, r.re.flags.includes('g') ? r.re.flags : r.re.flags + 'g')
135
+ for (const m of haystack.matchAll(g)) push(r.row, [m.index, m.index + m[0].length])
136
+ }
137
+ return found
138
+ }
139
+
140
+ // an npm/yarn integrity field, and any long base64 run. ONLY an entry that
141
+ // asked for `inHashes` consults this: an api token is also a long base64 run,
142
+ // so skipping these by default would be the guard going quiet on the exact
143
+ // shape it exists to catch.
144
+ const HASH_SPAN = /(?:sha\d{1,3}|md5)-[A-Za-z0-9+/=]{16,}|[A-Za-z0-9+/]{40,}={0,2}/gi
145
+
146
+ const hashSpans = (text: string): Span[] => [...text.matchAll(HASH_SPAN)].map((m): Span => [m.index, m.index + m[0].length])
147
+
148
+ const inside = (span: Span, spans: Span[]): boolean => spans.some(([a, b]) => span[0] >= a && span[1] <= b)
149
+
150
+ /** a path selector: a repo-relative path, a bare basename, or a 'dir/' prefix. */
151
+ function pathCovered(file: string | undefined, selectors: string[]): boolean {
152
+ if (file == null) return false
153
+ return selectors.some((s) => s === file || (s.endsWith('/') && file.startsWith(s)) || (!s.includes('/') && basename(file) === s))
154
+ }
155
+
156
+ export type LoadedAllow = LeakAllowEntry & { source: string }
157
+
158
+ /** what the guard reads: one file's added lines, one staged path, or a text
159
+ * the caller handed in (a commit message). */
160
+ export type ScanTarget = { where: string; content: string; file?: string }
161
+
162
+ /** the whole decision, with no git and no disk in it: which matches block,
163
+ * which are waived, by whom, and why. */
164
+ export function evaluateLeak(targets: ScanTarget[], p: ParsedPatterns, allow: LoadedAllow[]): { hits: LeakHit[]; waived: LeakWaiver[] } {
165
+ const hits: LeakHit[] = []
166
+ const waived: LeakWaiver[] = []
167
+ // one row per distinct (where, file, pattern): a keyword hit 40 times in
168
+ // one file is one finding
169
+ const seen = new Set<string>()
170
+ for (const t of targets) {
171
+ const found = occurrences(t.content, p)
172
+ if (found.size === 0) continue
173
+ let hashes: Span[] | null = null
174
+ for (const [id, spans] of found) {
175
+ const key = `${t.where} ${t.file ?? ''} ${id}`
176
+ if (seen.has(key)) continue
177
+ seen.add(key)
178
+ const fp = patternFingerprint(id)
179
+ const entry = allow.find((a) => {
180
+ if (a.paths != null && !pathCovered(t.file, a.paths)) return false
181
+ if (a.patterns != null && !a.patterns.some((x) => x === fp || x.toLowerCase() === id)) return false
182
+ if (a.inHashes === true) {
183
+ hashes ??= hashSpans(t.content)
184
+ if (!spans.every((s) => inside(s, hashes!))) return false
185
+ }
186
+ return true
187
+ })
188
+ if (entry == null) hits.push({ where: t.where, pattern: id, file: t.file })
189
+ else waived.push({ where: t.where, pattern: id, file: t.file, reason: entry.reason, source: entry.source })
190
+ }
191
+ }
192
+ return { hits, waived }
193
+ }
194
+
195
+ const ALLOW_KEYS = ['reason', 'paths', 'patterns', 'inHashes']
196
+ const FINGERPRINT = /^fp:[0-9a-f]{12}$/
197
+
198
+ /** why an entry cannot be used, or null. `tracked` = the source is committed
199
+ * (repo.config.ts), where a literal pattern publishes the private word the
200
+ * guard exists to keep out of git. */
201
+ export function leakAllowProblem(entry: unknown, where: string, tracked: boolean): string | null {
202
+ if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) return `${where}: every entry is an object`
203
+ const e = entry as Record<string, unknown>
204
+ for (const k of Object.keys(e)) if (!ALLOW_KEYS.includes(k)) return `${where}: unknown key '${k}' (known: ${ALLOW_KEYS.join(', ')})`
205
+ if (typeof e['reason'] !== 'string' || e['reason'].trim() === '') return `${where}: needs a 'reason' — a waiver nobody can judge later is a hole`
206
+ if (e['paths'] == null && e['patterns'] == null)
207
+ return `${where}: needs 'paths' or 'patterns' — an entry with neither waives every pattern in every file, which is the guard switched off`
208
+ for (const k of ['paths', 'patterns'] as const) {
209
+ const v = e[k]
210
+ if (v == null) continue
211
+ if (!Array.isArray(v) || v.length === 0 || v.some((x) => typeof x !== 'string' || x.trim() === ''))
212
+ return `${where}: '${k}' is a non-empty array of non-empty strings`
213
+ }
214
+ if (e['inHashes'] != null && typeof e['inHashes'] !== 'boolean') return `${where}: 'inHashes' is a boolean`
215
+ if (tracked)
216
+ for (const x of (e['patterns'] as string[] | undefined) ?? [])
217
+ if (!FINGERPRINT.test(x))
218
+ return `${where}: '${x}' — a tracked allowlist names a pattern by FINGERPRINT (fp:…, printed beside every hit), never by value, or the private keyword lands in git`
219
+ return null
220
+ }
221
+
222
+ /** every problem in a declared allowlist. `defineRepo` throws on the first. */
223
+ export function leakAllowProblems(entries: unknown, where: string, tracked: boolean): string[] {
224
+ if (!Array.isArray(entries)) return [`${where}: leakAllow is an array of entries`]
225
+ return entries.map((e, i) => leakAllowProblem(e, `${where}[${i}]`, tracked)).filter((x): x is string => x != null)
53
226
  }
54
227
 
55
228
  /** the machine-global list every repo's guard also reads: one private list
@@ -62,36 +235,138 @@ export function globalKeywordFile(): string | null {
62
235
  return existsSync(p) ? p : null
63
236
  }
64
237
 
238
+ /** the machine-global allowlist, the twin of the keyword file. It is private,
239
+ * so it may name a pattern by value as well as by fingerprint. */
240
+ export function globalAllowFile(): string | null {
241
+ const home = process.env.HOME ?? ''
242
+ if (home === '') return null
243
+ const p = join(home, '.rv-private', 'leak-allow.json')
244
+ return existsSync(p) ? p : null
245
+ }
246
+
247
+ /** read an allowlist file (`LeakAllowEntry[]` as JSON). A file that will not
248
+ * parse, or an entry that cannot be used, is a NOTE and never a failure: a
249
+ * waiver that does not load leaves the guard stricter, never looser. */
250
+ export function loadAllowFile(path: string, tracked: boolean): { entries: LoadedAllow[]; notes: string[] } {
251
+ let parsed: unknown
252
+ try { parsed = JSON.parse(readFileSync(path, 'utf8')) }
253
+ catch (e) { return { entries: [], notes: [`${path} is not readable JSON (${e instanceof Error ? e.message.split('\n')[0] : String(e)}) — its waivers are IGNORED`] } }
254
+ if (!Array.isArray(parsed)) return { entries: [], notes: [`${path} holds an array of allowlist entries — its waivers are IGNORED`] }
255
+ const entries: LoadedAllow[] = []
256
+ const notes: string[] = []
257
+ parsed.forEach((raw, i) => {
258
+ const problem = leakAllowProblem(raw, `${path}[${i}]`, tracked)
259
+ if (problem != null) notes.push(`${problem} — entry IGNORED`)
260
+ else entries.push({ ...(raw as LeakAllowEntry), source: `${basename(path)}[${i}]` })
261
+ })
262
+ return { entries, notes }
263
+ }
264
+
265
+ export type LeakGuardOpts = {
266
+ /** path to the keyword file, relative to root (default '.rv-private/banned-keywords.txt') */
267
+ keywordFile?: string
268
+ /** extra text to scan (a commit message, a release note) */
269
+ text?: { label: string; content: string }[]
270
+ /** scan the staged diff + staged paths (default true) */
271
+ staged?: boolean
272
+ /** the repo's declared waivers (repo.config.ts hooks.leakAllow). Tracked,
273
+ * so fingerprints only. */
274
+ allow?: LeakAllowEntry[]
275
+ /** override the machine-global allowlist path; null reads none */
276
+ allowFile?: string | null
277
+ }
278
+
279
+ /** git c-quotes a path that carries a space or a non-ascii byte. */
280
+ function unquotePath(p: string): string {
281
+ if (!p.startsWith('"')) return p
282
+ try { return JSON.parse(p) as string } catch { return p.slice(1, -1) }
283
+ }
284
+
285
+ /** `@@ -old,n +new,m @@`, the only line that says how long a hunk body is. */
286
+ const HUNK_HEADER = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/
287
+
288
+ /** ADDED lines only, grouped by the file that receives them: what this commit
289
+ * puts INTO the object store. Removed and context lines are already in
290
+ * history, and a commit that DELETES private content must never be blocked by
291
+ * that content. The grouping is what lets a waiver name a file.
292
+ *
293
+ * A `+++ x` line is a file header only OUTSIDE a hunk body. Inside one it is
294
+ * an added line whose own text starts with `++`, and reading it as a header
295
+ * is a hole in both directions: `++ /dev/null` would drop every added line
296
+ * after it in that file, and `++ b/bun.lock` would file them under a name a
297
+ * waiver covers. So the hunk body is COUNTED, from the size its header
298
+ * declares. A header this does not recognise (a combined `@@@` diff) opens an
299
+ * unbounded body that only the next file closes, which is the safe direction:
300
+ * content is still scanned, and no line inside it can name a file. */
301
+ export function addedLinesByFile(diff: string): { file: string; content: string }[] {
302
+ const out: { file: string; content: string }[] = []
303
+ let current: { file: string; lines: string[] } | null = null
304
+ let oldLeft = 0
305
+ let newLeft = 0
306
+ const flush = (): void => {
307
+ if (current != null && current.lines.length > 0) out.push({ file: current.file, content: current.lines.join('\n') })
308
+ current = null
309
+ }
310
+ const add = (line: string): void => { if (current != null) current.lines.push(line.slice(1)) }
311
+ for (const line of diff.split('\n')) {
312
+ // a `diff --git ` at column 0 can never be diff CONTENT: a content line
313
+ // always carries its ' ', '+' or '-' prefix first
314
+ if (line.startsWith('diff --git ')) {
315
+ flush()
316
+ oldLeft = 0
317
+ newLeft = 0
318
+ } else if (oldLeft > 0 || newLeft > 0) {
319
+ if (line.startsWith('+')) { newLeft--; add(line) }
320
+ else if (line.startsWith('-')) oldLeft--
321
+ else if (!line.startsWith('\\')) { oldLeft--; newLeft-- } // '' counts for neither
322
+ } else if (line.startsWith('@@')) {
323
+ const h = HUNK_HEADER.exec(line)
324
+ oldLeft = h == null ? Infinity : h[1] == null ? 1 : Number(h[1])
325
+ newLeft = h == null ? Infinity : h[2] == null ? 1 : Number(h[2])
326
+ } else if (line.startsWith('+++ ')) {
327
+ flush()
328
+ const p = unquotePath(line.slice(4).trim())
329
+ current = p === '/dev/null' ? null : { file: p.startsWith('b/') ? p.slice(2) : p, lines: [] }
330
+ } else if (line.startsWith('+')) add(line) // a stray added line outside any hunk is still content
331
+ }
332
+ flush()
333
+ return out
334
+ }
335
+
65
336
  export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResult {
66
337
  const rel = o.keywordFile ?? '.rv-private/banned-keywords.txt'
67
338
  const file = join(root, rel)
68
339
  const sources = [existsSync(file) ? file : null, globalKeywordFile()].filter((f): f is string => f != null)
69
340
  if (sources.length === 0) return { status: 'skipped', reason: `${rel} not found and no ~/.rv-private/banned-keywords.txt — no keyword list, no leak guard` }
70
341
  const patterns = parsePatterns(sources.map((f) => readFileSync(f, 'utf8')).join('\n'))
71
- const count = patterns.substrings.length + patterns.regexes.length
342
+ const count = patterns.substrings.length + patterns.words.length + patterns.regexes.length
72
343
  if (count === 0) return { status: 'skipped', reason: `keyword file(s) have no patterns (${sources.join(', ')})` }
73
344
 
74
- const hits: LeakHit[] = []
345
+ const notes: string[] = []
346
+ const allow: LoadedAllow[] = []
347
+ ;(o.allow ?? []).forEach((e, i) => {
348
+ const problem = leakAllowProblem(e, `repo.config.ts hooks.leakAllow[${i}]`, true)
349
+ if (problem != null) notes.push(`${problem} — entry IGNORED`)
350
+ else allow.push({ ...e, source: `repo.config.ts hooks.leakAllow[${i}]` })
351
+ })
352
+ const globalAllow = o.allowFile === undefined ? globalAllowFile() : o.allowFile
353
+ if (globalAllow != null && existsSync(globalAllow)) {
354
+ const g = loadAllowFile(globalAllow, false)
355
+ allow.push(...g.entries)
356
+ notes.push(...g.notes)
357
+ }
358
+
359
+ const targets: ScanTarget[] = []
75
360
  if (o.staged !== false) {
76
- // staged CONTENT (what lands in the object store) and staged PATHS
77
- // (a filename can leak as loudly as a line of code)
78
361
  const diff = Bun.spawnSync(['git', 'diff', '--cached'], { cwd: root, maxBuffer: 256 * 1024 * 1024 })
79
- if (diff.exitCode === 0) {
80
- // ADDED lines only: what this commit puts INTO the object store.
81
- // removed/context lines are already in history, and a commit that
82
- // deletes private content must never be blocked by that content
83
- const added = diff.stdout.toString().split('\n')
84
- .filter((l) => l.startsWith('+') && !l.startsWith('+++'))
85
- .join('\n')
86
- scan(added, 'staged content', patterns, hits)
87
- }
362
+ if (diff.exitCode === 0)
363
+ for (const f of addedLinesByFile(diff.stdout.toString())) targets.push({ where: 'staged content', file: f.file, content: f.content })
364
+ // a filename can leak as loudly as a line of code
88
365
  const names = Bun.spawnSync(['git', 'diff', '--cached', '--name-only'], { cwd: root })
89
- if (names.exitCode === 0) scan(names.stdout.toString(), 'staged path', patterns, hits)
366
+ if (names.exitCode === 0) for (const n of names.stdout.toString().split('\n')) if (n.trim() !== '') targets.push({ where: 'staged path', file: n, content: n })
90
367
  }
91
- for (const t of o.text ?? []) scan(t.content, t.label, patterns, hits)
368
+ for (const t of o.text ?? []) targets.push({ where: t.label, content: t.content })
92
369
 
93
- // one row per distinct (where, pattern) — a keyword hit 40 times is one finding
94
- const seen = new Set<string>()
95
- const unique = hits.filter((h) => { const k = h.where + ' ' + h.pattern; if (seen.has(k)) return false; seen.add(k); return true })
96
- return unique.length > 0 ? { status: 'hit', hits: unique, patterns: count } : { status: 'clean', patterns: count }
370
+ const { hits, waived } = evaluateLeak(targets, patterns, allow)
371
+ return hits.length > 0 ? { status: 'hit', hits, patterns: count, waived, notes } : { status: 'clean', patterns: count, waived, notes }
97
372
  }