flaglint 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +100 -12
- package/dist/bin/flaglint.js +177 -47
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-05-23
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **SARIF output**: `flaglint scan --format sarif --output flaglint.sarif` now emits SARIF 2.1.0 for GitHub Code Scanning / PR annotations.
|
|
13
|
+
- **Persistent scan metadata**: `ScanResult` now includes `scannedAt` and `scanRoot`, giving JSON/SARIF/HTML reports a stable scan timestamp and source-root context.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Config mutation**: `--exclude-tests` no longer mutates the loaded config object in both `scan` and `migrate` commands — uses spread instead of `push()` so the original config is never modified.
|
|
18
|
+
- **Typed scan warnings**: `ScanResult.warnings` is now a typed `ScanWarning` union (`read-failure` | `parse-failure`) instead of opaque strings, preserving structured data at the domain boundary.
|
|
19
|
+
- **StalenessEvaluator wired**: The `StalenessEvaluator` interface now has a call site in `scan()` — pass an `evaluator` to inject API-based staleness signals without touching core scanner logic.
|
|
20
|
+
- **ScanConfig boundary**: `scan()` now accepts `ScanConfig` (scan-relevant fields only) rather than the full `FlagLintConfig`, decoupling the scanner from CLI output concerns (`reportTitle`, `outputDir`).
|
|
21
|
+
- **Report count consistency**: Markdown and HTML stale candidate counts now exclude wildcard (`*`) usages, matching the CLI summary.
|
|
22
|
+
|
|
8
23
|
## [0.2.1] - 2026-05-23
|
|
9
24
|
|
|
10
25
|
### Fixed
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<strong>
|
|
6
|
+
<strong>Your LaunchDarkly codebase has flag debt. FlagLint tells you exactly what and where.</strong>
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
@@ -24,11 +24,8 @@
|
|
|
24
24
|
|
|
25
25
|
# FlagLint
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
[](https://github.com/flaglint/flaglint/actions/workflows/ci.yml)
|
|
30
|
-
[](https://www.npmjs.com/package/flaglint)
|
|
31
|
-
[](https://opensource.org/licenses/MIT)
|
|
27
|
+
Find zombie flags. Eliminate flag debt. Generate your OpenFeature
|
|
28
|
+
migration plan.
|
|
32
29
|
|
|
33
30
|
---
|
|
34
31
|
|
|
@@ -36,6 +33,8 @@
|
|
|
36
33
|
|
|
37
34
|
LaunchDarkly flags accumulate. Teams add them, forget to clean them up, and gradually build flag debt — dead code paths controlled by flags nobody manages. When you finally want to migrate to OpenFeature, you don't even know what you have.
|
|
38
35
|
|
|
36
|
+
Like Uber's Piranha — for any JS/TS codebase.
|
|
37
|
+
|
|
39
38
|
**FlagLint fixes this.** It scans your codebase, maps every flag usage, identifies stale candidates, and generates a step-by-step OpenFeature migration plan.
|
|
40
39
|
|
|
41
40
|
---
|
|
@@ -46,6 +45,49 @@ LaunchDarkly flags accumulate. Teams add them, forget to clean them up, and grad
|
|
|
46
45
|
npx flaglint scan
|
|
47
46
|
```
|
|
48
47
|
|
|
48
|
+
Example output:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
✓ 15 flag usages found across 6 unique flags (48ms)
|
|
52
|
+
⚠ 5 potentially stale flag(s) — review recommended
|
|
53
|
+
ℹ 1 dynamic flag key(s) require manual review
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Markdown report excerpt:
|
|
57
|
+
|
|
58
|
+
```markdown
|
|
59
|
+
## Flag Inventory
|
|
60
|
+
| Flag Key | Usages | Files | Call Types | Status |
|
|
61
|
+
|----------|--------|-------|------------|--------|
|
|
62
|
+
| show-banner | 1 | 1 | variation | ✓ Active |
|
|
63
|
+
| old-checkout | 1 | 1 | variation | ⚠ Stale |
|
|
64
|
+
| temp-debug-mode | 1 | 1 | variation | ⚠ Stale |
|
|
65
|
+
|
|
66
|
+
## ⚠ Stale Flag Candidates
|
|
67
|
+
| Flag Key | Reason | Location |
|
|
68
|
+
|----------|--------|----------|
|
|
69
|
+
| old-checkout | Contains "old" in key | ld-stale.ts:1 |
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### JSON output (`--format json`)
|
|
73
|
+
|
|
74
|
+
Pipe-friendly. Every usage includes file, line, call type,
|
|
75
|
+
and structured staleness signals:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"flagKey": "old-checkout",
|
|
80
|
+
"isDynamic": false,
|
|
81
|
+
"file": "src/components/Checkout.tsx",
|
|
82
|
+
"line": 14,
|
|
83
|
+
"callType": "variation",
|
|
84
|
+
"stalenessSignals": [
|
|
85
|
+
{ "source": "keyword", "keyword": "old" },
|
|
86
|
+
{ "source": "minFileCount", "fileCount": 1, "threshold": 1 }
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
49
91
|
---
|
|
50
92
|
|
|
51
93
|
## Installation
|
|
@@ -68,13 +110,15 @@ Scans a directory for LaunchDarkly SDK usage.
|
|
|
68
110
|
flaglint scan ./src
|
|
69
111
|
flaglint scan --format json --output report.json
|
|
70
112
|
flaglint scan --format html --output report.html
|
|
113
|
+
flaglint scan --format sarif --output flaglint.sarif
|
|
71
114
|
```
|
|
72
115
|
|
|
73
116
|
| Option | Default | Description |
|
|
74
117
|
|--------|---------|-------------|
|
|
75
|
-
| `--format` | `markdown` | Output format: `json`, `markdown`, `html` |
|
|
118
|
+
| `--format` | `markdown` | Output format: `json`, `markdown`, `html`, `sarif` |
|
|
76
119
|
| `--output` | stdout | Write report to file |
|
|
77
|
-
| `--config` | auto-detect | Path to
|
|
120
|
+
| `--config` | auto-detect | Path to a config file |
|
|
121
|
+
| `--exclude-tests` | — | Exclude test files from scan results |
|
|
78
122
|
|
|
79
123
|
Exit code `0` when no stale flags found, `1` when stale flags exist — enabling CI blocking.
|
|
80
124
|
|
|
@@ -94,13 +138,13 @@ flaglint migrate --output MIGRATION.md
|
|
|
94
138
|
|--------|---------|-------------|
|
|
95
139
|
| `--output` | `MIGRATION.md` | Write migration plan to file |
|
|
96
140
|
| `--dry-run` | — | Print plan to stdout, do not write file |
|
|
97
|
-
| `--config` | auto-detect | Path to
|
|
141
|
+
| `--config` | auto-detect | Path to a config file |
|
|
98
142
|
|
|
99
143
|
---
|
|
100
144
|
|
|
101
145
|
## Configuration
|
|
102
146
|
|
|
103
|
-
Create `.flaglintrc` in your project root:
|
|
147
|
+
Create `.flaglintrc`, `.flaglintrc.json`, or `flaglint.config.json` in your project root:
|
|
104
148
|
|
|
105
149
|
```json
|
|
106
150
|
{
|
|
@@ -122,18 +166,53 @@ Create `.flaglintrc` in your project root:
|
|
|
122
166
|
| `reportTitle` | `string` | — | Custom title for generated reports |
|
|
123
167
|
| `outputDir` | `string` | `"."` | Default output directory |
|
|
124
168
|
|
|
125
|
-
FlagLint searches for config in this order: `--config`
|
|
169
|
+
FlagLint searches for config in this order: `--config` path → `.flaglintrc` → `.flaglintrc.json` → `flaglint.config.json`.
|
|
126
170
|
|
|
127
171
|
---
|
|
128
172
|
|
|
129
173
|
## CI Integration
|
|
130
174
|
|
|
175
|
+
### Basic — block PRs on stale flags
|
|
176
|
+
|
|
131
177
|
```yaml
|
|
132
178
|
- name: Check for stale flags
|
|
133
179
|
run: npx flaglint scan --format json --output flaglint-report.json
|
|
134
180
|
# exits 1 if stale flags found, blocking the PR
|
|
135
181
|
```
|
|
136
182
|
|
|
183
|
+
### GitHub PR annotations via SARIF
|
|
184
|
+
|
|
185
|
+
Stale flags appear as warnings directly in the PR diff —
|
|
186
|
+
no dashboard, no separate tool.
|
|
187
|
+
|
|
188
|
+
```yaml
|
|
189
|
+
name: FlagLint
|
|
190
|
+
on: [pull_request]
|
|
191
|
+
|
|
192
|
+
jobs:
|
|
193
|
+
flaglint:
|
|
194
|
+
runs-on: ubuntu-latest
|
|
195
|
+
permissions:
|
|
196
|
+
security-events: write
|
|
197
|
+
contents: read
|
|
198
|
+
steps:
|
|
199
|
+
- uses: actions/checkout@v4
|
|
200
|
+
- uses: actions/setup-node@v4
|
|
201
|
+
with:
|
|
202
|
+
node-version: 20
|
|
203
|
+
- name: Scan for flag debt
|
|
204
|
+
run: npx flaglint scan --format sarif --output flaglint.sarif
|
|
205
|
+
continue-on-error: true
|
|
206
|
+
- name: Upload to GitHub Code Scanning
|
|
207
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
208
|
+
with:
|
|
209
|
+
sarif_file: flaglint.sarif
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Stale flags show up as Code Scanning alerts on the exact file
|
|
213
|
+
and line where the flag is used — reviewers see them in the PR
|
|
214
|
+
without running anything locally.
|
|
215
|
+
|
|
137
216
|
---
|
|
138
217
|
|
|
139
218
|
## What FlagLint detects
|
|
@@ -142,9 +221,10 @@ FlagLint searches for config in this order: `--config` flag → `.flaglintrc`
|
|
|
142
221
|
- `ldClient.allFlags()`
|
|
143
222
|
- `useFlags()`, `useLDClient()` React hooks
|
|
144
223
|
- `<LDProvider>` and `withLDConsumer()` patterns
|
|
224
|
+
- Custom wrapper calls such as `flagPredicate("my-flag", false)` when configured with `wrappers`
|
|
145
225
|
- Dynamic flag keys (runtime-determined, flagged for manual review)
|
|
146
226
|
|
|
147
|
-
All detections include the **file path**, **line number**, **call type**, and
|
|
227
|
+
All detections include the **file path**, **line number**, **call type**, and staleness signals based on key names, file locations, and low file counts.
|
|
148
228
|
|
|
149
229
|
---
|
|
150
230
|
|
|
@@ -169,6 +249,14 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
|
|
169
249
|
|
|
170
250
|
---
|
|
171
251
|
|
|
252
|
+
## Free flag debt audit
|
|
253
|
+
|
|
254
|
+
Running this on a real codebase?
|
|
255
|
+
[Book a free 30-minute audit →](https://flaglint.dev#waitlist)
|
|
256
|
+
I'll run FlagLint on your repo and walk you through the results.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
172
260
|
## License
|
|
173
261
|
|
|
174
262
|
MIT — see [LICENSE](./LICENSE).
|
package/dist/bin/flaglint.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Command } from "commander";
|
|
|
6
6
|
// src/commands/scan.ts
|
|
7
7
|
import { writeFile } from "fs/promises";
|
|
8
8
|
import { stat } from "fs/promises";
|
|
9
|
-
import { resolve as
|
|
9
|
+
import { resolve as resolve4 } from "path";
|
|
10
10
|
import chalk from "chalk";
|
|
11
11
|
import ora from "ora";
|
|
12
12
|
|
|
@@ -188,8 +188,9 @@ function detectUsages(ast, filePath, wrappers) {
|
|
|
188
188
|
});
|
|
189
189
|
return usages;
|
|
190
190
|
}
|
|
191
|
-
async function scan(source, config, onProgress) {
|
|
191
|
+
async function scan(source, config, onProgress, evaluator) {
|
|
192
192
|
const start = Date.now();
|
|
193
|
+
const scannedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
193
194
|
for (const pattern of config.include) {
|
|
194
195
|
if (pattern.startsWith("/") || pattern.startsWith("..")) {
|
|
195
196
|
throw new Error(
|
|
@@ -207,7 +208,7 @@ async function scan(source, config, onProgress) {
|
|
|
207
208
|
code = await source.readFile(file);
|
|
208
209
|
} catch (err) {
|
|
209
210
|
const fsCode = err.code ?? "UNKNOWN";
|
|
210
|
-
return { usages: [], warning:
|
|
211
|
+
return { usages: [], warning: { kind: "read-failure", file, fsCode } };
|
|
211
212
|
}
|
|
212
213
|
let ast;
|
|
213
214
|
try {
|
|
@@ -220,7 +221,7 @@ async function scan(source, config, onProgress) {
|
|
|
220
221
|
filePath: file
|
|
221
222
|
});
|
|
222
223
|
} catch {
|
|
223
|
-
return { usages: [], warning:
|
|
224
|
+
return { usages: [], warning: { kind: "parse-failure", file } };
|
|
224
225
|
}
|
|
225
226
|
return { usages: detectUsages(ast, file, config.wrappers), warning: null };
|
|
226
227
|
}
|
|
@@ -261,12 +262,17 @@ async function scan(source, config, onProgress) {
|
|
|
261
262
|
}
|
|
262
263
|
}
|
|
263
264
|
}
|
|
265
|
+
if (evaluator) {
|
|
266
|
+
await evaluator.evaluate(allUsages, config);
|
|
267
|
+
}
|
|
264
268
|
const uniqueFlags = [
|
|
265
269
|
...new Set(
|
|
266
270
|
allUsages.filter((u) => !u.isDynamic && u.flagKey !== "*").map((u) => u.flagKey)
|
|
267
271
|
)
|
|
268
272
|
];
|
|
269
273
|
return {
|
|
274
|
+
scannedAt,
|
|
275
|
+
scanRoot: source.root ?? ".",
|
|
270
276
|
scannedFiles,
|
|
271
277
|
totalUsages: allUsages.length,
|
|
272
278
|
uniqueFlags,
|
|
@@ -279,12 +285,14 @@ async function scan(source, config, onProgress) {
|
|
|
279
285
|
// src/scanner/local-source.ts
|
|
280
286
|
import fg from "fast-glob";
|
|
281
287
|
import { readFile } from "fs/promises";
|
|
282
|
-
import { join, relative } from "path";
|
|
288
|
+
import { join, relative, resolve } from "path";
|
|
283
289
|
var LocalFileSource = class {
|
|
284
290
|
constructor(dir) {
|
|
285
291
|
this.dir = dir;
|
|
292
|
+
this.root = resolve(dir);
|
|
286
293
|
}
|
|
287
294
|
dir;
|
|
295
|
+
root;
|
|
288
296
|
async listFiles(include, exclude) {
|
|
289
297
|
const files = await fg(include, {
|
|
290
298
|
cwd: this.dir,
|
|
@@ -299,6 +307,10 @@ var LocalFileSource = class {
|
|
|
299
307
|
}
|
|
300
308
|
};
|
|
301
309
|
|
|
310
|
+
// src/reporter/index.ts
|
|
311
|
+
import { resolve as resolve2 } from "path";
|
|
312
|
+
import { pathToFileURL } from "url";
|
|
313
|
+
|
|
302
314
|
// src/types.ts
|
|
303
315
|
var isStale = (u) => u.stalenessSignals.length > 0;
|
|
304
316
|
|
|
@@ -328,11 +340,11 @@ function sortedFlagEntries(map) {
|
|
|
328
340
|
}
|
|
329
341
|
function formatMarkdown(result, options) {
|
|
330
342
|
const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
|
|
331
|
-
const staleUsages = usages.filter(isStale);
|
|
343
|
+
const staleUsages = usages.filter((u) => isStale(u) && !u.isDynamic && u.flagKey !== "*");
|
|
332
344
|
const dynamicUsages = usages.filter((u) => u.isDynamic);
|
|
333
345
|
const flagMap = buildFlagMap(usages);
|
|
334
346
|
const sorted = sortedFlagEntries(flagMap);
|
|
335
|
-
const staleFlags = sorted.filter(([, d]) => d.isStale);
|
|
347
|
+
const staleFlags = sorted.filter(([key, d]) => key !== "*" && d.isStale);
|
|
336
348
|
const lines = [];
|
|
337
349
|
lines.push("# FlagLint Scan Report");
|
|
338
350
|
if (options.title) lines.push("", options.title);
|
|
@@ -388,13 +400,128 @@ function formatMarkdown(result, options) {
|
|
|
388
400
|
return lines.join("\n");
|
|
389
401
|
}
|
|
390
402
|
function formatJSON(result) {
|
|
391
|
-
return JSON.stringify({ generatedAt:
|
|
403
|
+
return JSON.stringify({ generatedAt: result.scannedAt, ...result }, null, 2);
|
|
404
|
+
}
|
|
405
|
+
function signalRuleId(usage) {
|
|
406
|
+
const signal = usage.stalenessSignals[0];
|
|
407
|
+
if (!signal) return "flaglint.stale";
|
|
408
|
+
return `flaglint.${signal.source}`;
|
|
409
|
+
}
|
|
410
|
+
function signalMessage(usage) {
|
|
411
|
+
const reasons = usage.stalenessSignals.map((signal) => {
|
|
412
|
+
switch (signal.source) {
|
|
413
|
+
case "keyword":
|
|
414
|
+
return `keyword "${signal.keyword}"`;
|
|
415
|
+
case "path":
|
|
416
|
+
return `path pattern "${signal.pattern}"`;
|
|
417
|
+
case "minFileCount":
|
|
418
|
+
return `file count ${signal.fileCount} <= ${signal.threshold}`;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
return reasons.length > 0 ? reasons.join(", ") : "staleness signal";
|
|
422
|
+
}
|
|
423
|
+
function sarifUri(file) {
|
|
424
|
+
return file.split(/[\\/]/).join("/");
|
|
425
|
+
}
|
|
426
|
+
function sarifRootUri(scanRoot) {
|
|
427
|
+
const uri = pathToFileURL(resolve2(scanRoot)).href;
|
|
428
|
+
return uri.endsWith("/") ? uri : `${uri}/`;
|
|
429
|
+
}
|
|
430
|
+
function formatSARIF(result) {
|
|
431
|
+
const staleUsages = result.usages.filter((u) => isStale(u) && !u.isDynamic && u.flagKey !== "*");
|
|
432
|
+
const rules = [
|
|
433
|
+
{
|
|
434
|
+
id: "flaglint.keyword",
|
|
435
|
+
name: "Stale flag keyword",
|
|
436
|
+
shortDescription: { text: "Flag key contains a stale keyword" },
|
|
437
|
+
helpUri: "https://github.com/flaglint/flaglint#what-flaglint-detects"
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
id: "flaglint.path",
|
|
441
|
+
name: "Stale flag path",
|
|
442
|
+
shortDescription: { text: "Flag usage appears in a stale path" },
|
|
443
|
+
helpUri: "https://github.com/flaglint/flaglint#what-flaglint-detects"
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
id: "flaglint.minFileCount",
|
|
447
|
+
name: "Low file count",
|
|
448
|
+
shortDescription: { text: "Flag appears in too few files" },
|
|
449
|
+
helpUri: "https://github.com/flaglint/flaglint#configuration"
|
|
450
|
+
}
|
|
451
|
+
];
|
|
452
|
+
return JSON.stringify(
|
|
453
|
+
{
|
|
454
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
455
|
+
version: "2.1.0",
|
|
456
|
+
runs: [
|
|
457
|
+
{
|
|
458
|
+
tool: {
|
|
459
|
+
driver: {
|
|
460
|
+
name: "FlagLint",
|
|
461
|
+
informationUri: "https://github.com/flaglint/flaglint",
|
|
462
|
+
rules
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
invocations: [
|
|
466
|
+
{
|
|
467
|
+
executionSuccessful: true,
|
|
468
|
+
startTimeUtc: result.scannedAt,
|
|
469
|
+
properties: {
|
|
470
|
+
scannedFiles: result.scannedFiles,
|
|
471
|
+
totalUsages: result.totalUsages,
|
|
472
|
+
uniqueFlags: result.uniqueFlags.length,
|
|
473
|
+
scanDurationMs: result.scanDurationMs
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
],
|
|
477
|
+
originalUriBaseIds: {
|
|
478
|
+
"%SRCROOT%": {
|
|
479
|
+
uri: sarifRootUri(result.scanRoot)
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
results: staleUsages.map((usage) => ({
|
|
483
|
+
ruleId: signalRuleId(usage),
|
|
484
|
+
level: "warning",
|
|
485
|
+
message: {
|
|
486
|
+
text: `Potentially stale feature flag "${usage.flagKey}" detected: ${signalMessage(usage)}.`
|
|
487
|
+
},
|
|
488
|
+
locations: [
|
|
489
|
+
{
|
|
490
|
+
physicalLocation: {
|
|
491
|
+
artifactLocation: {
|
|
492
|
+
uri: sarifUri(usage.file),
|
|
493
|
+
uriBaseId: "%SRCROOT%"
|
|
494
|
+
},
|
|
495
|
+
region: {
|
|
496
|
+
startLine: Math.max(usage.line, 1),
|
|
497
|
+
startColumn: Math.max(usage.column + 1, 1)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
],
|
|
502
|
+
partialFingerprints: {
|
|
503
|
+
"flagKey/v1": usage.flagKey
|
|
504
|
+
},
|
|
505
|
+
properties: {
|
|
506
|
+
flagKey: usage.flagKey,
|
|
507
|
+
callType: usage.callType,
|
|
508
|
+
stalenessSignals: usage.stalenessSignals
|
|
509
|
+
}
|
|
510
|
+
}))
|
|
511
|
+
}
|
|
512
|
+
]
|
|
513
|
+
},
|
|
514
|
+
null,
|
|
515
|
+
2
|
|
516
|
+
);
|
|
392
517
|
}
|
|
393
518
|
function formatHTML(result, options) {
|
|
394
519
|
const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
|
|
395
|
-
const staleCount = new Set(
|
|
520
|
+
const staleCount = new Set(
|
|
521
|
+
usages.filter((u) => isStale(u) && !u.isDynamic && u.flagKey !== "*").map((u) => u.flagKey)
|
|
522
|
+
).size;
|
|
396
523
|
const dynamicCount = usages.filter((u) => u.isDynamic).length;
|
|
397
|
-
const date =
|
|
524
|
+
const date = new Date(result.scannedAt).toLocaleString();
|
|
398
525
|
const flagMap = buildFlagMap(usages);
|
|
399
526
|
const sorted = sortedFlagEntries(flagMap);
|
|
400
527
|
const rows = sorted.map(([key, data]) => {
|
|
@@ -404,7 +531,7 @@ function formatHTML(result, options) {
|
|
|
404
531
|
return `<tr class="${cls}"><td><code>${esc(key)}</code></td><td>${data.usages.length}</td><td>${fileList}</td><td>${[...data.callTypes].map(esc).join(", ")}</td><td>${status}</td></tr>`;
|
|
405
532
|
}).join("\n ");
|
|
406
533
|
const title = options.title ? esc(options.title) : "FlagLint Scan Report";
|
|
407
|
-
const version = true ? "0.
|
|
534
|
+
const version = true ? "0.3.0" : "0.1.0";
|
|
408
535
|
return `<!DOCTYPE html>
|
|
409
536
|
<html lang="en">
|
|
410
537
|
<head>
|
|
@@ -479,14 +606,16 @@ function formatReport(result, options) {
|
|
|
479
606
|
return formatJSON(result);
|
|
480
607
|
case "html":
|
|
481
608
|
return formatHTML(result, options);
|
|
482
|
-
|
|
609
|
+
case "markdown":
|
|
483
610
|
return formatMarkdown(result, options);
|
|
611
|
+
case "sarif":
|
|
612
|
+
return formatSARIF(result);
|
|
484
613
|
}
|
|
485
614
|
}
|
|
486
615
|
|
|
487
616
|
// src/config.ts
|
|
488
617
|
import { readFile as readFile2, access } from "fs/promises";
|
|
489
|
-
import { resolve } from "path";
|
|
618
|
+
import { resolve as resolve3 } from "path";
|
|
490
619
|
import { z, ZodError } from "zod";
|
|
491
620
|
var FlagLintConfigSchema = z.object({
|
|
492
621
|
include: z.array(z.string()).default(["**/*.{ts,tsx,js,jsx}"]),
|
|
@@ -509,7 +638,7 @@ var SEARCH_PATHS = [".flaglintrc", ".flaglintrc.json", "flaglint.config.json"];
|
|
|
509
638
|
async function loadConfig(configPath) {
|
|
510
639
|
const candidates = configPath ? [configPath] : SEARCH_PATHS;
|
|
511
640
|
for (const candidate of candidates) {
|
|
512
|
-
const full =
|
|
641
|
+
const full = resolve3(candidate);
|
|
513
642
|
try {
|
|
514
643
|
await access(full);
|
|
515
644
|
} catch {
|
|
@@ -535,15 +664,16 @@ async function loadConfig(configPath) {
|
|
|
535
664
|
}
|
|
536
665
|
|
|
537
666
|
// src/commands/scan.ts
|
|
538
|
-
var VALID_FORMATS = ["json", "markdown", "html"];
|
|
667
|
+
var VALID_FORMATS = ["json", "markdown", "html", "sarif"];
|
|
539
668
|
function registerScanCommand(program2) {
|
|
540
|
-
program2.command("scan").description("Scan a directory for feature flag usages and detect stale flags").argument("[dir]", "directory to scan", process.cwd()).option("-f, --format <format>", "output format: json | markdown | html", "markdown").option("-o, --output <file>", "write report to file").option("-c, --config <path>", "path to .flaglintrc config file").option("--exclude-tests", "exclude test files (*.test.*, *.spec.*, __tests__/, tests/)").addHelpText(
|
|
669
|
+
program2.command("scan").description("Scan a directory for feature flag usages and detect stale flags").argument("[dir]", "directory to scan", process.cwd()).option("-f, --format <format>", "output format: json | markdown | html | sarif", "markdown").option("-o, --output <file>", "write report to file").option("-c, --config <path>", "path to .flaglintrc config file").option("--exclude-tests", "exclude test files (*.test.*, *.spec.*, __tests__/, tests/)").addHelpText(
|
|
541
670
|
"after",
|
|
542
671
|
`
|
|
543
672
|
Examples:
|
|
544
673
|
$ flaglint scan scan current directory
|
|
545
674
|
$ flaglint scan ./src scan specific directory
|
|
546
675
|
$ flaglint scan --format json output as JSON
|
|
676
|
+
$ flaglint scan --format sarif output as SARIF for GitHub Code Scanning
|
|
547
677
|
$ flaglint scan --output report.md save to file
|
|
548
678
|
$ flaglint scan --exclude-tests skip test and spec files`
|
|
549
679
|
).action(
|
|
@@ -558,7 +688,7 @@ Examples:
|
|
|
558
688
|
process.exit(2);
|
|
559
689
|
}
|
|
560
690
|
try {
|
|
561
|
-
const s = await stat(
|
|
691
|
+
const s = await stat(resolve4(dir));
|
|
562
692
|
if (!s.isDirectory()) {
|
|
563
693
|
process.stderr.write(chalk.red(`Error: Not a directory: ${dir}
|
|
564
694
|
`));
|
|
@@ -585,16 +715,15 @@ Examples:
|
|
|
585
715
|
process.stderr.write(chalk.red(String(err instanceof Error ? err.message : err)) + "\n");
|
|
586
716
|
process.exit(1);
|
|
587
717
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
}
|
|
718
|
+
const TEST_PATTERNS = [
|
|
719
|
+
"**/*.test.ts",
|
|
720
|
+
"**/*.test.tsx",
|
|
721
|
+
"**/*.spec.ts",
|
|
722
|
+
"**/*.spec.tsx",
|
|
723
|
+
"**/__tests__/**",
|
|
724
|
+
"**/tests/**"
|
|
725
|
+
];
|
|
726
|
+
const scanConfig = options.excludeTests ? { ...config, exclude: [...config.exclude, ...TEST_PATTERNS] } : config;
|
|
598
727
|
const format = options.format;
|
|
599
728
|
const spinner = ora(`Scanning ${dir}...`).start();
|
|
600
729
|
process.once("SIGINT", () => {
|
|
@@ -604,7 +733,7 @@ Examples:
|
|
|
604
733
|
let lastSpinnerUpdate = 0;
|
|
605
734
|
let result;
|
|
606
735
|
try {
|
|
607
|
-
result = await scan(new LocalFileSource(dir),
|
|
736
|
+
result = await scan(new LocalFileSource(dir), scanConfig, (filesScanned) => {
|
|
608
737
|
if (filesScanned - lastSpinnerUpdate >= 50) {
|
|
609
738
|
spinner.text = `Scanning... (${filesScanned} files)`;
|
|
610
739
|
lastSpinnerUpdate = filesScanned;
|
|
@@ -617,7 +746,8 @@ Examples:
|
|
|
617
746
|
process.exit(1);
|
|
618
747
|
}
|
|
619
748
|
for (const w of result.warnings) {
|
|
620
|
-
|
|
749
|
+
const msg = w.kind === "read-failure" ? `warn: could not read ${w.file} (${w.fsCode})` : `warn: failed to parse ${w.file}`;
|
|
750
|
+
process.stderr.write(chalk.yellow(msg + "\n"));
|
|
621
751
|
}
|
|
622
752
|
if (result.scannedFiles === 0) {
|
|
623
753
|
process.stderr.write(
|
|
@@ -658,7 +788,7 @@ Examples:
|
|
|
658
788
|
}
|
|
659
789
|
const report = formatReport(result, { format, title: config.reportTitle });
|
|
660
790
|
if (options.output) {
|
|
661
|
-
const outPath =
|
|
791
|
+
const outPath = resolve4(options.output);
|
|
662
792
|
try {
|
|
663
793
|
await writeFile(outPath, report, "utf8");
|
|
664
794
|
process.stderr.write(chalk.dim(` Report written to ${options.output}
|
|
@@ -683,7 +813,7 @@ Examples:
|
|
|
683
813
|
// src/commands/migrate.ts
|
|
684
814
|
import { writeFile as writeFile2 } from "fs/promises";
|
|
685
815
|
import { stat as stat2 } from "fs/promises";
|
|
686
|
-
import { resolve as
|
|
816
|
+
import { resolve as resolve5 } from "path";
|
|
687
817
|
import chalk2 from "chalk";
|
|
688
818
|
import ora2 from "ora";
|
|
689
819
|
|
|
@@ -824,7 +954,7 @@ function analyze(result) {
|
|
|
824
954
|
function formatMigrationReport(analysis) {
|
|
825
955
|
const { readinessScore, requiredPackages, items, manualReviewCount, autoMigrateCount } = analysis;
|
|
826
956
|
const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
|
|
827
|
-
const version = true ? "0.
|
|
957
|
+
const version = true ? "0.3.0" : "0.1.0";
|
|
828
958
|
let scoreLabel;
|
|
829
959
|
if (readinessScore >= 80) scoreLabel = "\u2713 Your codebase is ready for migration";
|
|
830
960
|
else if (readinessScore >= 50) scoreLabel = "\u26A0 Some manual work required before migration";
|
|
@@ -910,7 +1040,7 @@ Examples:
|
|
|
910
1040
|
).action(
|
|
911
1041
|
async (dir, options) => {
|
|
912
1042
|
try {
|
|
913
|
-
const s = await stat2(
|
|
1043
|
+
const s = await stat2(resolve5(dir));
|
|
914
1044
|
if (!s.isDirectory()) {
|
|
915
1045
|
process.stderr.write(chalk2.red(`Error: Not a directory: ${dir}
|
|
916
1046
|
`));
|
|
@@ -937,16 +1067,15 @@ Examples:
|
|
|
937
1067
|
process.stderr.write(chalk2.red(String(err instanceof Error ? err.message : err)) + "\n");
|
|
938
1068
|
process.exit(1);
|
|
939
1069
|
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
}
|
|
1070
|
+
const TEST_PATTERNS = [
|
|
1071
|
+
"**/*.test.ts",
|
|
1072
|
+
"**/*.test.tsx",
|
|
1073
|
+
"**/*.spec.ts",
|
|
1074
|
+
"**/*.spec.tsx",
|
|
1075
|
+
"**/__tests__/**",
|
|
1076
|
+
"**/tests/**"
|
|
1077
|
+
];
|
|
1078
|
+
const scanConfig = options.excludeTests ? { ...config, exclude: [...config.exclude, ...TEST_PATTERNS] } : config;
|
|
950
1079
|
const spinner = ora2(`Scanning ${dir}...`).start();
|
|
951
1080
|
process.once("SIGINT", () => {
|
|
952
1081
|
spinner.stop();
|
|
@@ -954,7 +1083,7 @@ Examples:
|
|
|
954
1083
|
});
|
|
955
1084
|
let scanResult;
|
|
956
1085
|
try {
|
|
957
|
-
scanResult = await scan(new LocalFileSource(dir),
|
|
1086
|
+
scanResult = await scan(new LocalFileSource(dir), scanConfig, (filesScanned) => {
|
|
958
1087
|
spinner.text = `Scanning files... ${filesScanned}`;
|
|
959
1088
|
});
|
|
960
1089
|
spinner.text = "Analyzing migration readiness...";
|
|
@@ -983,7 +1112,8 @@ Examples:
|
|
|
983
1112
|
const analysis = analyze(scanResult);
|
|
984
1113
|
spinner.stop();
|
|
985
1114
|
for (const w of scanResult.warnings) {
|
|
986
|
-
|
|
1115
|
+
const msg = w.kind === "read-failure" ? `warn: could not read ${w.file} (${w.fsCode})` : `warn: failed to parse ${w.file}`;
|
|
1116
|
+
process.stderr.write(chalk2.yellow(msg + "\n"));
|
|
987
1117
|
}
|
|
988
1118
|
const { readinessScore } = analysis;
|
|
989
1119
|
const scoreColor = readinessScore >= 80 ? chalk2.green : readinessScore >= 50 ? chalk2.yellow : chalk2.red;
|
|
@@ -1000,7 +1130,7 @@ Examples:
|
|
|
1000
1130
|
process.stdout.write(report + "\n");
|
|
1001
1131
|
process.exit(0);
|
|
1002
1132
|
}
|
|
1003
|
-
const outPath =
|
|
1133
|
+
const outPath = resolve5(options.output);
|
|
1004
1134
|
try {
|
|
1005
1135
|
await writeFile2(outPath, report, "utf8");
|
|
1006
1136
|
process.stderr.write(chalk2.green(`Migration plan written to ${options.output}
|
|
@@ -1022,7 +1152,7 @@ Examples:
|
|
|
1022
1152
|
// src/cli.ts
|
|
1023
1153
|
function createCLI() {
|
|
1024
1154
|
const program2 = new Command();
|
|
1025
|
-
program2.name("flaglint").description("Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.").version("0.
|
|
1155
|
+
program2.name("flaglint").description("Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.").version("0.3.0", "-v, --version", "output the current version").addHelpText(
|
|
1026
1156
|
"after",
|
|
1027
1157
|
`
|
|
1028
1158
|
Examples:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flaglint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,13 +34,15 @@
|
|
|
34
34
|
"url": "https://github.com/flaglint/flaglint/issues"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
-
"
|
|
37
|
+
"sync:www": "tsx scripts/sync-www.ts",
|
|
38
|
+
"build": "npm run sync:www && tsup",
|
|
38
39
|
"dev": "tsup --watch",
|
|
39
40
|
"typecheck": "tsc --noEmit",
|
|
40
41
|
"typecheck:agent": "tsc --project tsconfig.agent.json",
|
|
41
42
|
"test": "vitest",
|
|
42
43
|
"test:run": "vitest run",
|
|
43
44
|
"test:coverage": "vitest run --coverage",
|
|
45
|
+
"new-branch": "tsx scripts/new-branch.ts",
|
|
44
46
|
"release:patch": "tsx scripts/release.ts patch",
|
|
45
47
|
"release:minor": "tsx scripts/release.ts minor",
|
|
46
48
|
"release:major": "tsx scripts/release.ts major"
|