flagshark 1.0.0 โ†’ 1.0.1

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/README.md CHANGED
@@ -83,6 +83,8 @@ jobs:
83
83
  with:
84
84
  fetch-depth: 0 # Required for git blame age detection
85
85
  - uses: FlagShark/flagshark@v1
86
+ env:
87
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
86
88
  ```
87
89
 
88
90
  ### Action Inputs
@@ -93,9 +95,23 @@ jobs:
93
95
  | `threshold` | `6` | Staleness threshold in months |
94
96
  | `fail-threshold` | `0` | Health score below which the check fails (0 = never fail) |
95
97
 
98
+ ### Scan Modes
99
+
100
+ **`scan: changed`** (default) scans only files modified in the PR. Fast, focused on what you're changing.
101
+
102
+ **`scan: full`** scans the entire repository. Shows your full flag health score and finds stale flags everywhere, not just in changed files. Great for seeing the big picture:
103
+
104
+ ```yaml
105
+ - uses: FlagShark/flagshark@v1
106
+ with:
107
+ scan: full
108
+ env:
109
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
110
+ ```
111
+
96
112
  ### What the Action does
97
113
 
98
- On every PR, FlagShark comments with a table of stale flags found in the changed files:
114
+ On every PR, FlagShark comments with a table of stale flags:
99
115
 
100
116
  > ### ๐Ÿฆˆ FlagShark found 3 stale flags
101
117
  >
@@ -154,22 +170,6 @@ A flag is marked stale if **any** of these signals fires:
154
170
 
155
171
  FlagShark only checks files that actually import a flag SDK. A function called `isEnabled()` in a file that doesn't import LaunchDarkly/Unleash/etc. won't be flagged. This prevents false positives.
156
172
 
157
- ## Configuration
158
-
159
- ### `.flagsharkignore`
160
-
161
- Exclude files or specific flags:
162
-
163
- ```
164
- # Glob patterns for files
165
- test/**
166
- fixtures/**
167
-
168
- # Specific flag names (prefix with flag:)
169
- flag:PERMANENT_ADMIN_OVERRIDE
170
- flag:MAINTENANCE_MODE
171
- ```
172
-
173
173
  ## License
174
174
 
175
175
  MIT
package/action/index.ts CHANGED
@@ -1,15 +1,8 @@
1
1
  /**
2
2
  * GitHub Action entry point for FlagShark.
3
3
  *
4
- * This is a thin wrapper around the same detection engine used by the CLI.
5
- * It runs the scan, posts a PR comment with results, and sets a status check.
6
- *
7
- * Architecture:
8
- * action/index.ts (this file)
9
- * โ”‚
10
- * โ”œโ”€ Runs the same scanner + staleness logic as the CLI
11
- * โ”œโ”€ Posts a PR comment with markdown table
12
- * โ””โ”€ Sets a GitHub check status (pass/fail)
4
+ * Scans a repo for stale feature flags, posts a rich PR comment,
5
+ * writes a GitHub Actions job summary, and sets a status check.
13
6
  */
14
7
 
15
8
  import { readFileSync, readdirSync, statSync } from 'node:fs'
@@ -27,22 +20,22 @@ import type { StaleFlag } from '../src/staleness.js'
27
20
 
28
21
  const COMMENT_MARKER = '<!-- flagshark-action -->'
29
22
  const SKIP_DIRS = new Set([
30
- 'node_modules',
31
- 'vendor',
32
- '.git',
33
- 'dist',
34
- 'build',
35
- 'coverage',
36
- '__pycache__',
37
- '.next',
38
- '.turbo',
23
+ 'node_modules', 'vendor', '.git', 'dist', 'build',
24
+ 'coverage', '__pycache__', '.next', '.turbo',
39
25
  ])
40
26
 
