@shopify/shop-minis-cli 0.3.11 → 0.3.12

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 (36) hide show
  1. package/build/commands/create/examples/default/AGENTS.md +4 -0
  2. package/build/commands/setup/index.js +4 -0
  3. package/build/commands/setup/index.js.map +1 -1
  4. package/build/commands/verify-build/index.d.ts +2 -0
  5. package/build/commands/verify-build/index.js +14 -0
  6. package/build/commands/verify-build/index.js.map +1 -0
  7. package/build/data/mini-create.d.ts +1 -0
  8. package/build/data/mini-create.js +1 -0
  9. package/build/data/mini-create.js.map +1 -1
  10. package/build/data/mini.d.ts +1 -0
  11. package/build/data/mini.js +1 -0
  12. package/build/data/mini.js.map +1 -1
  13. package/build/data/types/autogenerated/shop-minis-admin-api/gql.d.ts +4 -4
  14. package/build/data/types/autogenerated/shop-minis-admin-api/gql.js +2 -2
  15. package/build/data/types/autogenerated/shop-minis-admin-api/gql.js.map +1 -1
  16. package/build/data/types/autogenerated/shop-minis-admin-api/graphql.d.ts +30 -0
  17. package/build/data/types/autogenerated/shop-minis-admin-api/graphql.js +2 -0
  18. package/build/data/types/autogenerated/shop-minis-admin-api/graphql.js.map +1 -1
  19. package/build/program.js +2 -0
  20. package/build/program.js.map +1 -1
  21. package/build/utils/allowed-dependencies.d.ts +7 -4
  22. package/build/utils/allowed-dependencies.js +8 -2
  23. package/build/utils/allowed-dependencies.js.map +1 -1
  24. package/build/utils/analytics.js +3 -1
  25. package/build/utils/analytics.js.map +1 -1
  26. package/build/utils/mini-uuid-cache.d.ts +2 -0
  27. package/build/utils/mini-uuid-cache.js +22 -0
  28. package/build/utils/mini-uuid-cache.js.map +1 -0
  29. package/build/utils/vite-config.d.ts +10 -0
  30. package/build/utils/vite-config.js +52 -0
  31. package/build/utils/vite-config.js.map +1 -1
  32. package/build/utils/worker-assets.d.ts +33 -0
  33. package/build/utils/worker-assets.js +239 -0
  34. package/build/utils/worker-assets.js.map +1 -0
  35. package/package.json +1 -1
  36. package/scripts/audit-dependencies.ts +419 -152
@@ -6,10 +6,12 @@
6
6
  * npx tsx audit-dependencies.ts # Audit new dependencies from git diff
7
7
  */
8
8
 
9
- import {exec} from 'child_process'
9
+ import {execFile} from 'child_process'
10
10
  import {promisify} from 'util'
11
11
 
12
- const execAsync = promisify(exec)
12
+ import semver from 'semver'
13
+
14
+ const execFileAsync = promisify(execFile)
13
15
 
