flagshark 1.0.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/LICENSE +21 -0
- package/README.md +175 -0
- package/action/index.ts +243 -0
- package/bin/flagshark.mjs +2 -0
- package/dist/action.js +3811 -0
- package/dist/cli.js +4003 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FlagShark
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# FlagShark
|
|
2
|
+
|
|
3
|
+
Find stale feature flags in your codebase. CLI tool + GitHub Action.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx flagshark scan
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
🦈 FlagShark v1.0.0
|
|
11
|
+
|
|
12
|
+
Scanned 156 files across 4 languages
|
|
13
|
+
Detected providers: LaunchDarkly (JS SDK), Unleash (Go SDK)
|
|
14
|
+
Found 23 feature flags, 7 stale
|
|
15
|
+
|
|
16
|
+
Stale flags:
|
|
17
|
+
┌──────────────────┬────────────────────────┬───────────────┬──────────────────────────────┐
|
|
18
|
+
│ Flag │ File │ Added │ Signal │
|
|
19
|
+
├──────────────────┼────────────────────────┼───────────────┼──────────────────────────────┤
|
|
20
|
+
│ CHECKOUT_V2 │ src/checkout.ts:47 │ 14 months ago │ Age > 6 months │
|
|
21
|
+
│ NEW_NAV │ src/layout.tsx:12 │ 8 months ago │ Age > 6 months, Single file │
|
|
22
|
+
│ BETA_SEARCH │ src/search.ts:91 │ 11 months ago │ Single file reference │
|
|
23
|
+
└──────────────────┴────────────────────────┴───────────────┴──────────────────────────────┘
|
|
24
|
+
|
|
25
|
+
Flag Health Score: 70/100 (7/23 flags are stale)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Run without installing
|
|
32
|
+
npx flagshark scan
|
|
33
|
+
|
|
34
|
+
# Or install globally
|
|
35
|
+
npm install -g flagshark
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## CLI Usage
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Scan current directory
|
|
42
|
+
flagshark scan
|
|
43
|
+
|
|
44
|
+
# JSON output (for piping to other tools)
|
|
45
|
+
flagshark scan --json
|
|
46
|
+
|
|
47
|
+
# Only scan files changed since a git ref
|
|
48
|
+
flagshark scan --diff HEAD~1
|
|
49
|
+
flagshark scan --diff main
|
|
50
|
+
|
|
51
|
+
# Custom staleness threshold (default: 6 months)
|
|
52
|
+
flagshark scan --threshold 3
|
|
53
|
+
|
|
54
|
+
# Show all stale flags (default shows top 10)
|
|
55
|
+
flagshark scan --verbose
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Exit codes
|
|
59
|
+
|
|
60
|
+
| Code | Meaning |
|
|
61
|
+
|------|---------|
|
|
62
|
+
| 0 | No stale flags found |
|
|
63
|
+
| 1 | Stale flags detected |
|
|
64
|
+
| 2 | Runtime error |
|
|
65
|
+
|
|
66
|
+
## GitHub Action
|
|
67
|
+
|
|
68
|
+
Add to your workflow:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
name: FlagShark
|
|
72
|
+
on: [pull_request]
|
|
73
|
+
|
|
74
|
+
permissions:
|
|
75
|
+
contents: read
|
|
76
|
+
pull-requests: write
|
|
77
|
+
|
|
78
|
+
jobs:
|
|
79
|
+
flagshark:
|
|
80
|
+
runs-on: ubuntu-latest
|
|
81
|
+
steps:
|
|
82
|
+
- uses: actions/checkout@v4
|
|
83
|
+
with:
|
|
84
|
+
fetch-depth: 0 # Required for git blame age detection
|
|
85
|
+
- uses: FlagShark/flagshark@v1
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Action Inputs
|
|
89
|
+
|
|
90
|
+
| Input | Default | Description |
|
|
91
|
+
|-------|---------|-------------|
|
|
92
|
+
| `scan` | `changed` | `changed` (PR files only) or `full` (entire repo) |
|
|
93
|
+
| `threshold` | `6` | Staleness threshold in months |
|
|
94
|
+
| `fail-threshold` | `0` | Health score below which the check fails (0 = never fail) |
|
|
95
|
+
|
|
96
|
+
### What the Action does
|
|
97
|
+
|
|
98
|
+
On every PR, FlagShark comments with a table of stale flags found in the changed files:
|
|
99
|
+
|
|
100
|
+
> ### 🦈 FlagShark found 3 stale flags
|
|
101
|
+
>
|
|
102
|
+
> | Flag | File | Added | Signal |
|
|
103
|
+
> |------|------|-------|--------|
|
|
104
|
+
> | `CHECKOUT_V2` | src/checkout.ts:47 | 14 months ago | Age > 6 months |
|
|
105
|
+
> | `NEW_NAV` | src/layout.tsx:12 | 8 months ago | Single file |
|
|
106
|
+
>
|
|
107
|
+
> **Flag Health:** 70/100
|
|
108
|
+
|
|
109
|
+
It also sets a GitHub status check that can optionally block merge if health drops below a threshold.
|
|
110
|
+
|
|
111
|
+
## Supported Languages
|
|
112
|
+
|
|
113
|
+
FlagShark detects feature flags across 13 languages:
|
|
114
|
+
|
|
115
|
+
| Language | Extensions |
|
|
116
|
+
|----------|-----------|
|
|
117
|
+
| TypeScript/JavaScript | .ts, .tsx, .js, .jsx, .mjs, .cjs |
|
|
118
|
+
| Go | .go |
|
|
119
|
+
| Python | .py |
|
|
120
|
+
| Java | .java |
|
|
121
|
+
| Kotlin | .kt |
|
|
122
|
+
| Swift | .swift |
|
|
123
|
+
| Ruby | .rb |
|
|
124
|
+
| C# | .cs |
|
|
125
|
+
| PHP | .php |
|
|
126
|
+
| Rust | .rs |
|
|
127
|
+
| C/C++ | .c, .cpp, .h, .hpp |
|
|
128
|
+
| Objective-C | .m |
|
|
129
|
+
|
|
130
|
+
## Supported Providers
|
|
131
|
+
|
|
132
|
+
Auto-detected from imports (no configuration needed):
|
|
133
|
+
|
|
134
|
+
- LaunchDarkly
|
|
135
|
+
- Unleash
|
|
136
|
+
- Flipt
|
|
137
|
+
- Split.io
|
|
138
|
+
- PostHog
|
|
139
|
+
- Flagsmith
|
|
140
|
+
- ConfigCat
|
|
141
|
+
- Statsig
|
|
142
|
+
- GrowthBook
|
|
143
|
+
- DevCycle
|
|
144
|
+
- Eppo
|
|
145
|
+
- Optimizely
|
|
146
|
+
- Custom flag implementations
|
|
147
|
+
|
|
148
|
+
## How Staleness Works
|
|
149
|
+
|
|
150
|
+
A flag is marked stale if **any** of these signals fires:
|
|
151
|
+
|
|
152
|
+
1. **Age:** `git blame` shows the flag reference was added more than 6 months ago (configurable with `--threshold`)
|
|
153
|
+
2. **Single file:** The flag name appears in only one file across the entire repo, suggesting a completed rollout
|
|
154
|
+
|
|
155
|
+
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
|
+
|
|
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
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT
|
package/action/index.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Action entry point for FlagShark.
|
|
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)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
16
|
+
import { join, extname } from 'node:path'
|
|
17
|
+
|
|
18
|
+
import * as core from '@actions/core'
|
|
19
|
+
import * as github from '@actions/github'
|
|
20
|
+
|
|
21
|
+
import { createDefaultRegistry } from '../src/detection/index.js'
|
|
22
|
+
import { PolyglotAnalyzer } from '../src/detection/polyglot-analyzer.js'
|
|
23
|
+
import { analyzeStaleness } from '../src/staleness.js'
|
|
24
|
+
|
|
25
|
+
import type { FeatureFlag } from '../src/detection/feature-flag.js'
|
|
26
|
+
import type { StaleFlag } from '../src/staleness.js'
|
|
27
|
+
|
|
28
|
+
const COMMENT_MARKER = '<!-- flagshark-action -->'
|
|
29
|
+
const SKIP_DIRS = new Set([
|
|
30
|
+
'node_modules',
|
|
31
|
+
'vendor',
|
|
32
|
+
'.git',
|
|
33
|
+
'dist',
|
|
34
|
+
'build',
|
|
35
|
+
'coverage',
|
|
36
|
+
'__pycache__',
|
|
37
|
+
'.next',
|
|
38
|
+
'.turbo',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
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(' ')),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function run(): Promise<void> {
|
|
49
|
+
const startTime = Date.now()
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const scanMode = core.getInput('scan') || 'changed'
|
|
53
|
+
const threshold = parseInt(core.getInput('threshold') || '6', 10)
|
|
54
|
+
const failThreshold = parseInt(core.getInput('fail-threshold') || '0', 10)
|
|
55
|
+
|
|
56
|
+
const registry = createDefaultRegistry()
|
|
57
|
+
const supportedExts = new Set(registry.getSupportedExtensions())
|
|
58
|
+
const analyzer = new PolyglotAnalyzer(registry, logger)
|
|
59
|
+
|
|
60
|
+
// Determine files to scan
|
|
61
|
+
let filePaths: string[]
|
|
62
|
+
|
|
63
|
+
if (scanMode === 'changed' && github.context.payload.pull_request) {
|
|
64
|
+
const token = process.env.GITHUB_TOKEN || core.getInput('token')
|
|
65
|
+
if (!token) {
|
|
66
|
+
core.setFailed('GITHUB_TOKEN is required for changed-file scanning')
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
const octokit = github.getOctokit(token)
|
|
70
|
+
const { data: prFiles } = await octokit.rest.pulls.listFiles({
|
|
71
|
+
...github.context.repo,
|
|
72
|
+
pull_number: github.context.payload.pull_request.number,
|
|
73
|
+
per_page: 100,
|
|
74
|
+
})
|
|
75
|
+
filePaths = prFiles
|
|
76
|
+
.filter((f) => f.status !== 'removed')
|
|
77
|
+
.map((f) => f.filename)
|
|
78
|
+
.filter((f) => supportedExts.has(extname(f)))
|
|
79
|
+
} else {
|
|
80
|
+
filePaths = walkDir('.', supportedExts)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Read file contents
|
|
84
|
+
const files = new Map<string, string>()
|
|
85
|
+
for (const fp of filePaths) {
|
|
86
|
+
try {
|
|
87
|
+
const stat = statSync(fp)
|
|
88
|
+
if (stat.size > 5 * 1024 * 1024) {
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
files.set(fp, readFileSync(fp, 'utf-8'))
|
|
92
|
+
} catch {
|
|
93
|
+
// Skip unreadable files
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
core.info(`Scanning ${files.size} files...`)
|
|
98
|
+
|
|
99
|
+
// Run detection
|
|
100
|
+
const result = await analyzer.analyzeFiles(files)
|
|
101
|
+
const totalFlags = result.totalFlags.size
|
|
102
|
+
|
|
103
|
+
// Run staleness analysis
|
|
104
|
+
const staleFlags = await analyzeStaleness(result.totalFlags as Map<string, FeatureFlag[]>, {
|
|
105
|
+
thresholdMonths: threshold,
|
|
106
|
+
repoRoot: process.cwd(),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Use unique stale flag names (not occurrences) for health score — matches CLI formula
|
|
110
|
+
const uniqueStaleNames = new Set(staleFlags.map((f) => f.name)).size
|
|
111
|
+
const healthScore =
|
|
112
|
+
totalFlags > 0 ? Math.round(((totalFlags - uniqueStaleNames) / totalFlags) * 100) : 100
|
|
113
|
+
|
|
114
|
+
const scanDuration = Date.now() - startTime
|
|
115
|
+
|
|
116
|
+
// Set outputs
|
|
117
|
+
core.setOutput('health-score', healthScore.toString())
|
|
118
|
+
core.setOutput('stale-count', staleFlags.length.toString())
|
|
119
|
+
core.setOutput('total-count', totalFlags.toString())
|
|
120
|
+
|
|
121
|
+
// Post PR comment (only on PRs)
|
|
122
|
+
if (github.context.payload.pull_request && staleFlags.length > 0) {
|
|
123
|
+
const token = process.env.GITHUB_TOKEN || core.getInput('token')
|
|
124
|
+
if (token) {
|
|
125
|
+
await postComment(token, staleFlags, totalFlags, healthScore)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Set status check
|
|
130
|
+
if (failThreshold > 0 && healthScore < failThreshold) {
|
|
131
|
+
core.setFailed(
|
|
132
|
+
`Flag health score ${healthScore}/100 is below threshold ${failThreshold}/100. ` +
|
|
133
|
+
`${staleFlags.length} stale flags found.`,
|
|
134
|
+
)
|
|
135
|
+
} else {
|
|
136
|
+
core.info(`Flag Health Score: ${healthScore}/100 (${staleFlags.length}/${totalFlags} stale)`)
|
|
137
|
+
}
|
|
138
|
+
|
|
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`)
|
|
145
|
+
await core.summary.write()
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error instanceof Error) {
|
|
148
|
+
core.setFailed(error.message)
|
|
149
|
+
} else {
|
|
150
|
+
core.setFailed('An unexpected error occurred')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function postComment(
|
|
156
|
+
token: string,
|
|
157
|
+
staleFlags: StaleFlag[],
|
|
158
|
+
totalFlags: number,
|
|
159
|
+
healthScore: number,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
const octokit = github.getOctokit(token)
|
|
162
|
+
const { owner, repo } = github.context.repo
|
|
163
|
+
const prNumber = github.context.payload.pull_request!.number
|
|
164
|
+
|
|
165
|
+
const displayFlags = staleFlags.slice(0, 10)
|
|
166
|
+
const remaining = staleFlags.length - displayFlags.length
|
|
167
|
+
|
|
168
|
+
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
|
+
|
|
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`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (remaining > 0) {
|
|
179
|
+
body += `\n... and ${remaining} more stale flags.\n`
|
|
180
|
+
}
|
|
181
|
+
|
|
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'
|
|
185
|
+
|
|
186
|
+
// Find existing comment to update
|
|
187
|
+
const { data: comments } = await octokit.rest.issues.listComments({
|
|
188
|
+
owner,
|
|
189
|
+
repo,
|
|
190
|
+
issue_number: prNumber,
|
|
191
|
+
per_page: 100,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const existing = comments.find((c) => c.body?.includes(COMMENT_MARKER))
|
|
195
|
+
|
|
196
|
+
if (existing) {
|
|
197
|
+
await octokit.rest.issues.updateComment({
|
|
198
|
+
owner,
|
|
199
|
+
repo,
|
|
200
|
+
comment_id: existing.id,
|
|
201
|
+
body,
|
|
202
|
+
})
|
|
203
|
+
core.info('Updated existing FlagShark comment')
|
|
204
|
+
} else {
|
|
205
|
+
await octokit.rest.issues.createComment({
|
|
206
|
+
owner,
|
|
207
|
+
repo,
|
|
208
|
+
issue_number: prNumber,
|
|
209
|
+
body,
|
|
210
|
+
})
|
|
211
|
+
core.info('Posted new FlagShark comment')
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function walkDir(dir: string, supportedExts: Set<string>): string[] {
|
|
216
|
+
const results: string[] = []
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
220
|
+
for (const entry of entries) {
|
|
221
|
+
if (SKIP_DIRS.has(entry.name)) {
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
if (entry.name.startsWith('.')) {
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const fullPath = join(dir, entry.name)
|
|
229
|
+
|
|
230
|
+
if (entry.isDirectory()) {
|
|
231
|
+
results.push(...walkDir(fullPath, supportedExts))
|
|
232
|
+
} else if (entry.isFile() && supportedExts.has(extname(entry.name))) {
|
|
233
|
+
results.push(fullPath)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
// Skip unreadable directories
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return results
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
run()
|