27
+ // Logger that serializes objects properly instead of [object Object]
41
28
  const logger = {
42
- debug: (...args: unknown[]) => core.debug(args.map(String).join(' ')),
43
- info: (...args: unknown[]) => core.info(args.map(String).join(' ')),
44
- warn: (...args: unknown[]) => core.warning(args.map(String).join(' ')),
45
- error: (...args: unknown[]) => core.error(args.map(String).join(' ')),
29
+ debug: (...args: unknown[]) => core.debug(formatLogArgs(args)),
30
+ info: (...args: unknown[]) => core.info(formatLogArgs(args)),
31
+ warn: (...args: unknown[]) => core.warning(formatLogArgs(args)),
32
+ error: (...args: unknown[]) => core.error(formatLogArgs(args)),
33
+ }
34
+
35
+ function formatLogArgs(args: unknown[]): string {
36
+ return args.map(a =>
37
+ typeof a === 'object' && a !== null ? JSON.stringify(a, null, 2) : String(a)
38
+ ).join(' ')
46
39
  }
47
40
 
48
41
  async function run(): Promise<void> {
@@ -85,9 +78,7 @@ async function run(): Promise<void> {
85
78
  for (const fp of filePaths) {
86
79
  try {
87
80
  const stat = statSync(fp)
88
- if (stat.size > 5 * 1024 * 1024) {
89
- continue
90
- }
81
+ if (stat.size > 5 * 1024 * 1024) continue
91
82
  files.set(fp, readFileSync(fp, 'utf-8'))
92
83
  } catch {
93
84
  // Skip unreadable files
@@ -100,29 +91,62 @@ async function run(): Promise<void> {
100
91
  const result = await analyzer.analyzeFiles(files)
101
92
  const totalFlags = result.totalFlags.size
102
93
 
94
+ // Collect language stats
95
+ const langStats: Record<string, number> = {}
96
+ for (const [lang, count] of result.languages) {
97
+ langStats[lang] = count
98
+ }
99
+
100
+ // Collect detected providers
101
+ const allFlags: FeatureFlag[] = []
102
+ for (const flags of result.totalFlags.values()) {
103
+ allFlags.push(...(flags as FeatureFlag[]))
104
+ }
105
+ const providers = [...new Set(
106
+ allFlags.map(f => f.provider).filter((p): p is string => p !== null && p !== undefined && p !== '')
107
+ )]
108
+
109
+ core.info(`Detection complete: ${totalFlags} unique flags across ${Object.keys(langStats).length} languages`)
110
+
103
111
  // Run staleness analysis
104
112
  const staleFlags = await analyzeStaleness(result.totalFlags as Map<string, FeatureFlag[]>, {
105
113
  thresholdMonths: threshold,
106
114
  repoRoot: process.cwd(),
107
115
  })
108
116
 
109
- // Use unique stale flag names (not occurrences) for health score โ€” matches CLI formula
110
117
  const uniqueStaleNames = new Set(staleFlags.map((f) => f.name)).size
111
118
  const healthScore =
112
119
  totalFlags > 0 ? Math.round(((totalFlags - uniqueStaleNames) / totalFlags) * 100) : 100
113
-
114
120
  const scanDuration = Date.now() - startTime
115
121
 
122
+ // Pretty log output
123
+ core.info('')
124
+ core.info('โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”')
125
+ core.info('โ”‚ ๐Ÿฆˆ FlagShark Scan Results โ”‚')
126
+ core.info('โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค')
127
+ core.info(`โ”‚ Files scanned: ${String(files.size).padStart(6)} โ”‚`)
128
+ core.info(`โ”‚ Languages: ${String(Object.keys(langStats).length).padStart(6)} โ”‚`)
129
+ core.info(`โ”‚ Flags detected: ${String(totalFlags).padStart(6)} โ”‚`)
130
+ core.info(`โ”‚ Stale flags: ${String(uniqueStaleNames).padStart(6)} โ”‚`)
131
+ core.info(`โ”‚ Health score: ${String(healthScore).padStart(3)}/100 โ”‚`)
132
+ core.info(`โ”‚ Scan time: ${String(scanDuration).padStart(5)}ms โ”‚`)
133
+ core.info('โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜')
134
+ core.info('')
135
+
136
+ if (providers.length > 0) {
137
+ core.info(`Detected providers: ${providers.slice(0, 8).join(', ')}${providers.length > 8 ? ` (+${providers.length - 8} more)` : ''}`)
138
+ }
139
+
116
140
  // Set outputs
117
141
  core.setOutput('health-score', healthScore.toString())
118
- core.setOutput('stale-count', staleFlags.length.toString())
142
+ core.setOutput('stale-count', uniqueStaleNames.toString())
119
143
  core.setOutput('total-count', totalFlags.toString())
120
144
 
121
- // Post PR comment (only on PRs)
122
- if (github.context.payload.pull_request && staleFlags.length > 0) {
145
+ // Post PR comment
146
+ if (github.context.payload.pull_request && totalFlags > 0) {
123
147
  const token = process.env.GITHUB_TOKEN || core.getInput('token')
124
148
  if (token) {
125
- await postComment(token, staleFlags, totalFlags, healthScore)
149
+ await postComment(token, staleFlags, totalFlags, healthScore, scanMode, langStats, providers, scanDuration)
126
150
  }
127
151
  }
128
152
 
@@ -130,19 +154,51 @@ async function run(): Promise<void> {
130
154
  if (failThreshold > 0 && healthScore < failThreshold) {
131
155
  core.setFailed(
132
156
  `Flag health score ${healthScore}/100 is below threshold ${failThreshold}/100. ` +
133
- `${staleFlags.length} stale flags found.`,
157
+ `${uniqueStaleNames} stale flags found.`,
134
158
  )
135
- } else {
136
- core.info(`Flag Health Score: ${healthScore}/100 (${staleFlags.length}/${totalFlags} stale)`)
137
159
  }
138
160
 
139
- // Summary
140
- core.summary
141
- .addHeading('FlagShark Results', 2)
142
- .addRaw(`**Health Score:** ${healthScore}/100\n\n`)
143
- .addRaw(`**Flags:** ${totalFlags} total, ${staleFlags.length} stale\n\n`)
144
- .addRaw(`**Scan time:** ${scanDuration}ms\n`)
161
+ // Job summary (visible in Actions UI under "Summary" tab)
162
+ const healthEmoji = healthScore >= 90 ? '๐ŸŸข' : healthScore >= 70 ? '๐ŸŸก' : healthScore >= 40 ? '๐ŸŸ ' : '๐Ÿ”ด'
163
+
164
+ core.summary.addHeading('๐Ÿฆˆ FlagShark Scan Results', 2)
165
+ core.summary.addRaw(`\n${healthEmoji} **Health Score: ${healthScore}/100**\n\n`)
166
+ core.summary.addTable([
167
+ [{ data: 'Metric', header: true }, { data: 'Value', header: true }],
168
+ ['Files scanned', files.size.toString()],
169
+ ['Languages', Object.keys(langStats).join(', ') || 'none'],
170
+ ['Total flags', totalFlags.toString()],
171
+ ['Stale flags', uniqueStaleNames.toString()],
172
+ ['Scan mode', scanMode],
173
+ ['Scan time', `${scanDuration}ms`],
174
+ ])
175
+
176
+ if (providers.length > 0) {
177
+ core.summary.addRaw(`\n**Detected providers:** ${providers.join(', ')}\n`)
178
+ }
179
+
180
+ if (uniqueStaleNames > 0) {
181
+ core.summary.addRaw('\n### Top stale flags\n\n')
182
+ core.summary.addTable([
183
+ [{ data: 'Flag', header: true }, { data: 'File', header: true }, { data: 'Age', header: true }, { data: 'Signal', header: true }],
184
+ ...staleFlags.slice(0, 15).map(f => [
185
+ `\`${f.name}\``,
186
+ `${f.filePath}:${f.lineNumber}`,
187
+ f.age || 'unknown',
188
+ f.signals.map(s => s.description).join(', '),
189
+ ]),
190
+ ])
191
+ if (staleFlags.length > 15) {
192
+ core.summary.addRaw(`\n*... and ${staleFlags.length - 15} more stale flags*\n`)
193
+ }
194
+ }
195
+
196
+ core.summary.addRaw('\n---\n')
197
+ core.summary.addRaw('*Powered by [FlagShark](https://github.com/FlagShark/flagshark) โ€” find stale feature flags before they cause incidents*\n')
198
+ core.summary.addRaw('\n[Automate flag cleanup](https://flagshark.com) ยท [Open source CLI](https://github.com/FlagShark/flagshark) ยท [Report an issue](https://github.com/FlagShark/flagshark/issues)\n')
199
+
145
200
  await core.summary.write()
201
+
146
202
  } catch (error) {
147
203
  if (error instanceof Error) {
148
204
  core.setFailed(error.message)
@@ -157,86 +213,103 @@ async function postComment(
157
213
  staleFlags: StaleFlag[],
158
214
  totalFlags: number,
159
215
  healthScore: number,
216
+ scanMode: string,
217
+ langStats: Record<string, number>,
218
+ providers: string[],
219
+ scanDuration: number,
160
220
  ): Promise<void> {
161
221
  const octokit = github.getOctokit(token)
162
222
  const { owner, repo } = github.context.repo
163
223
  const prNumber = github.context.payload.pull_request!.number
164
224
 
165
- const displayFlags = staleFlags.slice(0, 10)
166
- const remaining = staleFlags.length - displayFlags.length
225
+ const uniqueStaleCount = new Set(staleFlags.map((f) => f.name)).size
226
+ const modeLabel = scanMode === 'full' ? 'Full repo scan' : 'Changed files only'
227
+ const healthEmoji = healthScore >= 90 ? '๐ŸŸข' : healthScore >= 70 ? '๐ŸŸก' : healthScore >= 40 ? '๐ŸŸ ' : '๐Ÿ”ด'
228
+ const langList = Object.entries(langStats).map(([l, c]) => `${l} (${c})`).join(', ')
229
+ const providerList = providers.length > 0
230
+ ? providers.slice(0, 5).join(', ') + (providers.length > 5 ? ` +${providers.length - 5} more` : '')
231
+ : 'none detected'
167
232
 
168
233
  let body = `${COMMENT_MARKER}\n`
169
- body += `### ๐Ÿฆˆ FlagShark found ${staleFlags.length} stale flag${staleFlags.length !== 1 ? 's' : ''}\n\n`
170
- body += '| Flag | File | Added | Signal |\n'
171
- body += '|------|------|-------|--------|\n'
172
234
 
173
- for (const flag of displayFlags) {
174
- const signals = flag.signals.map((s) => s.description).join(', ')
175
- body += `| \`${flag.name}\` | ${flag.filePath}:${flag.lineNumber} | ${flag.age || 'unknown'} | ${signals} |\n`
235
+ // Header
236
+ if (uniqueStaleCount === 0) {
237
+ body += `## ๐Ÿฆˆ FlagShark โ€” All flags healthy\n\n`
238
+ } else {
239
+ body += `## ๐Ÿฆˆ FlagShark โ€” ${uniqueStaleCount} stale flag${uniqueStaleCount !== 1 ? 's' : ''} found\n\n`
176
240
  }
177
241
 
178
- if (remaining > 0) {
179
- body += `\n... and ${remaining} more stale flags.\n`
242
+ // Health score badge
243
+ body += `${healthEmoji} **Health Score: ${healthScore}/100**\n\n`
244
+
245
+ // Stats row
246
+ body += `| Metric | Value |\n`
247
+ body += `|--------|-------|\n`
248
+ body += `| Flags detected | ${totalFlags} |\n`
249
+ body += `| Stale flags | ${uniqueStaleCount} |\n`
250
+ body += `| Languages | ${langList} |\n`
251
+ body += `| Providers | ${providerList} |\n`
252
+ body += `| Scan mode | ${modeLabel} |\n`
253
+ body += `| Scan time | ${scanDuration}ms |\n\n`
254
+
255
+ // Stale flags table
256
+ if (uniqueStaleCount > 0) {
257
+ body += `<details${uniqueStaleCount <= 5 ? ' open' : ''}>\n`
258
+ body += `<summary><strong>Stale flags (${uniqueStaleCount})</strong></summary>\n\n`
259
+ body += '| Flag | File | Age | Why it looks stale |\n'
260
+ body += '|------|------|-----|--------------------|\n'
261
+
262
+ const displayFlags = staleFlags.slice(0, 20)
263
+ for (const flag of displayFlags) {
264
+ const signals = flag.signals.map(s => s.description).join(', ')
265
+ const shortPath = flag.filePath.replace(/^\.\//, '')
266
+ body += `| \`${flag.name}\` | \`${shortPath}:${flag.lineNumber}\` | ${flag.age || 'unknown'} | ${signals} |\n`
267
+ }
268
+
269
+ if (staleFlags.length > 20) {
270
+ body += `\n*... and ${staleFlags.length - 20} more. Run \`npx flagshark scan --verbose\` locally for the full list.*\n`
271
+ }
272
+
273
+ body += '\n</details>\n\n'
180
274
  }
181
275
 
182
- body += `\n**Flag Health:** ${healthScore}/100 (${totalFlags} total, ${staleFlags.length} stale)\n`
183
- body +=
184
- '\nFull analysis โ†’ [FlagShark](https://github.com/FlagShark/flagshark)\n'
276
+ // Footer with links
277
+ body += '---\n'
278
+ body += `*[FlagShark](https://github.com/FlagShark/flagshark) finds stale feature flags before they cause incidents*\n\n`
279
+ body += `[Automate flag cleanup](https://flagshark.com) ยท `
280
+ body += `[Install CLI](https://www.npmjs.com/package/flagshark) ยท `
281
+ body += `[Open source](https://github.com/FlagShark/flagshark)\n`
185
282
 
186
283
  // Find existing comment to update
187
284
  const { data: comments } = await octokit.rest.issues.listComments({
188
- owner,
189
- repo,
190
- issue_number: prNumber,
191
- per_page: 100,
285
+ owner, repo, issue_number: prNumber, per_page: 100,
192
286
  })
193
287
 
194
288
  const existing = comments.find((c) => c.body?.includes(COMMENT_MARKER))
195
289
 
196
290
  if (existing) {
197
- await octokit.rest.issues.updateComment({
198
- owner,
199
- repo,
200
- comment_id: existing.id,
201
- body,
202
- })
291
+ await octokit.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body })
203
292
  core.info('Updated existing FlagShark comment')
204
293
  } else {
205
- await octokit.rest.issues.createComment({
206
- owner,
207
- repo,
208
- issue_number: prNumber,
209
- body,
210
- })
294
+ await octokit.rest.issues.createComment({ owner, repo, issue_number: prNumber, body })
211
295
  core.info('Posted new FlagShark comment')
212
296
  }
213
297
  }
214
298
 
215
299
  function walkDir(dir: string, supportedExts: Set<string>): string[] {
216
300
  const results: string[] = []
217
-
218
301
  try {
219
302
  const entries = readdirSync(dir, { withFileTypes: true })
220
303
  for (const entry of entries) {
221
- if (SKIP_DIRS.has(entry.name)) {
222
- continue
223
- }
224
- if (entry.name.startsWith('.')) {
225
- continue
226
- }
227
-
304
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue
228
305
  const fullPath = join(dir, entry.name)
229
-
230
306
  if (entry.isDirectory()) {
231
307
  results.push(...walkDir(fullPath, supportedExts))
232
308
  } else if (entry.isFile() && supportedExts.has(extname(entry.name))) {
233
309
  results.push(fullPath)
234
310
  }
235
311
  }
236
- } catch {
237
- // Skip unreadable directories
238
- }
239
-
312
+ } catch { /* skip unreadable dirs */ }
240
313
  return results
241
314
  }
242
315