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 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
- "staleThreshold": 1,
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
- | `staleThreshold` | `number` | `1` | Days before a flag is considered stale |
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
 
@@ -11,16 +11,34 @@ import chalk from "chalk";
11
11
  import ora from "ora";
12
12
 
13
13
  // src/scanner/index.ts
14
- import { readFile } from "fs/promises";
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
- var LD_MEMBER_METHODS = /* @__PURE__ */ new Set(["variation", "variationDetail", "allFlags"]);
19
- var LD_CLIENT_PATTERN = /ld|client/i;
20
- var LD_HOOKS = /* @__PURE__ */ new Set(["useFlags", "useLDClient"]);
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 checkStale(flagKey, filePath) {
37
- if (STALE_FILE_RE.test(filePath)) return true;
38
- if (STALE_PATH_RE.test(filePath)) return true;
39
- const lk = flagKey.toLowerCase();
40
- return STALE_KEY_WORDS.some((kw) => lk.includes(kw));
41
- }
42
- function walk(node, visit) {
43
- if (!node || typeof node !== "object") return;
44
- visit(node);
45
- for (const key of Object.keys(node)) {
46
- if (key === "parent") continue;
47
- const val = node[key];
48
- if (Array.isArray(val)) {
49
- for (const item of val) {
50
- if (item && typeof item === "object" && "type" in item) {
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
- } else if (val && typeof val === "object" && "type" in val) {
55
- walk(val, visit);
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
- isStale: checkStale("*", filePath)
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
- isStale: checkStale(flagKey, filePath)
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
- isStale: checkStale(flagKey, filePath)
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
- isStale: checkStale("*", filePath)
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
- isStale: checkStale("*", filePath)
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
- isStale: checkStale("*", filePath)
184
+ stalenessSignals: sigP ? [sigP] : []
145
185
  });
146
186
  }
147
187
  }
148
188
  });
149
189
  return usages;
150
190
  }
151
- async function scan(dir, config, onProgress) {
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 fg(config.include, {
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
- for (const file of files) {
170
- scannedFiles++;
171
- onProgress?.(scannedFiles);
204
+ async function processFile(file) {
172
205
  let code;
173
206
  try {
174
- code = await readFile(file, "utf8");
175
- } catch {
176
- continue;
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
- warnings.push(`warn: failed to parse ${relative(dir, file)}`);
189
- continue;
223
+ return { usages: [], warning: `warn: failed to parse ${file}` };
190
224
  }
191
- allUsages.push(...detectUsages(ast, file));
225
+ return { usages: detectUsages(ast, file, config.wrappers), warning: null };
192
226
  }
193
- if (config.staleThreshold > 0) {
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.staleThreshold) {
207
- usage.isStale = true;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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.isStale) entry.isStale = true;
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((u) => u.isStale);
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((u) => u.isStale) ?? data.usages[0];
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((u) => u.isStale).map((u) => u.flagKey)).size;
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.5" : "0.1.0";
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 { existsSync, readFileSync } from "fs";
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
- staleThreshold: z.number().int().min(0).default(1),
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
- if (!existsSync(full)) continue;
513
+ try {
514
+ await access(full);
515
+ } catch {
516
+ continue;
517
+ }
442
518
  let raw;
443
519
  try {
444
- raw = JSON.parse(readFileSync(full, "utf8"));
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(result.usages.filter((u) => u.isStale).map((u) => u.flagKey)).size;
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.variation(flagKey, context, false)`,
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.5" : "0.1.0";
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.5", "-v, --version", "output the current version").addHelpText(
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.5",
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": {