flaglint 0.1.2 → 0.2.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 +39 -0
- package/README.md +27 -3
- package/dist/bin/flaglint.js +142 -78
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,45 @@ 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.2.0] - 2026-05-22
|
|
9
|
+
|
|
10
|
+
### Breaking Changes
|
|
11
|
+
|
|
12
|
+
- **`FlagUsage.isStale: boolean` replaced with `stalenessSignals: StalenessSignal[]`**
|
|
13
|
+
The boolean had no provenance — you could not tell which signal (keyword, path, file-count, future LD API age) caused a flag to be marked stale. Replaced with a typed union array that records every signal that fired and why.
|
|
14
|
+
|
|
15
|
+
**Migration:** Replace `usage.isStale` checks with the exported helper:
|
|
16
|
+
```typescript
|
|
17
|
+
import { isStale } from "flaglint";
|
|
18
|
+
if (isStale(usage)) { ... }
|
|
19
|
+
```
|
|
20
|
+
JSON report consumers: the `usages[].isStale` field is gone. Use `usages[].stalenessSignals.length > 0` or the `isStale()` helper. Reports now include staleness provenance (source + keyword/pattern/count).
|
|
21
|
+
|
|
22
|
+
- **Renamed config field: `staleThreshold` → `minFileCount`**
|
|
23
|
+
The field was previously documented as "days before a flag is considered stale" but was actually implemented as a file-count threshold (a flag is stale if it appears in ≤ N files). The rename makes the actual behavior honest.
|
|
24
|
+
|
|
25
|
+
**Migration:** In your `flaglint.config.json` or `.flaglintrc`, rename the field:
|
|
26
|
+
```json
|
|
27
|
+
// Before
|
|
28
|
+
{ "staleThreshold": 5 }
|
|
29
|
+
// After
|
|
30
|
+
{ "minFileCount": 5 }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Extracted shared stale detection logic (`STALE_KEYWORDS`, `checkStale`, `staleReason`) into `src/stale.ts` — single source of truth, eliminates duplicate keyword lists between scanner and reporter
|
|
36
|
+
|
|
37
|
+
### Roadmap
|
|
38
|
+
|
|
39
|
+
- `v0.3`: Replace `minFileCount` with real date-based staleness detection via `git log` integration
|
|
40
|
+
|
|
41
|
+
## [0.1.5] - 2026-05-21
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- Corrected the README CI badge URL.
|
|
46
|
+
|
|
8
47
|
## [0.1.0] - 2026-05-18
|
|
9
48
|
|
|
10
49
|
### Added
|
package/README.md
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/assets/logo.png" alt="FlagLint" width="400" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<strong>Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.</strong>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://github.com/flaglint/flaglint/actions/workflows/ci.yml">
|
|
11
|
+
<img src="https://github.com/flaglint/flaglint/actions/workflows/ci.yml/badge.svg" alt="CI" />
|
|
12
|
+
</a>
|
|
13
|
+
<a href="https://www.npmjs.com/package/flaglint">
|
|
14
|
+
<img src="https://img.shields.io/npm/v/flaglint.svg" alt="npm version" />
|
|
15
|
+
</a>
|
|
16
|
+
<a href="https://www.npmjs.com/package/flaglint">
|
|
17
|
+
<img src="https://img.shields.io/npm/dm/flaglint.svg" alt="downloads" />
|
|
18
|
+
</a>
|
|
19
|
+
<a href="https://opensource.org/licenses/MIT">
|
|
20
|
+
<img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="MIT License" />
|
|
21
|
+
</a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
|
|
1
25
|
# FlagLint
|
|
2
26
|
|
|
3
27
|
**Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.**
|
|
4
28
|
|
|
5
|
-
[](https://github.com/flaglint/flaglint/actions/workflows/ci.yml)
|
|
6
30
|
[](https://www.npmjs.com/package/flaglint)
|
|
7
31
|
[](https://opensource.org/licenses/MIT)
|
|
8
32
|
|
|
@@ -83,7 +107,7 @@ Create `.flaglintrc` in your project root:
|
|
|
83
107
|
"include": ["**/*.{ts,tsx,js,jsx}"],
|
|
84
108
|
"exclude": ["**/node_modules/**", "**/dist/**"],
|
|
85
109
|
"provider": "launchdarkly",
|
|
86
|
-
"
|
|
110
|
+
"minFileCount": 1,
|
|
87
111
|
"reportTitle": "My Project Flag Report"
|
|
88
112
|
}
|
|
89
113
|
```
|
|
@@ -93,7 +117,7 @@ Create `.flaglintrc` in your project root:
|
|
|
93
117
|
| `include` | `string[]` | `["**/*.{ts,tsx,js,jsx}"]` | Glob patterns to scan |
|
|
94
118
|
| `exclude` | `string[]` | `["**/node_modules/**", ...]` | Glob patterns to ignore |
|
|
95
119
|
| `provider` | `string` | `"launchdarkly"` | Feature flag provider |
|
|
96
|
-
| `
|
|
120
|
+
| `minFileCount` | `number` | `1` | A flag is stale if it appears in ≤ N files (default: 1) |
|
|
97
121
|
| `reportTitle` | `string` | — | Custom title for generated reports |
|
|
98
122
|
| `outputDir` | `string` | `"."` | Default output directory |
|
|
99
123
|
|
package/dist/bin/flaglint.js
CHANGED
|
@@ -11,16 +11,34 @@ import chalk from "chalk";
|
|
|
11
11
|
import ora from "ora";
|
|
12
12
|
|
|
13
13
|
// src/scanner/index.ts
|
|
14
|
-
import
|
|
15
|
-
import { relative } from "path";
|
|
16
|
-
import fg from "fast-glob";
|
|
14
|
+
import pLimit from "p-limit";
|
|
17
15
|
import { parse } from "@typescript-eslint/typescript-estree";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
var
|
|
21
|
-
var STALE_KEY_WORDS = ["old", "deprecated", "legacy", "temp", "tmp", "test", "demo"];
|
|
16
|
+
|
|
17
|
+
// src/stale.ts
|
|
18
|
+
var STALE_KEYWORDS = ["old", "deprecated", "legacy", "temp", "tmp", "test", "demo"];
|
|
22
19
|
var STALE_FILE_RE = /\.(test|spec|mock)\.[jt]sx?$/;
|
|
23
20
|
var STALE_PATH_RE = /\/deprecated\/|\/old\/|\/legacy\//;
|
|
21
|
+
function checkStale(flagKey, filePath) {
|
|
22
|
+
if (STALE_FILE_RE.test(filePath)) return { source: "path", pattern: "test/spec/mock file" };
|
|
23
|
+
if (STALE_PATH_RE.test(filePath)) return { source: "path", pattern: "deprecated/old/legacy path" };
|
|
24
|
+
const lk = flagKey.toLowerCase();
|
|
25
|
+
const kw = STALE_KEYWORDS.find((k) => new RegExp(`(?:^|[-_])${k}(?:[-_]|$)`).test(lk));
|
|
26
|
+
if (kw) return { source: "keyword", keyword: kw };
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function staleReason(u) {
|
|
30
|
+
for (const s of u.stalenessSignals) {
|
|
31
|
+
if (s.source === "keyword") return `Contains "${s.keyword}" in key`;
|
|
32
|
+
if (s.source === "path") return s.pattern === "test/spec/mock file" ? "Located in test file" : "Located in deprecated path";
|
|
33
|
+
if (s.source === "minFileCount") return `Appears in only ${s.fileCount} file(s) (threshold: ${s.threshold})`;
|
|
34
|
+
}
|
|
35
|
+
return "Flagged as stale";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/scanner/index.ts
|
|
39
|
+
var LD_MEMBER_METHODS = /* @__PURE__ */ new Set(["variation", "variationDetail", "allFlags"]);
|
|
40
|
+
var LD_CLIENT_PATTERN = /^ld|client/i;
|
|
41
|
+
var LD_HOOKS = /* @__PURE__ */ new Set(["useFlags", "useLDClient"]);
|
|
24
42
|
var DEFAULT_EXCLUDE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.next/**"];
|
|
25
43
|
function extractFlagKey(arg) {
|
|
26
44
|
if (!arg) return { flagKey: "dynamic", isDynamic: true };
|
|
@@ -33,26 +51,28 @@ function extractFlagKey(arg) {
|
|
|
33
51
|
}
|
|
34
52
|
return { flagKey: "dynamic", isDynamic: true };
|
|
35
53
|
}
|
|
36
|
-
function
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
walk(item, visit);
|
|
54
|
+
function walk(root, visit) {
|
|
55
|
+
if (!root || typeof root !== "object") return;
|
|
56
|
+
const stack = [root];
|
|
57
|
+
while (stack.length > 0) {
|
|
58
|
+
const node = stack.pop();
|
|
59
|
+
visit(node);
|
|
60
|
+
const children = [];
|
|
61
|
+
for (const key of Object.keys(node)) {
|
|
62
|
+
if (key === "parent") continue;
|
|
63
|
+
const val = node[key];
|
|
64
|
+
if (Array.isArray(val)) {
|
|
65
|
+
for (const item of val) {
|
|
66
|
+
if (item && typeof item === "object" && "type" in item) {
|
|
67
|
+
children.push(item);
|
|
68
|
+
}
|
|
52
69
|
}
|
|
70
|
+
} else if (val && typeof val === "object" && "type" in val) {
|
|
71
|
+
children.push(val);
|
|
53
72
|
}
|
|
54
|
-
}
|
|
55
|
-
|
|
73
|
+
}
|
|
74
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
75
|
+
stack.push(children[i]);
|
|
56
76
|
}
|
|
57
77
|
}
|
|
58
78
|
}
|
|
@@ -66,6 +86,7 @@ function detectUsages(ast, filePath) {
|
|
|
66
86
|
if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.property.type === "Identifier" && LD_CLIENT_PATTERN.test(callee.object.name) && LD_MEMBER_METHODS.has(callee.property.name)) {
|
|
67
87
|
const method = callee.property.name;
|
|
68
88
|
if (method === "allFlags") {
|
|
89
|
+
const sig = checkStale("*", filePath);
|
|
69
90
|
usages.push({
|
|
70
91
|
flagKey: "*",
|
|
71
92
|
isDynamic: false,
|
|
@@ -73,10 +94,11 @@ function detectUsages(ast, filePath) {
|
|
|
73
94
|
line: loc.line,
|
|
74
95
|
column: loc.column,
|
|
75
96
|
callType: "allFlags",
|
|
76
|
-
|
|
97
|
+
stalenessSignals: sig ? [sig] : []
|
|
77
98
|
});
|
|
78
99
|
} else {
|
|
79
100
|
const { flagKey, isDynamic } = extractFlagKey(call.arguments[0]);
|
|
101
|
+
const sig = checkStale(flagKey, filePath);
|
|
80
102
|
usages.push({
|
|
81
103
|
flagKey,
|
|
82
104
|
isDynamic,
|
|
@@ -84,7 +106,7 @@ function detectUsages(ast, filePath) {
|
|
|
84
106
|
line: loc.line,
|
|
85
107
|
column: loc.column,
|
|
86
108
|
callType: method,
|
|
87
|
-
|
|
109
|
+
stalenessSignals: sig ? [sig] : []
|
|
88
110
|
});
|
|
89
111
|
}
|
|
90
112
|
return;
|
|
@@ -93,6 +115,7 @@ function detectUsages(ast, filePath) {
|
|
|
93
115
|
const name = callee.name;
|
|
94
116
|
if (name === "isFeatureEnabled") {
|
|
95
117
|
const { flagKey, isDynamic } = extractFlagKey(call.arguments[0]);
|
|
118
|
+
const sig = checkStale(flagKey, filePath);
|
|
96
119
|
usages.push({
|
|
97
120
|
flagKey,
|
|
98
121
|
isDynamic,
|
|
@@ -100,11 +123,12 @@ function detectUsages(ast, filePath) {
|
|
|
100
123
|
line: loc.line,
|
|
101
124
|
column: loc.column,
|
|
102
125
|
callType: "isFeatureEnabled",
|
|
103
|
-
|
|
126
|
+
stalenessSignals: sig ? [sig] : []
|
|
104
127
|
});
|
|
105
128
|
return;
|
|
106
129
|
}
|
|
107
130
|
if (LD_HOOKS.has(name)) {
|
|
131
|
+
const sig = checkStale("*", filePath);
|
|
108
132
|
usages.push({
|
|
109
133
|
flagKey: "*",
|
|
110
134
|
isDynamic: false,
|
|
@@ -112,12 +136,13 @@ function detectUsages(ast, filePath) {
|
|
|
112
136
|
line: loc.line,
|
|
113
137
|
column: loc.column,
|
|
114
138
|
callType: name === "useFlags" ? "hook-useFlags" : "hook-useLDClient",
|
|
115
|
-
|
|
139
|
+
stalenessSignals: sig ? [sig] : []
|
|
116
140
|
});
|
|
117
141
|
return;
|
|
118
142
|
}
|
|
119
143
|
}
|
|
120
144
|
if (callee.type === "CallExpression" && callee.callee.type === "Identifier" && callee.callee.name === "withLDConsumer") {
|
|
145
|
+
const sig = checkStale("*", filePath);
|
|
121
146
|
usages.push({
|
|
122
147
|
flagKey: "*",
|
|
123
148
|
isDynamic: false,
|
|
@@ -125,7 +150,7 @@ function detectUsages(ast, filePath) {
|
|
|
125
150
|
line: loc.line,
|
|
126
151
|
column: loc.column,
|
|
127
152
|
callType: "hoc",
|
|
128
|
-
|
|
153
|
+
stalenessSignals: sig ? [sig] : []
|
|
129
154
|
});
|
|
130
155
|
return;
|
|
131
156
|
}
|
|
@@ -134,6 +159,7 @@ function detectUsages(ast, filePath) {
|
|
|
134
159
|
const jsx = node;
|
|
135
160
|
if (jsx.name.type === "JSXIdentifier" && jsx.name.name === "LDProvider") {
|
|
136
161
|
const loc = jsx.loc?.start ?? { line: 0, column: 0 };
|
|
162
|
+
const sigP = checkStale("*", filePath);
|
|
137
163
|
usages.push({
|
|
138
164
|
flagKey: "*",
|
|
139
165
|
isDynamic: false,
|
|
@@ -141,14 +167,14 @@ function detectUsages(ast, filePath) {
|
|
|
141
167
|
line: loc.line,
|
|
142
168
|
column: loc.column,
|
|
143
169
|
callType: "provider",
|
|
144
|
-
|
|
170
|
+
stalenessSignals: sigP ? [sigP] : []
|
|
145
171
|
});
|
|
146
172
|
}
|
|
147
173
|
}
|
|
148
174
|
});
|
|
149
175
|
return usages;
|
|
150
176
|
}
|
|
151
|
-
async function scan(
|
|
177
|
+
async function scan(source, config, onProgress) {
|
|
152
178
|
const start = Date.now();
|
|
153
179
|
for (const pattern of config.include) {
|
|
154
180
|
if (pattern.startsWith("/") || pattern.startsWith("..")) {
|
|
@@ -157,23 +183,17 @@ async function scan(dir, config, onProgress) {
|
|
|
157
183
|
);
|
|
158
184
|
}
|
|
159
185
|
}
|
|
160
|
-
const files = await
|
|
161
|
-
cwd: dir,
|
|
162
|
-
absolute: true,
|
|
163
|
-
ignore: [...DEFAULT_EXCLUDE, ...config.exclude],
|
|
164
|
-
onlyFiles: true
|
|
165
|
-
});
|
|
186
|
+
const files = await source.listFiles(config.include, config.exclude);
|
|
166
187
|
const allUsages = [];
|
|
167
188
|
const warnings = [];
|
|
168
189
|
let scannedFiles = 0;
|
|
169
|
-
|
|
170
|
-
scannedFiles++;
|
|
171
|
-
onProgress?.(scannedFiles);
|
|
190
|
+
async function processFile(file) {
|
|
172
191
|
let code;
|
|
173
192
|
try {
|
|
174
|
-
code = await readFile(file
|
|
175
|
-
} catch {
|
|
176
|
-
|
|
193
|
+
code = await source.readFile(file);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const fsCode = err.code ?? "UNKNOWN";
|
|
196
|
+
return { usages: [], warning: `warn: could not read ${file} (${fsCode})` };
|
|
177
197
|
}
|
|
178
198
|
let ast;
|
|
179
199
|
try {
|
|
@@ -185,12 +205,25 @@ async function scan(dir, config, onProgress) {
|
|
|
185
205
|
tokens: false
|
|
186
206
|
});
|
|
187
207
|
} catch {
|
|
188
|
-
|
|
189
|
-
continue;
|
|
208
|
+
return { usages: [], warning: `warn: failed to parse ${file}` };
|
|
190
209
|
}
|
|
191
|
-
|
|
210
|
+
return { usages: detectUsages(ast, file), warning: null };
|
|
211
|
+
}
|
|
212
|
+
const limit = pLimit(50);
|
|
213
|
+
const results = await Promise.all(
|
|
214
|
+
files.map(
|
|
215
|
+
(file) => limit(async () => {
|
|
216
|
+
scannedFiles++;
|
|
217
|
+
onProgress?.(scannedFiles);
|
|
218
|
+
return processFile(file);
|
|
219
|
+
})
|
|
220
|
+
)
|
|
221
|
+
);
|
|
222
|
+
for (const r of results) {
|
|
223
|
+
allUsages.push(...r.usages);
|
|
224
|
+
if (r.warning) warnings.push(r.warning);
|
|
192
225
|
}
|
|
193
|
-
if (config.
|
|
226
|
+
if (config.minFileCount > 0) {
|
|
194
227
|
const flagFileCount = /* @__PURE__ */ new Map();
|
|
195
228
|
for (const usage of allUsages) {
|
|
196
229
|
if (!usage.isDynamic && usage.flagKey !== "*") {
|
|
@@ -203,8 +236,12 @@ async function scan(dir, config, onProgress) {
|
|
|
203
236
|
for (const usage of allUsages) {
|
|
204
237
|
if (!usage.isDynamic && usage.flagKey !== "*") {
|
|
205
238
|
const fileCount = flagFileCount.get(usage.flagKey)?.size ?? 0;
|
|
206
|
-
if (fileCount <= config.
|
|
207
|
-
usage.
|
|
239
|
+
if (fileCount <= config.minFileCount) {
|
|
240
|
+
usage.stalenessSignals.push({
|
|
241
|
+
source: "minFileCount",
|
|
242
|
+
fileCount,
|
|
243
|
+
threshold: config.minFileCount
|
|
244
|
+
});
|
|
208
245
|
}
|
|
209
246
|
}
|
|
210
247
|
}
|
|
@@ -224,18 +261,36 @@ async function scan(dir, config, onProgress) {
|
|
|
224
261
|
};
|
|
225
262
|
}
|
|
226
263
|
|
|
264
|
+
// src/scanner/local-source.ts
|
|
265
|
+
import fg from "fast-glob";
|
|
266
|
+
import { readFile } from "fs/promises";
|
|
267
|
+
import { join, relative } from "path";
|
|
268
|
+
var LocalFileSource = class {
|
|
269
|
+
constructor(dir) {
|
|
270
|
+
this.dir = dir;
|
|
271
|
+
}
|
|
272
|
+
dir;
|
|
273
|
+
async listFiles(include, exclude) {
|
|
274
|
+
const files = await fg(include, {
|
|
275
|
+
cwd: this.dir,
|
|
276
|
+
absolute: true,
|
|
277
|
+
ignore: [...DEFAULT_EXCLUDE, ...exclude],
|
|
278
|
+
onlyFiles: true
|
|
279
|
+
});
|
|
280
|
+
return files.map((f) => relative(this.dir, f));
|
|
281
|
+
}
|
|
282
|
+
async readFile(path) {
|
|
283
|
+
return readFile(join(this.dir, path), "utf8");
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// src/types.ts
|
|
288
|
+
var isStale = (u) => u.stalenessSignals.length > 0;
|
|
289
|
+
|
|
227
290
|
// src/reporter/index.ts
|
|
228
291
|
function esc(s) {
|
|
229
292
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
230
293
|
}
|
|
231
|
-
function staleReason(u) {
|
|
232
|
-
if (/\.(test|spec|mock)\.[jt]sx?$/.test(u.file)) return "Located in test file";
|
|
233
|
-
if (/\/deprecated\/|\/old\/|\/legacy\//.test(u.file)) return "Located in deprecated path";
|
|
234
|
-
const kw = ["old", "deprecated", "legacy", "temp", "tmp", "test", "demo"].find(
|
|
235
|
-
(k) => u.flagKey.toLowerCase().includes(k)
|
|
236
|
-
);
|
|
237
|
-
return kw ? `Contains "${kw}" in key` : "Flagged as stale";
|
|
238
|
-
}
|
|
239
294
|
function buildFlagMap(usages) {
|
|
240
295
|
const map = /* @__PURE__ */ new Map();
|
|
241
296
|
for (const u of usages) {
|
|
@@ -246,7 +301,7 @@ function buildFlagMap(usages) {
|
|
|
246
301
|
entry.usages.push(u);
|
|
247
302
|
entry.files.add(u.file);
|
|
248
303
|
entry.callTypes.add(u.callType);
|
|
249
|
-
if (u
|
|
304
|
+
if (isStale(u)) entry.isStale = true;
|
|
250
305
|
}
|
|
251
306
|
return map;
|
|
252
307
|
}
|
|
@@ -258,7 +313,7 @@ function sortedFlagEntries(map) {
|
|
|
258
313
|
}
|
|
259
314
|
function formatMarkdown(result, options) {
|
|
260
315
|
const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
|
|
261
|
-
const staleUsages = usages.filter(
|
|
316
|
+
const staleUsages = usages.filter(isStale);
|
|
262
317
|
const dynamicUsages = usages.filter((u) => u.isDynamic);
|
|
263
318
|
const flagMap = buildFlagMap(usages);
|
|
264
319
|
const sorted = sortedFlagEntries(flagMap);
|
|
@@ -300,7 +355,7 @@ function formatMarkdown(result, options) {
|
|
|
300
355
|
lines.push("| Flag Key | Reason | Location |");
|
|
301
356
|
lines.push("|----------|--------|----------|");
|
|
302
357
|
for (const [key, data] of staleFlags) {
|
|
303
|
-
const first = data.usages.find(
|
|
358
|
+
const first = data.usages.find(isStale) ?? data.usages[0];
|
|
304
359
|
lines.push(`| ${key} | ${staleReason(first)} | ${first.file}:${first.line} |`);
|
|
305
360
|
}
|
|
306
361
|
lines.push("");
|
|
@@ -322,7 +377,7 @@ function formatJSON(result) {
|
|
|
322
377
|
}
|
|
323
378
|
function formatHTML(result, options) {
|
|
324
379
|
const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
|
|
325
|
-
const staleCount = usages.filter((u) => u.
|
|
380
|
+
const staleCount = new Set(usages.filter(isStale).map((u) => u.flagKey)).size;
|
|
326
381
|
const dynamicCount = usages.filter((u) => u.isDynamic).length;
|
|
327
382
|
const date = (/* @__PURE__ */ new Date()).toLocaleString();
|
|
328
383
|
const flagMap = buildFlagMap(usages);
|
|
@@ -334,7 +389,7 @@ function formatHTML(result, options) {
|
|
|
334
389
|
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>`;
|
|
335
390
|
}).join("\n ");
|
|
336
391
|
const title = options.title ? esc(options.title) : "FlagLint Scan Report";
|
|
337
|
-
const version = true ? "0.
|
|
392
|
+
const version = true ? "0.2.0" : "0.1.0";
|
|
338
393
|
return `<!DOCTYPE html>
|
|
339
394
|
<html lang="en">
|
|
340
395
|
<head>
|
|
@@ -415,7 +470,7 @@ function formatReport(result, options) {
|
|
|
415
470
|
}
|
|
416
471
|
|
|
417
472
|
// src/config.ts
|
|
418
|
-
import {
|
|
473
|
+
import { readFile as readFile2, access } from "fs/promises";
|
|
419
474
|
import { resolve } from "path";
|
|
420
475
|
import { z, ZodError } from "zod";
|
|
421
476
|
var FlagLintConfigSchema = z.object({
|
|
@@ -429,19 +484,24 @@ var FlagLintConfigSchema = z.object({
|
|
|
429
484
|
"**/*.d.ts"
|
|
430
485
|
]),
|
|
431
486
|
provider: z.enum(["launchdarkly", "unleash", "growthbook", "custom"]).default("launchdarkly"),
|
|
432
|
-
|
|
487
|
+
// TODO v0.3: replace minFileCount with real date-based staleness via git log
|
|
488
|
+
minFileCount: z.number().int().min(0).default(1),
|
|
433
489
|
reportTitle: z.string().optional(),
|
|
434
490
|
outputDir: z.string().default(".")
|
|
435
491
|
});
|
|
436
492
|
var SEARCH_PATHS = [".flaglintrc", ".flaglintrc.json", "flaglint.config.json"];
|
|
437
|
-
function loadConfig(configPath) {
|
|
493
|
+
async function loadConfig(configPath) {
|
|
438
494
|
const candidates = configPath ? [configPath] : SEARCH_PATHS;
|
|
439
495
|
for (const candidate of candidates) {
|
|
440
496
|
const full = resolve(candidate);
|
|
441
|
-
|
|
497
|
+
try {
|
|
498
|
+
await access(full);
|
|
499
|
+
} catch {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
442
502
|
let raw;
|
|
443
503
|
try {
|
|
444
|
-
raw = JSON.parse(
|
|
504
|
+
raw = JSON.parse(await readFile2(full, "utf8"));
|
|
445
505
|
} catch (err) {
|
|
446
506
|
throw new Error(`Error reading ${candidate}: ${String(err)}`);
|
|
447
507
|
}
|
|
@@ -504,7 +564,7 @@ Examples:
|
|
|
504
564
|
}
|
|
505
565
|
let config;
|
|
506
566
|
try {
|
|
507
|
-
config = loadConfig(options.config);
|
|
567
|
+
config = await loadConfig(options.config);
|
|
508
568
|
} catch (err) {
|
|
509
569
|
process.stderr.write(chalk.red(String(err instanceof Error ? err.message : err)) + "\n");
|
|
510
570
|
process.exit(1);
|
|
@@ -528,7 +588,7 @@ Examples:
|
|
|
528
588
|
let lastSpinnerUpdate = 0;
|
|
529
589
|
let result;
|
|
530
590
|
try {
|
|
531
|
-
result = await scan(dir, config, (filesScanned) => {
|
|
591
|
+
result = await scan(new LocalFileSource(dir), config, (filesScanned) => {
|
|
532
592
|
if (filesScanned - lastSpinnerUpdate >= 50) {
|
|
533
593
|
spinner.text = `Scanning... (${filesScanned} files)`;
|
|
534
594
|
lastSpinnerUpdate = filesScanned;
|
|
@@ -558,7 +618,9 @@ Examples:
|
|
|
558
618
|
);
|
|
559
619
|
process.exit(0);
|
|
560
620
|
}
|
|
561
|
-
const staleCount = new Set(
|
|
621
|
+
const staleCount = new Set(
|
|
622
|
+
result.usages.filter((u) => isStale(u) && !u.isDynamic && u.flagKey !== "*").map((u) => u.flagKey)
|
|
623
|
+
).size;
|
|
562
624
|
const dynamicCount = new Set(result.usages.filter((u) => u.isDynamic).map((u) => u.flagKey)).size;
|
|
563
625
|
process.stderr.write(
|
|
564
626
|
chalk.green(
|
|
@@ -616,11 +678,13 @@ function keyLiteral(usage) {
|
|
|
616
678
|
function buildItem(usage) {
|
|
617
679
|
const k = keyLiteral(usage);
|
|
618
680
|
if (usage.isDynamic) {
|
|
681
|
+
const isDetail = usage.callType === "variationDetail";
|
|
682
|
+
const methodName = isDetail ? "variationDetail" : "variation";
|
|
619
683
|
return {
|
|
620
684
|
usage,
|
|
621
|
-
openFeatureEquivalent: "client.getBooleanValue()",
|
|
622
|
-
codeChangeBefore: `ldClient
|
|
623
|
-
codeChangeAfter: `await client.getBooleanValue(flagKey, false) // server SDK is async`,
|
|
685
|
+
openFeatureEquivalent: isDetail ? "client.getBooleanDetails()" : "client.getBooleanValue()",
|
|
686
|
+
codeChangeBefore: `ldClient.${methodName}(flagKey, context, false)`,
|
|
687
|
+
codeChangeAfter: isDetail ? `await client.getBooleanDetails(flagKey, false) // server SDK is async` : `await client.getBooleanValue(flagKey, false) // server SDK is async`,
|
|
624
688
|
requiresManualReview: true,
|
|
625
689
|
reviewReason: "Flag key determined at runtime; OpenFeature server SDK methods are async \u2014 add await and make the enclosing function async"
|
|
626
690
|
};
|
|
@@ -744,7 +808,7 @@ function analyze(result) {
|
|
|
744
808
|
function formatMigrationReport(analysis) {
|
|
745
809
|
const { readinessScore, requiredPackages, items, manualReviewCount, autoMigrateCount } = analysis;
|
|
746
810
|
const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
|
|
747
|
-
const version = true ? "0.
|
|
811
|
+
const version = true ? "0.2.0" : "0.1.0";
|
|
748
812
|
let scoreLabel;
|
|
749
813
|
if (readinessScore >= 80) scoreLabel = "\u2713 Your codebase is ready for migration";
|
|
750
814
|
else if (readinessScore >= 50) scoreLabel = "\u26A0 Some manual work required before migration";
|
|
@@ -852,7 +916,7 @@ Examples:
|
|
|
852
916
|
}
|
|
853
917
|
let config;
|
|
854
918
|
try {
|
|
855
|
-
config = loadConfig(options.config);
|
|
919
|
+
config = await loadConfig(options.config);
|
|
856
920
|
} catch (err) {
|
|
857
921
|
process.stderr.write(chalk2.red(String(err instanceof Error ? err.message : err)) + "\n");
|
|
858
922
|
process.exit(1);
|
|
@@ -874,7 +938,7 @@ Examples:
|
|
|
874
938
|
});
|
|
875
939
|
let scanResult;
|
|
876
940
|
try {
|
|
877
|
-
scanResult = await scan(dir, config, (filesScanned) => {
|
|
941
|
+
scanResult = await scan(new LocalFileSource(dir), config, (filesScanned) => {
|
|
878
942
|
spinner.text = `Scanning files... ${filesScanned}`;
|
|
879
943
|
});
|
|
880
944
|
spinner.text = "Analyzing migration readiness...";
|
|
@@ -942,7 +1006,7 @@ Examples:
|
|
|
942
1006
|
// src/cli.ts
|
|
943
1007
|
function createCLI() {
|
|
944
1008
|
const program2 = new Command();
|
|
945
|
-
program2.name("flaglint").description("Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.").version("0.
|
|
1009
|
+
program2.name("flaglint").description("Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.").version("0.2.0", "-v, --version", "output the current version").addHelpText(
|
|
946
1010
|
"after",
|
|
947
1011
|
`
|
|
948
1012
|
Examples:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flaglint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
|
30
|
-
"url": "https://github.com/flaglint/
|
|
30
|
+
"url": "https://github.com/flaglint/flaglint.git"
|
|
31
31
|
},
|
|
32
32
|
"homepage": "https://flaglint.dev",
|
|
33
33
|
"bugs": {
|
|
34
|
-
"url": "https://github.com/flaglint/
|
|
34
|
+
"url": "https://github.com/flaglint/flaglint/issues"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsup",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"commander": "^12.1.0",
|
|
57
57
|
"fast-glob": "^3.3.2",
|
|
58
58
|
"ora": "^8.1.0",
|
|
59
|
+
"p-limit": "^7.3.0",
|
|
59
60
|
"zod": "^3.23.8"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|