flaglint 0.1.5 → 0.2.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/CHANGELOG.md +47 -0
- package/README.md +3 -2
- package/dist/bin/flaglint.js +160 -80
- package/package.json +2 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,53 @@ 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.1] - 2026-05-23
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Parse failure on generic TypeScript arrow functions** — `flagPredicate = <T>(...)` and similar generic arrows in `.ts` files now parse correctly. Root cause: `@typescript-eslint/typescript-estree` wasn't receiving a `filePath`, so it couldn't apply TypeScript's extension-based JSX rules. Adding `filePath` tells the compiler to treat `.ts` files as non-JSX (generics parse cleanly) and `.tsx` as JSX (LDProvider detection still works). Validated against LaunchDarkly's own docs codebase.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`wrappers` config option** — detect custom wrapper functions as flag usages. Add your wrapper names to `.flaglintrc`:
|
|
17
|
+
```json
|
|
18
|
+
{ "wrappers": ["flagPredicate", "useFlag", "getFlag", "isEnabled"] }
|
|
19
|
+
```
|
|
20
|
+
FlagLint will treat calls to these functions as `variation`-equivalent. Supports static and dynamic flag keys. Default is `[]` — no behaviour change for existing users.
|
|
21
|
+
|
|
22
|
+
## [0.2.0] - 2026-05-22
|
|
23
|
+
|
|
24
|
+
### Breaking Changes
|
|
25
|
+
|
|
26
|
+
- **`FlagUsage.isStale: boolean` replaced with `stalenessSignals: StalenessSignal[]`**
|
|
27
|
+
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.
|
|
28
|
+
|
|
29
|
+
**Migration:** Replace `usage.isStale` checks with the exported helper:
|
|
30
|
+
```typescript
|
|
31
|
+
import { isStale } from "flaglint";
|
|
32
|
+
if (isStale(usage)) { ... }
|
|
33
|
+
```
|
|
34
|
+
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).
|
|
35
|
+
|
|
36
|
+
- **Renamed config field: `staleThreshold` → `minFileCount`**
|
|
37
|
+
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.
|
|
38
|
+
|
|
39
|
+
**Migration:** In your `flaglint.config.json` or `.flaglintrc`, rename the field:
|
|
40
|
+
```json
|
|
41
|
+
// Before
|
|
42
|
+
{ "staleThreshold": 5 }
|
|
43
|
+
// After
|
|
44
|
+
{ "minFileCount": 5 }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- 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
|
|
50
|
+
|
|
51
|
+
### Roadmap
|
|
52
|
+
|
|
53
|
+
- `v0.3`: Replace `minFileCount` with real date-based staleness detection via `git log` integration
|
|
54
|
+
|
|
8
55
|
## [0.1.5] - 2026-05-21
|
|
9
56
|
|
|
10
57
|
### Fixed
|
package/README.md
CHANGED
|
@@ -107,7 +107,7 @@ Create `.flaglintrc` in your project root:
|
|
|
107
107
|
"include": ["**/*.{ts,tsx,js,jsx}"],
|
|
108
108
|
"exclude": ["**/node_modules/**", "**/dist/**"],
|
|
109
109
|
"provider": "launchdarkly",
|
|
110
|
-
"
|
|
110
|
+
"minFileCount": 1,
|
|
111
111
|
"reportTitle": "My Project Flag Report"
|
|
112
112
|
}
|
|
113
113
|
```
|
|
@@ -117,7 +117,8 @@ Create `.flaglintrc` in your project root:
|
|
|
117
117
|
| `include` | `string[]` | `["**/*.{ts,tsx,js,jsx}"]` | Glob patterns to scan |
|
|
118
118
|
| `exclude` | `string[]` | `["**/node_modules/**", ...]` | Glob patterns to ignore |
|
|
119
119
|
| `provider` | `string` | `"launchdarkly"` | Feature flag provider |
|
|
120
|
-
| `
|
|
120
|
+
| `minFileCount` | `number` | `1` | A flag is stale if it appears in ≤ N files (default: 1) |
|
|
121
|
+
| `wrappers` | `string[]` | `[]` | Function names that wrap LD SDK calls. FlagLint will detect calls to these functions as flag usages. Example: `["flagPredicate", "useFlag", "getFlag", "isEnabled"]` |
|
|
121
122
|
| `reportTitle` | `string` | — | Custom title for generated reports |
|
|
122
123
|
| `outputDir` | `string` | `"."` | Default output directory |
|
|
123
124
|
|
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,30 +51,32 @@ 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
|
}
|
|
59
|
-
function detectUsages(ast, filePath) {
|
|
79
|
+
function detectUsages(ast, filePath, wrappers) {
|
|
60
80
|
const usages = [];
|
|
61
81
|
walk(ast, (node) => {
|
|
62
82
|
if (node.type === "CallExpression") {
|
|
@@ -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,27 @@ 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
|
}
|
|
144
|
+
if (wrappers.length > 0 && callee.type === "Identifier" && wrappers.includes(callee.name) && call.arguments.length >= 1) {
|
|
145
|
+
const { flagKey, isDynamic } = extractFlagKey(call.arguments[0]);
|
|
146
|
+
const sig = checkStale(flagKey, filePath);
|
|
147
|
+
usages.push({
|
|
148
|
+
flagKey,
|
|
149
|
+
isDynamic,
|
|
150
|
+
file: filePath,
|
|
151
|
+
line: loc.line,
|
|
152
|
+
column: loc.column,
|
|
153
|
+
callType: "variation",
|
|
154
|
+
stalenessSignals: sig ? [sig] : []
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
120
158
|
if (callee.type === "CallExpression" && callee.callee.type === "Identifier" && callee.callee.name === "withLDConsumer") {
|
|
159
|
+
const sig = checkStale("*", filePath);
|
|
121
160
|
usages.push({
|
|
122
161
|
flagKey: "*",
|
|
123
162
|
isDynamic: false,
|
|
@@ -125,7 +164,7 @@ function detectUsages(ast, filePath) {
|
|
|
125
164
|
line: loc.line,
|
|
126
165
|
column: loc.column,
|
|
127
166
|
callType: "hoc",
|
|
128
|
-
|
|
167
|
+
stalenessSignals: sig ? [sig] : []
|
|
129
168
|
});
|
|
130
169
|
return;
|
|
131
170
|
}
|
|
@@ -134,6 +173,7 @@ function detectUsages(ast, filePath) {
|
|
|
134
173
|
const jsx = node;
|
|
135
174
|
if (jsx.name.type === "JSXIdentifier" && jsx.name.name === "LDProvider") {
|
|
136
175
|
const loc = jsx.loc?.start ?? { line: 0, column: 0 };
|
|
176
|
+
const sigP = checkStale("*", filePath);
|
|
137
177
|
usages.push({
|
|
138
178
|
flagKey: "*",
|
|
139
179
|
isDynamic: false,
|
|
@@ -141,14 +181,14 @@ function detectUsages(ast, filePath) {
|
|
|
141
181
|
line: loc.line,
|
|
142
182
|
column: loc.column,
|
|
143
183
|
callType: "provider",
|
|
144
|
-
|
|
184
|
+
stalenessSignals: sigP ? [sigP] : []
|
|
145
185
|
});
|
|
146
186
|
}
|
|
147
187
|
}
|
|
148
188
|
});
|
|
149
189
|
return usages;
|
|
150
190
|
}
|
|
151
|
-
async function scan(
|
|
191
|
+
async function scan(source, config, onProgress) {
|
|
152
192
|
const start = Date.now();
|
|
153
193
|
for (const pattern of config.include) {
|
|
154
194
|
if (pattern.startsWith("/") || pattern.startsWith("..")) {
|
|
@@ -157,23 +197,17 @@ async function scan(dir, config, onProgress) {
|
|
|
157
197
|
);
|
|
158
198
|
}
|
|
159
199
|
}
|
|
160
|
-
const files = await
|
|
161
|
-
cwd: dir,
|
|
162
|
-
absolute: true,
|
|
163
|
-
ignore: [...DEFAULT_EXCLUDE, ...config.exclude],
|
|
164
|
-
onlyFiles: true
|
|
165
|
-
});
|
|
200
|
+
const files = await source.listFiles(config.include, config.exclude);
|
|
166
201
|
const allUsages = [];
|
|
167
202
|
const warnings = [];
|
|
168
203
|
let scannedFiles = 0;
|
|
169
|
-
|
|
170
|
-
scannedFiles++;
|
|
171
|
-
onProgress?.(scannedFiles);
|
|
204
|
+
async function processFile(file) {
|
|
172
205
|
let code;
|
|
173
206
|
try {
|
|
174
|
-
code = await readFile(file
|
|
175
|
-
} catch {
|
|
176
|
-
|
|
207
|
+
code = await source.readFile(file);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
const fsCode = err.code ?? "UNKNOWN";
|
|
210
|
+
return { usages: [], warning: `warn: could not read ${file} (${fsCode})` };
|
|
177
211
|
}
|
|
178
212
|
let ast;
|
|
179
213
|
try {
|
|
@@ -182,15 +216,29 @@ async function scan(dir, config, onProgress) {
|
|
|
182
216
|
loc: true,
|
|
183
217
|
range: false,
|
|
184
218
|
comment: false,
|
|
185
|
-
tokens: false
|
|
219
|
+
tokens: false,
|
|
220
|
+
filePath: file
|
|
186
221
|
});
|
|
187
222
|
} catch {
|
|
188
|
-
|
|
189
|
-
continue;
|
|
223
|
+
return { usages: [], warning: `warn: failed to parse ${file}` };
|
|
190
224
|
}
|
|
191
|
-
|
|
225
|
+
return { usages: detectUsages(ast, file, config.wrappers), warning: null };
|
|
192
226
|
}
|
|
193
|
-
|
|
227
|
+
const limit = pLimit(50);
|
|
228
|
+
const results = await Promise.all(
|
|
229
|
+
files.map(
|
|
230
|
+
(file) => limit(async () => {
|
|
231
|
+
scannedFiles++;
|
|
232
|
+
onProgress?.(scannedFiles);
|
|
233
|
+
return processFile(file);
|
|
234
|
+
})
|
|
235
|
+
)
|
|
236
|
+
);
|
|
237
|
+
for (const r of results) {
|
|
238
|
+
allUsages.push(...r.usages);
|
|
239
|
+
if (r.warning) warnings.push(r.warning);
|
|
240
|
+
}
|
|
241
|
+
if (config.minFileCount > 0) {
|
|
194
242
|
const flagFileCount = /* @__PURE__ */ new Map();
|
|
195
243
|
for (const usage of allUsages) {
|
|
196
244
|
if (!usage.isDynamic && usage.flagKey !== "*") {
|
|
@@ -203,8 +251,12 @@ async function scan(dir, config, onProgress) {
|
|
|
203
251
|
for (const usage of allUsages) {
|
|
204
252
|
if (!usage.isDynamic && usage.flagKey !== "*") {
|
|
205
253
|
const fileCount = flagFileCount.get(usage.flagKey)?.size ?? 0;
|
|
206
|
-
if (fileCount <= config.
|
|
207
|
-
usage.
|
|
254
|
+
if (fileCount <= config.minFileCount) {
|
|
255
|
+
usage.stalenessSignals.push({
|
|
256
|
+
source: "minFileCount",
|
|
257
|
+
fileCount,
|
|
258
|
+
threshold: config.minFileCount
|
|
259
|
+
});
|
|
208
260
|
}
|
|
209
261
|
}
|
|
210
262
|
}
|
|
@@ -224,18 +276,36 @@ async function scan(dir, config, onProgress) {
|
|
|
224
276
|
};
|
|
225
277
|
}
|
|
226
278
|
|
|
279
|
+
// src/scanner/local-source.ts
|
|
280
|
+
import fg from "fast-glob";
|
|
281
|
+
import { readFile } from "fs/promises";
|
|
282
|
+
import { join, relative } from "path";
|
|
283
|
+
var LocalFileSource = class {
|
|
284
|
+
constructor(dir) {
|
|
285
|
+
this.dir = dir;
|
|
286
|
+
}
|
|
287
|
+
dir;
|
|
288
|
+
async listFiles(include, exclude) {
|
|
289
|
+
const files = await fg(include, {
|
|
290
|
+
cwd: this.dir,
|
|
291
|
+
absolute: true,
|
|
292
|
+
ignore: [...DEFAULT_EXCLUDE, ...exclude],
|
|
293
|
+
onlyFiles: true
|
|
294
|
+
});
|
|
295
|
+
return files.map((f) => relative(this.dir, f));
|
|
296
|
+
}
|
|
297
|
+
async readFile(path) {
|
|
298
|
+
return readFile(join(this.dir, path), "utf8");
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// src/types.ts
|
|
303
|
+
var isStale = (u) => u.stalenessSignals.length > 0;
|
|
304
|
+
|
|
227
305
|
// src/reporter/index.ts
|
|
228
306
|
function esc(s) {
|
|
229
307
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
230
308
|
}
|
|
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
309
|
function buildFlagMap(usages) {
|
|
240
310
|
const map = /* @__PURE__ */ new Map();
|
|
241
311
|
for (const u of usages) {
|
|
@@ -246,7 +316,7 @@ function buildFlagMap(usages) {
|
|
|
246
316
|
entry.usages.push(u);
|
|
247
317
|
entry.files.add(u.file);
|
|
248
318
|
entry.callTypes.add(u.callType);
|
|
249
|
-
if (u
|
|
319
|
+
if (isStale(u)) entry.isStale = true;
|
|
250
320
|
}
|
|
251
321
|
return map;
|
|
252
322
|
}
|
|
@@ -258,7 +328,7 @@ function sortedFlagEntries(map) {
|
|
|
258
328
|
}
|
|
259
329
|
function formatMarkdown(result, options) {
|
|
260
330
|
const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
|
|
261
|
-
const staleUsages = usages.filter(
|
|
331
|
+
const staleUsages = usages.filter(isStale);
|
|
262
332
|
const dynamicUsages = usages.filter((u) => u.isDynamic);
|
|
263
333
|
const flagMap = buildFlagMap(usages);
|
|
264
334
|
const sorted = sortedFlagEntries(flagMap);
|
|
@@ -300,7 +370,7 @@ function formatMarkdown(result, options) {
|
|
|
300
370
|
lines.push("| Flag Key | Reason | Location |");
|
|
301
371
|
lines.push("|----------|--------|----------|");
|
|
302
372
|
for (const [key, data] of staleFlags) {
|
|
303
|
-
const first = data.usages.find(
|
|
373
|
+
const first = data.usages.find(isStale) ?? data.usages[0];
|
|
304
374
|
lines.push(`| ${key} | ${staleReason(first)} | ${first.file}:${first.line} |`);
|
|
305
375
|
}
|
|
306
376
|
lines.push("");
|
|
@@ -322,7 +392,7 @@ function formatJSON(result) {
|
|
|
322
392
|
}
|
|
323
393
|
function formatHTML(result, options) {
|
|
324
394
|
const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
|
|
325
|
-
const staleCount = new Set(usages.filter(
|
|
395
|
+
const staleCount = new Set(usages.filter(isStale).map((u) => u.flagKey)).size;
|
|
326
396
|
const dynamicCount = usages.filter((u) => u.isDynamic).length;
|
|
327
397
|
const date = (/* @__PURE__ */ new Date()).toLocaleString();
|
|
328
398
|
const flagMap = buildFlagMap(usages);
|
|
@@ -334,7 +404,7 @@ function formatHTML(result, options) {
|
|
|
334
404
|
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
405
|
}).join("\n ");
|
|
336
406
|
const title = options.title ? esc(options.title) : "FlagLint Scan Report";
|
|
337
|
-
const version = true ? "0.1
|
|
407
|
+
const version = true ? "0.2.1" : "0.1.0";
|
|
338
408
|
return `<!DOCTYPE html>
|
|
339
409
|
<html lang="en">
|
|
340
410
|
<head>
|
|
@@ -415,7 +485,7 @@ function formatReport(result, options) {
|
|
|
415
485
|
}
|
|
416
486
|
|
|
417
487
|
// src/config.ts
|
|
418
|
-
import {
|
|
488
|
+
import { readFile as readFile2, access } from "fs/promises";
|
|
419
489
|
import { resolve } from "path";
|
|
420
490
|
import { z, ZodError } from "zod";
|
|
421
491
|
var FlagLintConfigSchema = z.object({
|
|
@@ -429,19 +499,25 @@ var FlagLintConfigSchema = z.object({
|
|
|
429
499
|
"**/*.d.ts"
|
|
430
500
|
]),
|
|
431
501
|
provider: z.enum(["launchdarkly", "unleash", "growthbook", "custom"]).default("launchdarkly"),
|
|
432
|
-
|
|
502
|
+
// TODO v0.3: replace minFileCount with real date-based staleness via git log
|
|
503
|
+
minFileCount: z.number().int().min(0).default(1),
|
|
504
|
+
wrappers: z.array(z.string()).default([]),
|
|
433
505
|
reportTitle: z.string().optional(),
|
|
434
506
|
outputDir: z.string().default(".")
|
|
435
507
|
});
|
|
436
508
|
var SEARCH_PATHS = [".flaglintrc", ".flaglintrc.json", "flaglint.config.json"];
|
|
437
|
-
function loadConfig(configPath) {
|
|
509
|
+
async function loadConfig(configPath) {
|
|
438
510
|
const candidates = configPath ? [configPath] : SEARCH_PATHS;
|
|
439
511
|
for (const candidate of candidates) {
|
|
440
512
|
const full = resolve(candidate);
|
|
441
|
-
|
|
513
|
+
try {
|
|
514
|
+
await access(full);
|
|
515
|
+
} catch {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
442
518
|
let raw;
|
|
443
519
|
try {
|
|
444
|
-
raw = JSON.parse(
|
|
520
|
+
raw = JSON.parse(await readFile2(full, "utf8"));
|
|
445
521
|
} catch (err) {
|
|
446
522
|
throw new Error(`Error reading ${candidate}: ${String(err)}`);
|
|
447
523
|
}
|
|
@@ -504,7 +580,7 @@ Examples:
|
|
|
504
580
|
}
|
|
505
581
|
let config;
|
|
506
582
|
try {
|
|
507
|
-
config = loadConfig(options.config);
|
|
583
|
+
config = await loadConfig(options.config);
|
|
508
584
|
} catch (err) {
|
|
509
585
|
process.stderr.write(chalk.red(String(err instanceof Error ? err.message : err)) + "\n");
|
|
510
586
|
process.exit(1);
|
|
@@ -528,7 +604,7 @@ Examples:
|
|
|
528
604
|
let lastSpinnerUpdate = 0;
|
|
529
605
|
let result;
|
|
530
606
|
try {
|
|
531
|
-
result = await scan(dir, config, (filesScanned) => {
|
|
607
|
+
result = await scan(new LocalFileSource(dir), config, (filesScanned) => {
|
|
532
608
|
if (filesScanned - lastSpinnerUpdate >= 50) {
|
|
533
609
|
spinner.text = `Scanning... (${filesScanned} files)`;
|
|
534
610
|
lastSpinnerUpdate = filesScanned;
|
|
@@ -558,7 +634,9 @@ Examples:
|
|
|
558
634
|
);
|
|
559
635
|
process.exit(0);
|
|
560
636
|
}
|
|
561
|
-
const staleCount = new Set(
|
|
637
|
+
const staleCount = new Set(
|
|
638
|
+
result.usages.filter((u) => isStale(u) && !u.isDynamic && u.flagKey !== "*").map((u) => u.flagKey)
|
|
639
|
+
).size;
|
|
562
640
|
const dynamicCount = new Set(result.usages.filter((u) => u.isDynamic).map((u) => u.flagKey)).size;
|
|
563
641
|
process.stderr.write(
|
|
564
642
|
chalk.green(
|
|
@@ -616,11 +694,13 @@ function keyLiteral(usage) {
|
|
|
616
694
|
function buildItem(usage) {
|
|
617
695
|
const k = keyLiteral(usage);
|
|
618
696
|
if (usage.isDynamic) {
|
|
697
|
+
const isDetail = usage.callType === "variationDetail";
|
|
698
|
+
const methodName = isDetail ? "variationDetail" : "variation";
|
|
619
699
|
return {
|
|
620
700
|
usage,
|
|
621
|
-
openFeatureEquivalent: "client.getBooleanValue()",
|
|
622
|
-
codeChangeBefore: `ldClient
|
|
623
|
-
codeChangeAfter: `await client.getBooleanValue(flagKey, false) // server SDK is async`,
|
|
701
|
+
openFeatureEquivalent: isDetail ? "client.getBooleanDetails()" : "client.getBooleanValue()",
|
|
702
|
+
codeChangeBefore: `ldClient.${methodName}(flagKey, context, false)`,
|
|
703
|
+
codeChangeAfter: isDetail ? `await client.getBooleanDetails(flagKey, false) // server SDK is async` : `await client.getBooleanValue(flagKey, false) // server SDK is async`,
|
|
624
704
|
requiresManualReview: true,
|
|
625
705
|
reviewReason: "Flag key determined at runtime; OpenFeature server SDK methods are async \u2014 add await and make the enclosing function async"
|
|
626
706
|
};
|
|
@@ -744,7 +824,7 @@ function analyze(result) {
|
|
|
744
824
|
function formatMigrationReport(analysis) {
|
|
745
825
|
const { readinessScore, requiredPackages, items, manualReviewCount, autoMigrateCount } = analysis;
|
|
746
826
|
const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
|
|
747
|
-
const version = true ? "0.1
|
|
827
|
+
const version = true ? "0.2.1" : "0.1.0";
|
|
748
828
|
let scoreLabel;
|
|
749
829
|
if (readinessScore >= 80) scoreLabel = "\u2713 Your codebase is ready for migration";
|
|
750
830
|
else if (readinessScore >= 50) scoreLabel = "\u26A0 Some manual work required before migration";
|
|
@@ -852,7 +932,7 @@ Examples:
|
|
|
852
932
|
}
|
|
853
933
|
let config;
|
|
854
934
|
try {
|
|
855
|
-
config = loadConfig(options.config);
|
|
935
|
+
config = await loadConfig(options.config);
|
|
856
936
|
} catch (err) {
|
|
857
937
|
process.stderr.write(chalk2.red(String(err instanceof Error ? err.message : err)) + "\n");
|
|
858
938
|
process.exit(1);
|
|
@@ -874,7 +954,7 @@ Examples:
|
|
|
874
954
|
});
|
|
875
955
|
let scanResult;
|
|
876
956
|
try {
|
|
877
|
-
scanResult = await scan(dir, config, (filesScanned) => {
|
|
957
|
+
scanResult = await scan(new LocalFileSource(dir), config, (filesScanned) => {
|
|
878
958
|
spinner.text = `Scanning files... ${filesScanned}`;
|
|
879
959
|
});
|
|
880
960
|
spinner.text = "Analyzing migration readiness...";
|
|
@@ -942,7 +1022,7 @@ Examples:
|
|
|
942
1022
|
// src/cli.ts
|
|
943
1023
|
function createCLI() {
|
|
944
1024
|
const program2 = new Command();
|
|
945
|
-
program2.name("flaglint").description("Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.").version("0.1
|
|
1025
|
+
program2.name("flaglint").description("Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.").version("0.2.1", "-v, --version", "output the current version").addHelpText(
|
|
946
1026
|
"after",
|
|
947
1027
|
`
|
|
948
1028
|
Examples:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flaglint",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,10 +41,6 @@
|
|
|
41
41
|
"test": "vitest",
|
|
42
42
|
"test:run": "vitest run",
|
|
43
43
|
"test:coverage": "vitest run --coverage",
|
|
44
|
-
"agent": "tsx scripts/agent/agent.ts",
|
|
45
|
-
"agent:launch": "tsx scripts/agent/agent.ts launch",
|
|
46
|
-
"agent:parallel": "tsx scripts/agent/agent.ts parallel",
|
|
47
|
-
"agent:sync": "tsx scripts/agent/agent.ts sync-docs",
|
|
48
44
|
"release:patch": "tsx scripts/release.ts patch",
|
|
49
45
|
"release:minor": "tsx scripts/release.ts minor",
|
|
50
46
|
"release:major": "tsx scripts/release.ts major"
|
|
@@ -56,6 +52,7 @@
|
|
|
56
52
|
"commander": "^12.1.0",
|
|
57
53
|
"fast-glob": "^3.3.2",
|
|
58
54
|
"ora": "^8.1.0",
|
|
55
|
+
"p-limit": "^7.3.0",
|
|
59
56
|
"zod": "^3.23.8"
|
|
60
57
|
},
|
|
61
58
|
"devDependencies": {
|