14
16
  interface PackageInfo {
15
17
  package: string
@@ -19,45 +21,182 @@ interface PackageInfo {
19
21
  publishDate: string
20
22
  }
21
23
 
24
+ type ChangeKind = 'added' | 'bumped'
25
+
26
+ // OSV's `database_specific.severity` uses GHSA labels (LOW/MODERATE/HIGH/
27
+ // CRITICAL). Some older OSV records (e.g. PYSEC) omit the field; those are
28
+ // tracked as UNKNOWN.
29
+ type Severity = 'CRITICAL' | 'HIGH' | 'MODERATE' | 'LOW' | 'UNKNOWN'
30
+
31
+ const SEVERITY_ORDER: Severity[] = [
32
+ 'CRITICAL',
33
+ 'HIGH',
34
+ 'MODERATE',
35
+ 'LOW',
36
+ 'UNKNOWN',
37
+ ]
38
+
39
+ interface VulnerabilityRecord {
40
+ id: string
41
+ summary: string
42
+ severity: Severity
43
+ }
44
+
22
45
  interface DependencyInfo {
23
46
  name: string
24
47
  version: string
48
+ kind: ChangeKind
49
+ previousVersion?: string
50
+ }
51
+
52
+ // Use Node's built-in fetch instead of shelling out to curl: keeps all input
53
+ // (package names, URLs, POST bodies) out of `/bin/sh -c`, so a malicious diff
54
+ // entry can't inject shell commands. 30s timeout per request via AbortSignal.
55
+ const FETCH_TIMEOUT_MS = 30_000
56
+
57
+ async function fetchJson(url: string, postBody?: unknown): Promise<any> {
58
+ let response: Response
59
+ try {
60
+ response = await fetch(url, {
61
+ method: postBody ? 'POST' : 'GET',
62
+ headers: postBody ? {'Content-Type': 'application/json'} : undefined,
63
+ body: postBody ? JSON.stringify(postBody) : undefined,
64
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
65
+ })
66
+ } catch (error: any) {
67
+ throw new Error(`Network error fetching ${url}: ${error?.message || error}`)
68
+ }
69
+
70
+ if (!response.ok) {
71
+ const body = await response.text().catch(() => '')
72
+ throw new Error(
73
+ `Non-2xx response from ${url}: HTTP ${response.status}${
74
+ body ? ` — ${body.slice(0, 200)}` : ''
75
+ }`
76
+ )
77
+ }
78
+
79
+ const text = await response.text()
80
+ try {
81
+ return JSON.parse(text)
82
+ } catch (error: any) {
83
+ throw new Error(
84
+ `Invalid JSON from ${url}: ${error?.message || error} (body: ${text.slice(0, 200)})`
85
+ )
86
+ }
87
+ }
88
+
89
+ function normalizeSeverity(raw: unknown): Severity {
90
+ if (typeof raw !== 'string') return 'UNKNOWN'
91
+ const upper = raw.toUpperCase()
92
+ if (
93
+ upper === 'CRITICAL' ||
94
+ upper === 'HIGH' ||
95
+ upper === 'MODERATE' ||
96
+ upper === 'LOW'
97
+ ) {
98
+ return upper
99
+ }
100
+ return 'UNKNOWN'
101
+ }
102
+
103
+ // Convert an OSV `affected.ranges[]` entry to a semver range string. OSV uses
104
+ // an event-stream representation: each "introduced" opens an interval, each
105
+ // "fixed" or "last_affected" closes it. Returns null for unsupported types
106
+ // (e.g. GIT ranges) or empty event lists.
107
+ function osvRangeToSemver(range: any): string | null {
108
+ if (range?.type === 'GIT') return null
109
+ const events: any[] = Array.isArray(range?.events) ? range.events : []
110
+ const parts: string[] = []
111
+ let lower: string | null = null
112
+ for (const event of events) {
113
+ if (event.introduced !== undefined) {
114
+ lower = event.introduced === '0' ? null : event.introduced
115
+ } else if (event.fixed !== undefined) {
116
+ parts.push(`${lower ? `>=${lower} ` : ''}<${event.fixed}`)
117
+ lower = null
118
+ } else if (event.last_affected !== undefined) {
119
+ parts.push(`${lower ? `>=${lower} ` : ''}<=${event.last_affected}`)
120
+ lower = null
121
+ }
122
+ }
123
+ if (lower !== null) parts.push(`>=${lower}`)
124
+ if (
125
+ lower === null &&
126
+ parts.length === 0 &&
127
+ events.some(event => event.introduced)
128
+ ) {
129
+ // "introduced: 0" with no closer → all versions affected.
130
+ return '>=0.0.0-0'
131
+ }
132
+ return parts.length > 0 ? parts.join(' || ') : null
133
+ }
134
+
135
+ // True if `vuln` reports the given package as affected at `spec` (either an
136
+ // exact version or a semver range). Used to drop OSV results that match the
137
+ // package by name but only affect other versions.
138
+ function osvAffectsSpec(vuln: any, packageName: string, spec: string): boolean {
139
+ const affectedEntries = Array.isArray(vuln?.affected) ? vuln.affected : []
140
+ const exact = semver.valid(spec)
141
+ for (const affected of affectedEntries) {
142
+ if (affected?.package?.name !== packageName) continue
143
+ const ecosystem = affected?.package?.ecosystem
144
+ if (ecosystem && ecosystem !== 'npm') continue
145
+
146
+ if (Array.isArray(affected.versions) && affected.versions.length > 0) {
147
+ if (exact) {
148
+ if (affected.versions.includes(exact)) return true
149
+ } else {
150
+ for (const version of affected.versions) {
151
+ if (semver.satisfies(version, spec)) return true
152
+ }
153
+ }
154
+ }
155
+
156
+ for (const range of affected.ranges || []) {
157
+ const vulnRange = osvRangeToSemver(range)
158
+ if (!vulnRange) continue
159
+ try {
160
+ if (exact) {
161
+ if (semver.satisfies(exact, vulnRange)) return true
162
+ } else if (semver.intersects(spec, vulnRange)) {
163
+ return true
164
+ }
165
+ } catch {
166
+ // bad range — fall through
167
+ }
168
+ }
169
+ }
170
+ return false
25
171
  }
26
172
 
27
173
  class PackageAuditor {
28
174
  private packageName: string
29
175
  private version: string
176
+ private kind: ChangeKind
177
+ private previousVersion?: string
30
178
  private riskScore = 0
31
179
  private riskFactors: string[] = []
32
- private vulnerabilities: string[] = []
33
-
34
- constructor(packageName: string, version = 'latest') {
180
+ private vulnerabilities: VulnerabilityRecord[] = []
181
+
182
+ constructor(
183
+ packageName: string,
184
+ version = 'latest',
185
+ kind: ChangeKind = 'added',
186
+ previousVersion?: string
187
+ ) {
35
188
  this.packageName = packageName
36
189
  this.version = version
190
+ this.kind = kind
191
+ this.previousVersion = previousVersion
37
192
  }
38
193
 
39
194
  private async fetchJson(url: string): Promise<any> {
40
- try {
41
- const {stdout} = await execAsync(`curl -s "${url}"`, {
42
- maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large NPM responses
43
- })
44
- return JSON.parse(stdout)
45
- } catch (error) {
46
- throw new Error(`Failed to fetch ${url}: ${error}`)
47
- }
195
+ return fetchJson(url)
48
196
  }
49
197
 
50
198
  private async fetchPostJson(url: string, body: any): Promise<any> {
51
- try {
52
- const jsonBody = JSON.stringify(body)
53
- const {stdout} = await execAsync(
54
- `curl -s -X POST -H "Content-Type: application/json" -d '${jsonBody}' "${url}"`,
55
- {maxBuffer: 1024 * 1024 * 10} // 10MB buffer for large responses
56
- )
57
- return JSON.parse(stdout)
58
- } catch (error) {
59
- throw new Error(`Failed to POST to ${url}: ${error}`)
60
- }
199
+ return fetchJson(url, body)
61
200
  }
62
201
 
63
202
  private async fetchNpmData(): Promise<any> {
@@ -105,14 +244,22 @@ class PackageAuditor {
105
244
  }
106
245
  }
107
246
 
108
- private async fetchDownloads(): Promise<number> {
247
+ // Returns null when the downloads endpoint is unreachable so the caller can
248
+ // skip adoption scoring rather than reject the audit. Downloads are
249
+ // telemetry, not a security signal — an npmjs.org outage shouldn't block
250
+ // safe allowlist changes when OSV (the real gate) is healthy.
251
+ private async fetchDownloads(): Promise<number | null> {
109
252
  try {
110
253
  const data = await this.fetchJson(
111
254
  `https://api.npmjs.org/downloads/point/last-week/${this.packageName}`
112
255
  )
113
256
  return data.downloads || 0
114
- } catch {
115
- return 0
257
+ } catch (error) {
258
+ const message = error instanceof Error ? error.message : String(error)
259
+ console.error(
260
+ `⚠️ Download stats unavailable for ${this.packageName}: ${message}`
261
+ )
262
+ return null
116
263
  }
117
264
  }
118
265
 
@@ -149,67 +296,68 @@ class PackageAuditor {
149
296
  }
150
297
 
151
298
  private async checkPublicVulnerabilities(): Promise<void> {
152
- const vulnerabilities: string[] = []
153
- let totalVulnCount = 0
154
-
155
- // 1. Check NPM Security Advisories (official, reliable)
156
- try {
157
- const auditData = await this.fetchJson(
158
- `https://registry.npmjs.org/-/npm/v1/security/advisories/search?text=${this.packageName}`
159
- )
160
-
161
- if (auditData.objects && auditData.objects.length > 0) {
162
- auditData.objects.forEach((advisory: any) => {
163
- if (advisory.package_name === this.packageName) {
164
- totalVulnCount++
165
- vulnerabilities.push(`NPM: ${advisory.title}`)
166
- }
167
- })
168
- }
169
- } catch {
170
- // NPM audit API might not be available
171
- }
172
-
173
- // 2. Check OSV (Google-maintained, comprehensive)
174
- try {
175
- const osvData = await this.fetchPostJson(`https://api.osv.dev/v1/query`, {
176
- package: {name: this.packageName, ecosystem: 'npm'},
177
- })
178
-
179
- if (osvData.vulns && osvData.vulns.length > 0) {
180
- osvData.vulns.forEach((vuln: any) => {
181
- totalVulnCount++
182
- vulnerabilities.push(
183
- `OSV: ${vuln.id} - ${vuln.summary || 'Vulnerability'}`
184
- )
185
- })
186
- }
187
- } catch {
188
- // OSV might not have data for this package
189
- }
190
-
191
- // 3. Check Snyk (industry standard, but may require auth)
192
- try {
193
- const snykData = await this.fetchJson(
194
- `https://api.snyk.io/v1/test/npm/${this.packageName}/${this.version}`
195
- )
299
+ // OSV is the single source of vulnerability data. Errors here propagate up
300
+ // so the script fails loudly rather than silently reporting "no vulns
301
+ // found" on a network blip.
302
+ //
303
+ // The NPM advisories search endpoint and the Snyk test endpoint were
304
+ // previously consulted here but were removed: NPM's endpoint returns
305
+ // HTTP 404 ("does not exist"), and Snyk requires an auth token that
306
+ // isn't provisioned in CI. Both silently returned zero results.
307
+ //
308
+ // Choice of OSV as sole source matches BumperBot's ADR 008-vulnerability-source.md
309
+ // in shop/world (OSV aggregates GHSA, so direct GHSA integration is dedup churn).
310
+ const osvData = await this.fetchPostJson(`https://api.osv.dev/v1/query`, {
311
+ package: {name: this.packageName, ecosystem: 'npm'},
312
+ })
313
+
314
+ if (!osvData?.vulns?.length) return
315
+
316
+ // OSV's query endpoint accepts an optional `version` field that would
317
+ // filter server-side, but `this.version` is often a constraint string
318
+ // (e.g. `^1.2.3`) coming from `allowed-dependencies.ts`, which OSV won't
319
+ // resolve. Instead query without a version and filter client-side using
320
+ // each vuln's `affected[].ranges` so a safe bump isn't rejected because
321
+ // some unrelated release of the same package is vulnerable.
322
+ const affectingVulns = osvData.vulns.filter((vuln: any) =>
323
+ osvAffectsSpec(vuln, this.packageName, this.version)
324
+ )
325
+ if (affectingVulns.length === 0) return
326
+
327
+ this.vulnerabilities = affectingVulns.map((vuln: any) => ({
328
+ id: vuln.id,
329
+ summary: vuln.summary || 'Vulnerability',
330
+ severity: normalizeSeverity(vuln.database_specific?.severity),
331
+ }))
332
+
333
+ const counts = this.severityCounts()
334
+ const summary = SEVERITY_ORDER.filter(sev => counts[sev] > 0)
335
+ .map(sev => `${counts[sev]} ${sev}`)
336
+ .join(', ')
337
+ this.riskFactors.push(
338
+ `${this.vulnerabilities.length} security vulnerabilities found (${summary})`
339
+ )
340
+ // Weighted by severity so a single CRITICAL trips REJECT (score > 49).
341
+ // Reject threshold is 50, so CRITICAL alone is enough; two HIGH or four
342
+ // MODERATE also reach it. Unweighted counts let a single CRITICAL slip
343
+ // through as APPROVE, which is the bug binks-code-reviewer flagged.
344
+ const weighted =
345
+ counts.CRITICAL * 50 + counts.HIGH * 25 + counts.MODERATE * 5 + counts.LOW
346
+ this.riskScore += Math.min(weighted, 100)
347
+ }
196
348
 
197
- if (snykData.issues && snykData.issues.vulnerabilities) {
198
- snykData.issues.vulnerabilities.forEach((vuln: any) => {
199
- totalVulnCount++
200
- vulnerabilities.push(`Snyk: ${vuln.title}`)
201
- })
202
- }
203
- } catch {
204
- // Snyk API might require auth or have rate limits - continue without it
349
+ private severityCounts(): Record<Severity, number> {
350
+ const counts: Record<Severity, number> = {
351
+ CRITICAL: 0,
352
+ HIGH: 0,
353
+ MODERATE: 0,
354
+ LOW: 0,
355
+ UNKNOWN: 0,
205
356
  }
206
-
207
- // Calculate risk based on findings
208
- if (totalVulnCount > 0) {
209
- this.riskScore += Math.min(totalVulnCount * 5, 50)
210
- this.riskFactors.push(`${totalVulnCount} security vulnerabilities found`)
211
- this.vulnerabilities = vulnerabilities
357
+ for (const vuln of this.vulnerabilities) {
358
+ counts[vuln.severity]++
212
359
  }
360
+ return counts
213
361
  }
214
362
 
215
363
  private assessMaintenanceRisk(publishDate: string): void {
@@ -228,7 +376,11 @@ class PackageAuditor {
228
376
  }
229
377
  }
230
378
 
231
- private assessAdoptionRisk(downloads: number): void {
379
+ private assessAdoptionRisk(downloads: number | null): void {
380
+ if (downloads === null) {
381
+ this.riskFactors.push('Download stats unavailable (not scored)')
382
+ return
383
+ }
232
384
  if (downloads < 1000) {
233
385
  this.riskScore += 25
234
386
  this.riskFactors.push('Low adoption (<1K downloads/week)')
@@ -250,18 +402,58 @@ class PackageAuditor {
250
402
  return '❌ REJECT'
251
403
  }
252
404
 
253
- private generateReport(packageInfo: PackageInfo, downloads: number): void {
405
+ private getChangeLabel(): string {
406
+ if (this.kind === 'bumped' && this.previousVersion) {
407
+ return `🔁 Bumped from ${this.previousVersion}`
408
+ }
409
+ return '➕ Added'
410
+ }
411
+
412
+ // Sort vulns worst-first and show the top N; if more exist, summarize the
413
+ // tail by severity so a reviewer can see what's hidden.
414
+ private formatTopVulnerabilities(limit: number): string {
415
+ const sorted = [...this.vulnerabilities].sort(
416
+ (left, right) =>
417
+ SEVERITY_ORDER.indexOf(left.severity) -
418
+ SEVERITY_ORDER.indexOf(right.severity)
419
+ )
420
+ const lines = sorted
421
+ .slice(0, limit)
422
+ .map(vuln => ` - [${vuln.severity}] OSV: ${vuln.id} - ${vuln.summary}`)
423
+ if (sorted.length <= limit) return lines.join('\n')
424
+
425
+ const tailCounts: Record<Severity, number> = {
426
+ CRITICAL: 0,
427
+ HIGH: 0,
428
+ MODERATE: 0,
429
+ LOW: 0,
430
+ UNKNOWN: 0,
431
+ }
432
+ for (const vuln of sorted.slice(limit)) tailCounts[vuln.severity]++
433
+ const tailSummary = SEVERITY_ORDER.filter(sev => tailCounts[sev] > 0)
434
+ .map(sev => `${tailCounts[sev]} ${sev}`)
435
+ .join(', ')
436
+ return `${lines.join('\n')}\n - ... and ${
437
+ sorted.length - limit
438
+ } more (${tailSummary})`
439
+ }
440
+
441
+ private generateReport(
442
+ packageInfo: PackageInfo,
443
+ downloads: number | null
444
+ ): void {
254
445
  const riskLevel = this.getRiskLevel()
255
446
  const decision = this.getDecision()
256
- const formattedDownloads = downloads.toLocaleString()
447
+ const formattedDownloads =
448
+ downloads === null ? 'unavailable' : `${downloads.toLocaleString()}/week`
257
449
 
258
450
  console.log(`
259
451
  ### 📦 ${packageInfo.package}@${packageInfo.version}
260
452
 
261
- **${riskLevel}** | **${decision}**
453
+ **${this.getChangeLabel()}** | **${riskLevel}** | **${decision}**
262
454
 
263
- 📈 **Downloads:** ${formattedDownloads}/week
264
- 🔗 **Dependencies:** ${packageInfo.dependenciesCount}
455
+ 📈 **Downloads:** ${formattedDownloads}
456
+ 🔗 **Dependencies:** ${packageInfo.dependenciesCount}
265
457
  📊 **Risk Score:** ${this.riskScore}/100
266
458
 
267
459
  ${
@@ -276,14 +468,7 @@ ${
276
468
  this.vulnerabilities.length > 0
277
469
  ? `🚨 **Security Vulnerabilities (${
278
470
  this.vulnerabilities.length
279
- }):**\n${this.vulnerabilities
280
- .slice(0, 3)
281
- .map(vuln => ` - ${vuln}`)
282
- .join('\n')}${
283
- this.vulnerabilities.length > 3
284
- ? `\n - ... and ${this.vulnerabilities.length - 3} more`
285
- : ''
286
- }`
471
+ }):**\n${this.formatTopVulnerabilities(3)}`
287
472
  : '🛡️ **Security:** No vulnerabilities found'
288
473
  }
289
474
 
@@ -291,7 +476,10 @@ ${
291
476
  `)
292
477
  }
293
478
 
294
- async audit(): Promise<void> {
479
+ // Returns true on success, false if the audit failed (e.g. network error).
480
+ // On failure, emits a ❌ REJECT report block so the failure is visible in
481
+ // the PR comment and trips the CI grep for `❌ REJECT`.
482
+ async audit(): Promise<boolean> {
295
483
  try {
296
484
  const npmData = await this.fetchNpmData()
297
485
  const packageInfo = this.extractPackageInfo(npmData)
@@ -302,74 +490,151 @@ ${
302
490
  this.assessAdoptionRisk(downloads)
303
491
 
304
492
  this.generateReport(packageInfo, downloads)
493
+ return true
305
494
  } catch (error) {
306
- console.error(`❌ Audit failed: ${error}`)
307
- process.exit(1)
495
+ const message = error instanceof Error ? error.message : String(error)
496
+ console.error(`❌ Audit failed for ${this.packageName}: ${message}`)
497
+ console.log(`
498
+ ### 📦 ${this.packageName}@${this.version}
499
+
500
+ **${this.getChangeLabel()}** | **🔴 HIGH/CRITICAL** | **❌ REJECT**
501
+
502
+ 🚨 **Audit could not complete:**
503
+ \`\`\`
504
+ ${message}
505
+ \`\`\`
506
+
507
+ ---
508
+ `)
509
+ return false
308
510
  }
309
511
  }
310
512
  }
311
513
 
312
- // Simple git diff function
313
- async function getAddedDependencies(): Promise<DependencyInfo[]> {
314
- try {
315
- const {stdout} = await execAsync(
316
- `git diff HEAD~1 HEAD -- src/utils/allowed-dependencies.ts`,
317
- {maxBuffer: 1024 * 1024}
318
- )
514
+ // Parse a `+`/`-` diff line for an entry like: '@scope/pkg': '1.2.3',
515
+ // `\s+` (not `.*`) anchors the indent so that an unquoted key like
516
+ // `three: '0.178.0'` isn't eaten by a greedy prefix.
517
+ //
518
+ // Both the name and version captures restrict to characters that are valid
519
+ // in npm package names and semver ranges. This is defense-in-depth: the
520
+ // captured strings are passed to fetch URLs and to the npm registry, never
521
+ // to a shell, but rejecting `$(){};|&\`` here keeps the parser honest.
522
+ const NAME_CHARS = 'a-zA-Z0-9@._/~^*+-'
523
+ const VERSION_CHARS = 'a-zA-Z0-9.<>=~^*+|& \\-'
524
+ const DEP_LINE_REGEX = new RegExp(
525
+ `^[+-]\\s+(?:['"\`]([${NAME_CHARS}]+)['"\`]|([${NAME_CHARS}]+))\\s*:\\s*['"\`]([${VERSION_CHARS}]+)['"\`]`
526
+ )
527
+
528
+ function parseDepLine(
529
+ line: string
530
+ ): {name: string; version: string} | undefined {
531
+ const match = line.match(DEP_LINE_REGEX)
532
+ if (!match) return undefined
533
+ const name = match[1] || match[2]
534
+ const version = match[3]
535
+ if (!name || !version) return undefined
536
+ return {name, version}
537
+ }
319
538
 
320
- const addedLines = stdout
321
- .split('\n')
322
- .filter(line => line.startsWith('+'))
323
- .filter(line => !line.startsWith('+++'))
324
- .filter(line => line.includes(':'))
539
+ // Refs come from env (or default constants), but git treats refs starting
540
+ // with `-` as flags. Reject anything that doesn't look like a ref to avoid
541
+ // surprises (e.g. `--upload-pack=…` style argv injection).
542
+ const REF_REGEX = /^[A-Za-z0-9_./~^@-]+$/
543
+ function validateRef(ref: string, label: string): string {
544
+ if (ref.startsWith('-') || !REF_REGEX.test(ref)) {
545
+ throw new Error(`Invalid ${label} ref: ${ref}`)
546
+ }
547
+ return ref
548
+ }
325
549
 
326
- const addedDependencies: DependencyInfo[] = []
550
+ async function getChangedDependencies(): Promise<DependencyInfo[]> {
551
+ // Allow CI to pass the PR's base and head refs so the diff covers every
552
+ // commit in the PR, not just the last one. Fall back to HEAD~1..HEAD for
553
+ // local use.
554
+ const baseRef = validateRef(
555
+ process.env.AUDIT_BASE_REF || 'HEAD~1',
556
+ 'AUDIT_BASE_REF'
557
+ )
558
+ const headRef = validateRef(
559
+ process.env.AUDIT_HEAD_REF || 'HEAD',
560
+ 'AUDIT_HEAD_REF'
561
+ )
327
562
 
328
- for (const line of addedLines) {
329
- const depRegex =
330
- /\+.*(?:['"`]([^'"`]+)['"`]|([a-zA-Z0-9@_-]+))\s*:\s*['"`]([^'"`]+)['"`]/
331
- const match = line.match(depRegex)
563
+ // `base...head` (three-dot) diffs against the merge-base of the two refs,
564
+ // not the current base tip. That keeps the audit stable when the base
565
+ // branch advances after the PR diverged: unrelated edits to
566
+ // allowed-dependencies.ts landing on main after this PR forked won't
567
+ // show up here.
568
+ const {stdout} = await execFileAsync(
569
+ 'git',
570
+ [
571
+ 'diff',
572
+ `${baseRef}...${headRef}`,
573
+ '--',
574
+ 'src/utils/allowed-dependencies.ts',
575
+ ],
576
+ {maxBuffer: 1024 * 1024}
577
+ )
332
578
 
333
- if (match) {
334
- const packageName = match[1] || match[2]
335
- const version = match[3]
336
- addedDependencies.push({name: packageName, version})
337
- }
338
- }
579
+ const removed = new Map<string, string>()
580
+ const added = new Map<string, string>()
339
581
 
340
- return addedDependencies
341
- } catch (error) {
342
- console.error(`⚠️ Could not get git diff: ${error}`)
343
- return []
582
+ for (const line of stdout.split('\n')) {
583
+ if (line.startsWith('---') || line.startsWith('+++')) continue
584
+ const parsed = parseDepLine(line)
585
+ if (!parsed) continue
586
+ if (line.startsWith('-')) removed.set(parsed.name, parsed.version)
587
+ else if (line.startsWith('+')) added.set(parsed.name, parsed.version)
344
588
  }
345
- }
346
589
 
347
- // Simple audit of multiple dependencies
348
- async function auditAddedDependencies(): Promise<void> {
349
- try {
350
- const depsToAudit = await getAddedDependencies()
351
-
352
- if (depsToAudit.length === 0) {
353
- console.log(
354
- '## Dependency Audit Results\n✅ No new dependencies found - no audit needed.'
355
- )
356
- return
590
+ const changes: DependencyInfo[] = []
591
+ for (const [name, version] of added) {
592
+ const previousVersion = removed.get(name)
593
+ if (previousVersion === undefined) {
594
+ changes.push({name, version, kind: 'added'})
595
+ } else if (previousVersion !== version) {
596
+ changes.push({name, version, kind: 'bumped', previousVersion})
357
597
  }
598
+ // Same name and version on both sides = unchanged, skip.
599
+ }
600
+ return changes
601
+ }
602
+
603
+ // Returns true if every audit succeeded.
604
+ async function auditChangedDependencies(): Promise<boolean> {
605
+ const depsToAudit = await getChangedDependencies()
358
606
 
607
+ if (depsToAudit.length === 0) {
359
608
  console.log(
360
- `## Dependency Audit Results\n🔍 Audited ${depsToAudit.length} new ${
361
- depsToAudit.length === 1 ? 'dependency' : 'dependencies'
362
- }:\n`
609
+ '## Dependency Audit Results\n No dependency changes detected — no audit needed.'
363
610
  )
611
+ return true
612
+ }
364
613
 
365
- for (const dep of depsToAudit) {
366
- const auditor = new PackageAuditor(dep.name, dep.version)
367
- await auditor.audit()
368
- }
369
- } catch (error) {
370
- console.error(`❌ Audit failed: ${error}`)
371
- process.exit(1)
614
+ const addedCount = depsToAudit.filter(dep => dep.kind === 'added').length
615
+ const bumpedCount = depsToAudit.length - addedCount
616
+ const summaryParts: string[] = []
617
+ if (addedCount) summaryParts.push(`${addedCount} added`)
618
+ if (bumpedCount) summaryParts.push(`${bumpedCount} version-bumped`)
619
+
620
+ console.log(
621
+ `## Dependency Audit Results\n🔍 Audited ${depsToAudit.length} ${
622
+ depsToAudit.length === 1 ? 'change' : 'changes'
623
+ } (${summaryParts.join(', ')}):\n`
624
+ )
625
+
626
+ let allOk = true
627
+ for (const dep of depsToAudit) {
628
+ const auditor = new PackageAuditor(
629
+ dep.name,
630
+ dep.version,
631
+ dep.kind,
632
+ dep.previousVersion
633
+ )
634
+ const ok = await auditor.audit()
635
+ if (!ok) allOk = false
372
636
  }
637
+ return allOk
373
638
  }
374
639
 
375
640
  // CLI interface
@@ -392,8 +657,9 @@ async function main(): Promise<void> {
392
657
  const args = process.argv.slice(2)
393
658
 
394
659
  if (args.length === 0) {
395
- // Mode: Audit new dependencies from git diff
396
- await auditAddedDependencies()
660
+ // Mode: Audit new/changed dependencies from git diff
661
+ const ok = await auditChangedDependencies()
662
+ if (!ok) process.exit(1)
397
663
  } else if (args[0] === '--help' || args[0] === '-h') {
398
664
  showUsage()
399
665
  } else {
@@ -401,7 +667,8 @@ async function main(): Promise<void> {
401
667
  const packageName = args[0]
402
668
  const version = args[1] || 'latest'
403
669
  const auditor = new PackageAuditor(packageName, version)
404
- await auditor.audit()
670
+ const ok = await auditor.audit()
671
+ if (!ok) process.exit(1)
405
672
  }
406
673
  }
407
674