flaglint 0.0.1 → 0.1.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.
@@ -0,0 +1,947 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/scan.ts
7
+ import { writeFile } from "fs/promises";
8
+ import { stat } from "fs/promises";
9
+ import { resolve as resolve2 } from "path";
10
+ import chalk from "chalk";
11
+ import ora from "ora";
12
+
13
+ // src/scanner/index.ts
14
+ import { readFile } from "fs/promises";
15
+ import { relative } from "path";
16
+ import fg from "fast-glob";
17
+ 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"];
22
+ var STALE_FILE_RE = /\.(test|spec|mock)\.[jt]sx?$/;
23
+ var STALE_PATH_RE = /\/deprecated\/|\/old\/|\/legacy\//;
24
+ var DEFAULT_EXCLUDE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.next/**"];
25
+ function extractFlagKey(arg) {
26
+ if (!arg) return { flagKey: "dynamic", isDynamic: true };
27
+ if (arg.type === "Literal" && typeof arg.value === "string") {
28
+ return { flagKey: arg.value, isDynamic: false };
29
+ }
30
+ if (arg.type === "TemplateLiteral" && arg.expressions.length === 0) {
31
+ const cooked = arg.quasis[0]?.value.cooked;
32
+ if (cooked != null) return { flagKey: cooked, isDynamic: false };
33
+ }
34
+ return { flagKey: "dynamic", isDynamic: true };
35
+ }
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);
52
+ }
53
+ }
54
+ } else if (val && typeof val === "object" && "type" in val) {
55
+ walk(val, visit);
56
+ }
57
+ }
58
+ }
59
+ function detectUsages(ast, filePath) {
60
+ const usages = [];
61
+ walk(ast, (node) => {
62
+ if (node.type === "CallExpression") {
63
+ const call = node;
64
+ const { callee } = call;
65
+ const loc = call.loc?.start ?? { line: 0, column: 0 };
66
+ 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
+ const method = callee.property.name;
68
+ if (method === "allFlags") {
69
+ usages.push({
70
+ flagKey: "*",
71
+ isDynamic: false,
72
+ file: filePath,
73
+ line: loc.line,
74
+ column: loc.column,
75
+ callType: "allFlags",
76
+ isStale: checkStale("*", filePath)
77
+ });
78
+ } else {
79
+ const { flagKey, isDynamic } = extractFlagKey(call.arguments[0]);
80
+ usages.push({
81
+ flagKey,
82
+ isDynamic,
83
+ file: filePath,
84
+ line: loc.line,
85
+ column: loc.column,
86
+ callType: method,
87
+ isStale: checkStale(flagKey, filePath)
88
+ });
89
+ }
90
+ return;
91
+ }
92
+ if (callee.type === "Identifier") {
93
+ const name = callee.name;
94
+ if (name === "isFeatureEnabled") {
95
+ const { flagKey, isDynamic } = extractFlagKey(call.arguments[0]);
96
+ usages.push({
97
+ flagKey,
98
+ isDynamic,
99
+ file: filePath,
100
+ line: loc.line,
101
+ column: loc.column,
102
+ callType: "isFeatureEnabled",
103
+ isStale: checkStale(flagKey, filePath)
104
+ });
105
+ return;
106
+ }
107
+ if (LD_HOOKS.has(name)) {
108
+ usages.push({
109
+ flagKey: "*",
110
+ isDynamic: false,
111
+ file: filePath,
112
+ line: loc.line,
113
+ column: loc.column,
114
+ callType: name === "useFlags" ? "hook-useFlags" : "hook-useLDClient",
115
+ isStale: checkStale("*", filePath)
116
+ });
117
+ return;
118
+ }
119
+ }
120
+ if (callee.type === "CallExpression" && callee.callee.type === "Identifier" && callee.callee.name === "withLDConsumer") {
121
+ usages.push({
122
+ flagKey: "*",
123
+ isDynamic: false,
124
+ file: filePath,
125
+ line: loc.line,
126
+ column: loc.column,
127
+ callType: "hoc",
128
+ isStale: checkStale("*", filePath)
129
+ });
130
+ return;
131
+ }
132
+ }
133
+ if (node.type === "JSXOpeningElement") {
134
+ const jsx = node;
135
+ if (jsx.name.type === "JSXIdentifier" && jsx.name.name === "LDProvider") {
136
+ const loc = jsx.loc?.start ?? { line: 0, column: 0 };
137
+ usages.push({
138
+ flagKey: "*",
139
+ isDynamic: false,
140
+ file: filePath,
141
+ line: loc.line,
142
+ column: loc.column,
143
+ callType: "provider",
144
+ isStale: false
145
+ });
146
+ }
147
+ }
148
+ });
149
+ return usages;
150
+ }
151
+ async function scan(dir, config, onProgress) {
152
+ const start = Date.now();
153
+ for (const pattern of config.include) {
154
+ if (pattern.startsWith("/") || pattern.startsWith("..")) {
155
+ throw new Error(
156
+ `Invalid include pattern: "${pattern}" \u2014 patterns must be relative and must not start with ".."`
157
+ );
158
+ }
159
+ }
160
+ const files = await fg(config.include, {
161
+ cwd: dir,
162
+ absolute: true,
163
+ ignore: [...DEFAULT_EXCLUDE, ...config.exclude],
164
+ onlyFiles: true
165
+ });
166
+ const allUsages = [];
167
+ const warnings = [];
168
+ let scannedFiles = 0;
169
+ for (const file of files) {
170
+ scannedFiles++;
171
+ onProgress?.(scannedFiles);
172
+ let code;
173
+ try {
174
+ code = await readFile(file, "utf8");
175
+ } catch {
176
+ continue;
177
+ }
178
+ let ast;
179
+ try {
180
+ ast = parse(code, {
181
+ jsx: true,
182
+ loc: true,
183
+ range: false,
184
+ comment: false,
185
+ tokens: false
186
+ });
187
+ } catch {
188
+ warnings.push(`warn: failed to parse ${relative(dir, file)}`);
189
+ continue;
190
+ }
191
+ allUsages.push(...detectUsages(ast, file));
192
+ }
193
+ if (config.staleThreshold > 0) {
194
+ const flagFileCount = /* @__PURE__ */ new Map();
195
+ for (const usage of allUsages) {
196
+ if (!usage.isDynamic && usage.flagKey !== "*") {
197
+ if (!flagFileCount.has(usage.flagKey)) {
198
+ flagFileCount.set(usage.flagKey, /* @__PURE__ */ new Set());
199
+ }
200
+ flagFileCount.get(usage.flagKey).add(usage.file);
201
+ }
202
+ }
203
+ for (const usage of allUsages) {
204
+ if (!usage.isDynamic && usage.flagKey !== "*") {
205
+ const fileCount = flagFileCount.get(usage.flagKey)?.size ?? 0;
206
+ if (fileCount <= config.staleThreshold) {
207
+ usage.isStale = true;
208
+ }
209
+ }
210
+ }
211
+ }
212
+ const uniqueFlags = [
213
+ ...new Set(
214
+ allUsages.filter((u) => !u.isDynamic && u.flagKey !== "*").map((u) => u.flagKey)
215
+ )
216
+ ];
217
+ return {
218
+ scannedFiles,
219
+ totalUsages: allUsages.length,
220
+ uniqueFlags,
221
+ usages: allUsages,
222
+ scanDurationMs: Date.now() - start,
223
+ warnings
224
+ };
225
+ }
226
+
227
+ // src/reporter/index.ts
228
+ function esc(s) {
229
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
230
+ }
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
+ function buildFlagMap(usages) {
240
+ const map = /* @__PURE__ */ new Map();
241
+ for (const u of usages) {
242
+ if (!map.has(u.flagKey)) {
243
+ map.set(u.flagKey, { usages: [], files: /* @__PURE__ */ new Set(), callTypes: /* @__PURE__ */ new Set(), isStale: false });
244
+ }
245
+ const entry = map.get(u.flagKey);
246
+ entry.usages.push(u);
247
+ entry.files.add(u.file);
248
+ entry.callTypes.add(u.callType);
249
+ if (u.isStale) entry.isStale = true;
250
+ }
251
+ return map;
252
+ }
253
+ function sortedFlagEntries(map) {
254
+ return [...map.entries()].sort(([, a], [, b]) => {
255
+ if (a.isStale !== b.isStale) return a.isStale ? -1 : 1;
256
+ return b.usages.length - a.usages.length;
257
+ });
258
+ }
259
+ function formatMarkdown(result, options) {
260
+ const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
261
+ const staleUsages = usages.filter((u) => u.isStale);
262
+ const dynamicUsages = usages.filter((u) => u.isDynamic);
263
+ const flagMap = buildFlagMap(usages);
264
+ const sorted = sortedFlagEntries(flagMap);
265
+ const staleFlags = sorted.filter(([, d]) => d.isStale);
266
+ const lines = [];
267
+ lines.push("# FlagLint Scan Report");
268
+ if (options.title) lines.push("", options.title);
269
+ lines.push("");
270
+ lines.push(`**Scanned:** ${scannedFiles} files in ${scanDurationMs}ms `);
271
+ lines.push(`**Flag usages:** ${totalUsages} across ${uniqueFlags.length} unique flags `);
272
+ lines.push(`**Stale candidates:** ${staleUsages.length} flags flagged for review`);
273
+ lines.push("");
274
+ lines.push("## Flag Inventory");
275
+ lines.push("| Flag Key | Usages | Files | Call Types | Status |");
276
+ lines.push("|----------|--------|-------|------------|--------|");
277
+ for (const [key, data] of sorted) {
278
+ const status = data.isStale ? "\u26A0 Stale" : "\u2713 Active";
279
+ lines.push(
280
+ `| ${key} | ${data.usages.length} | ${data.files.size} | ${[...data.callTypes].join(", ")} | ${status} |`
281
+ );
282
+ }
283
+ lines.push("");
284
+ lines.push("## Usages by File");
285
+ const byFile = /* @__PURE__ */ new Map();
286
+ for (const u of usages) {
287
+ if (!byFile.has(u.file)) byFile.set(u.file, []);
288
+ byFile.get(u.file).push(u);
289
+ }
290
+ for (const [file, fileUsages] of byFile) {
291
+ lines.push(`### ${file}`);
292
+ for (const u of [...fileUsages].sort((a, b) => a.line - b.line)) {
293
+ lines.push(`- Line ${u.line}: \`${u.flagKey}\` (${u.callType})`);
294
+ }
295
+ lines.push("");
296
+ }
297
+ if (staleFlags.length > 0) {
298
+ lines.push("## \u26A0 Stale Flag Candidates");
299
+ lines.push("Flags that may be safe to remove:");
300
+ lines.push("| Flag Key | Reason | Location |");
301
+ lines.push("|----------|--------|----------|");
302
+ for (const [key, data] of staleFlags) {
303
+ const first = data.usages[0];
304
+ lines.push(`| ${key} | ${staleReason(first)} | ${first.file}:${first.line} |`);
305
+ }
306
+ lines.push("");
307
+ }
308
+ if (dynamicUsages.length > 0) {
309
+ lines.push("## Dynamic Flag Keys (Manual Review Required)");
310
+ lines.push(
311
+ "Flags with non-static keys that could not be automatically identified:"
312
+ );
313
+ for (const u of dynamicUsages) {
314
+ lines.push(`- \`dynamic\` at ${u.file}:${u.line} \u2014 key determined at runtime`);
315
+ }
316
+ lines.push("");
317
+ }
318
+ return lines.join("\n");
319
+ }
320
+ function formatJSON(result) {
321
+ return JSON.stringify({ generatedAt: (/* @__PURE__ */ new Date()).toISOString(), ...result }, null, 2);
322
+ }
323
+ function formatHTML(result, options) {
324
+ const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
325
+ const staleCount = usages.filter((u) => u.isStale).length;
326
+ const dynamicCount = usages.filter((u) => u.isDynamic).length;
327
+ const date = (/* @__PURE__ */ new Date()).toLocaleString();
328
+ const flagMap = buildFlagMap(usages);
329
+ const sorted = sortedFlagEntries(flagMap);
330
+ const rows = sorted.map(([key, data]) => {
331
+ const cls = data.isStale ? "stale" : data.usages.some((u) => u.isDynamic) ? "dynamic" : "";
332
+ const status = data.isStale ? "\u26A0 Stale" : "\u2713 Active";
333
+ const fileList = [...data.files].map((f) => esc(f)).join("<br>");
334
+ 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
+ }).join("\n ");
336
+ const title = options.title ? esc(options.title) : "FlagLint Scan Report";
337
+ const version = true ? "0.1.1" : "0.1.0";
338
+ return `<!DOCTYPE html>
339
+ <html lang="en">
340
+ <head>
341
+ <meta charset="UTF-8">
342
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
343
+ <title>${title}</title>
344
+ <style>
345
+ :root{--bg:#fff;--surface:#f8f9fa;--border:#dee2e6;--text:#212529;--muted:#6c757d;--stale-bg:#fef3c7;--dyn-bg:#dbeafe;--card-shadow:0 1px 3px rgba(0,0,0,.1)}
346
+ @media(prefers-color-scheme:dark){:root{--bg:#0f172a;--surface:#1e293b;--border:#334155;--text:#e2e8f0;--muted:#94a3b8;--stale-bg:#78350f;--dyn-bg:#1e3a5f;--card-shadow:0 1px 3px rgba(0,0,0,.4)}}
347
+ *{box-sizing:border-box;margin:0;padding:0}
348
+ body{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;padding:2rem;max-width:1200px;margin:0 auto;line-height:1.5}
349
+ h1{font-size:1.75rem;margin-bottom:.25rem}
350
+ h2{font-size:1.125rem;margin:2rem 0 .75rem;padding-bottom:.5rem;border-bottom:1px solid var(--border)}
351
+ .subtitle{color:var(--muted);margin-bottom:1.5rem;font-size:.875rem}
352
+ .cards{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
353
+ .card{flex:1;min-width:140px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;box-shadow:var(--card-shadow)}
354
+ .card-num{font-size:1.875rem;font-weight:700;line-height:1}
355
+ .card-num.yellow{color:#d97706}
356
+ .card-num.blue{color:#3b82f6}
357
+ .card-label{color:var(--muted);font-size:.75rem;margin-top:.375rem;text-transform:uppercase;letter-spacing:.05em}
358
+ .filter-wrap{margin-bottom:.75rem}
359
+ #filter{width:100%;padding:.5rem .75rem;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font-size:.875rem;outline:none}
360
+ #filter:focus{border-color:#6366f1}
361
+ table{width:100%;border-collapse:collapse;font-size:.8125rem}
362
+ th{text-align:left;padding:.625rem .75rem;background:var(--surface);border-bottom:2px solid var(--border);font-weight:600;white-space:nowrap}
363
+ td{padding:.625rem .75rem;border-bottom:1px solid var(--border);vertical-align:top}
364
+ tr.stale td{background:var(--stale-bg)}
365
+ tr.dynamic td{background:var(--dyn-bg)}
366
+ code{font-family:ui-monospace,monospace;font-size:.8em;background:var(--surface);padding:.1em .3em;border-radius:3px}
367
+ footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border);color:var(--muted);font-size:.75rem;text-align:center}
368
+ </style>
369
+ </head>
370
+ <body>
371
+ <h1>${title}</h1>
372
+ <p class="subtitle">Scanned ${scannedFiles} files in ${scanDurationMs}ms</p>
373
+
374
+ <div class="cards">
375
+ <div class="card"><div class="card-num">${scannedFiles}</div><div class="card-label">Files Scanned</div></div>
376
+ <div class="card"><div class="card-num">${uniqueFlags.length}</div><div class="card-label">Unique Flags</div></div>
377
+ <div class="card"><div class="card-num">${totalUsages}</div><div class="card-label">Total Usages</div></div>
378
+ <div class="card"><div class="card-num yellow">${staleCount}</div><div class="card-label">Stale Candidates</div></div>
379
+ <div class="card"><div class="card-num blue">${dynamicCount}</div><div class="card-label">Dynamic Keys</div></div>
380
+ </div>
381
+
382
+ <h2>Flag Inventory</h2>
383
+ <div class="filter-wrap">
384
+ <input type="text" id="filter" placeholder="Filter by flag key, file, or call type\u2026">
385
+ </div>
386
+ <table id="flags-table">
387
+ <thead><tr><th>Flag Key</th><th>Usages</th><th>Files</th><th>Call Types</th><th>Status</th></tr></thead>
388
+ <tbody>
389
+ ${rows}
390
+ </tbody>
391
+ </table>
392
+
393
+ <footer>Generated by FlagLint ${esc(version)} on ${esc(date)}</footer>
394
+
395
+ <script>
396
+ const input = document.getElementById('filter');
397
+ const rows = document.querySelectorAll('#flags-table tbody tr');
398
+ input.addEventListener('input', () => {
399
+ const q = input.value.toLowerCase();
400
+ rows.forEach(r => { r.style.display = r.textContent.toLowerCase().includes(q) ? '' : 'none'; });
401
+ });
402
+ </script>
403
+ </body>
404
+ </html>`;
405
+ }
406
+ function formatReport(result, options) {
407
+ switch (options.format) {
408
+ case "json":
409
+ return formatJSON(result);
410
+ case "html":
411
+ return formatHTML(result, options);
412
+ default:
413
+ return formatMarkdown(result, options);
414
+ }
415
+ }
416
+
417
+ // src/config.ts
418
+ import { existsSync, readFileSync } from "fs";
419
+ import { resolve } from "path";
420
+ import { z, ZodError } from "zod";
421
+ var FlagLintConfigSchema = z.object({
422
+ include: z.array(z.string()).default(["**/*.{ts,tsx,js,jsx}"]),
423
+ exclude: z.array(z.string()).default([
424
+ "**/node_modules/**",
425
+ "**/dist/**",
426
+ "**/build/**",
427
+ "**/.next/**",
428
+ "**/coverage/**",
429
+ "**/*.d.ts"
430
+ ]),
431
+ provider: z.enum(["launchdarkly", "unleash", "growthbook", "custom"]).default("launchdarkly"),
432
+ staleThreshold: z.number().int().min(0).default(1),
433
+ reportTitle: z.string().optional(),
434
+ outputDir: z.string().default(".")
435
+ });
436
+ var SEARCH_PATHS = [".flaglintrc", ".flaglintrc.json", "flaglint.config.json"];
437
+ function loadConfig(configPath) {
438
+ const candidates = configPath ? [configPath] : SEARCH_PATHS;
439
+ for (const candidate of candidates) {
440
+ const full = resolve(candidate);
441
+ if (!existsSync(full)) continue;
442
+ let raw;
443
+ try {
444
+ raw = JSON.parse(readFileSync(full, "utf8"));
445
+ } catch (err) {
446
+ throw new Error(`Error reading ${candidate}: ${String(err)}`);
447
+ }
448
+ try {
449
+ return FlagLintConfigSchema.parse(raw);
450
+ } catch (err) {
451
+ if (err instanceof ZodError) {
452
+ const detail = err.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join("; ");
453
+ throw new Error(`Error in ${candidate}: ${detail}`);
454
+ }
455
+ throw err;
456
+ }
457
+ }
458
+ return FlagLintConfigSchema.parse({});
459
+ }
460
+
461
+ // src/commands/scan.ts
462
+ var VALID_FORMATS = ["json", "markdown", "html"];
463
+ function registerScanCommand(program2) {
464
+ 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").addHelpText(
465
+ "after",
466
+ `
467
+ Examples:
468
+ $ flaglint scan scan current directory
469
+ $ flaglint scan ./src scan specific directory
470
+ $ flaglint scan --format json output as JSON
471
+ $ flaglint scan --output report.md save to file`
472
+ ).action(
473
+ async (dir, options) => {
474
+ if (!VALID_FORMATS.includes(options.format)) {
475
+ process.stderr.write(
476
+ chalk.red(
477
+ `Error: Invalid format '${options.format}'. Must be one of: ${VALID_FORMATS.join(", ")}
478
+ `
479
+ )
480
+ );
481
+ process.exit(2);
482
+ }
483
+ try {
484
+ const s = await stat(resolve2(dir));
485
+ if (!s.isDirectory()) {
486
+ process.stderr.write(chalk.red(`Error: Not a directory: ${dir}
487
+ `));
488
+ process.exit(1);
489
+ }
490
+ } catch (err) {
491
+ const code = err.code;
492
+ if (code === "ENOENT") {
493
+ process.stderr.write(chalk.red(`Error: Directory not found: ${dir}
494
+ `));
495
+ } else if (code === "EACCES") {
496
+ process.stderr.write(chalk.red(`Error: Permission denied: ${dir}
497
+ `));
498
+ } else {
499
+ process.stderr.write(chalk.red(`Error: Cannot access directory: ${dir}
500
+ `));
501
+ }
502
+ process.exit(1);
503
+ }
504
+ let config;
505
+ try {
506
+ config = loadConfig(options.config);
507
+ } catch (err) {
508
+ process.stderr.write(chalk.red(String(err instanceof Error ? err.message : err)) + "\n");
509
+ process.exit(1);
510
+ }
511
+ const format = options.format;
512
+ const spinner = ora(`Scanning ${dir}...`).start();
513
+ process.once("SIGINT", () => {
514
+ spinner.stop();
515
+ process.exit(130);
516
+ });
517
+ let lastSpinnerUpdate = 0;
518
+ let result;
519
+ try {
520
+ result = await scan(dir, config, (filesScanned) => {
521
+ if (filesScanned - lastSpinnerUpdate >= 50) {
522
+ spinner.text = `Scanning... (${filesScanned} files)`;
523
+ lastSpinnerUpdate = filesScanned;
524
+ }
525
+ });
526
+ spinner.stop();
527
+ } catch (err) {
528
+ spinner.fail("Scan failed");
529
+ process.stderr.write(chalk.red(String(err)) + "\n");
530
+ process.exit(1);
531
+ }
532
+ for (const w of result.warnings) {
533
+ process.stderr.write(chalk.yellow(w + "\n"));
534
+ }
535
+ if (result.scannedFiles === 0) {
536
+ process.stderr.write(
537
+ chalk.yellow("No matching files found. Check your .flaglintrc include patterns.\n")
538
+ );
539
+ process.exit(0);
540
+ }
541
+ if (result.totalUsages === 0) {
542
+ process.stderr.write(
543
+ chalk.dim(
544
+ `No LaunchDarkly SDK usage detected in ${result.scannedFiles} files.
545
+ `
546
+ )
547
+ );
548
+ process.exit(0);
549
+ }
550
+ const staleCount = new Set(result.usages.filter((u) => u.isStale).map((u) => u.flagKey)).size;
551
+ const dynamicCount = new Set(result.usages.filter((u) => u.isDynamic).map((u) => u.flagKey)).size;
552
+ process.stderr.write(
553
+ chalk.green(
554
+ `\u2713 ${result.totalUsages} flag usages found across ${result.uniqueFlags.length} unique flags (${result.scanDurationMs}ms)
555
+ `
556
+ )
557
+ );
558
+ if (staleCount > 0) {
559
+ process.stderr.write(
560
+ chalk.yellow(`\u26A0 ${staleCount} potentially stale flag(s) \u2014 review recommended
561
+ `)
562
+ );
563
+ }
564
+ if (dynamicCount > 0) {
565
+ process.stderr.write(
566
+ chalk.blue(`\u2139 ${dynamicCount} dynamic flag key(s) require manual review
567
+ `)
568
+ );
569
+ }
570
+ const report = formatReport(result, { format, title: config.reportTitle });
571
+ if (options.output) {
572
+ const outPath = resolve2(options.output);
573
+ try {
574
+ await writeFile(outPath, report, "utf8");
575
+ process.stderr.write(chalk.dim(` Report written to ${options.output}
576
+ `));
577
+ } catch (err) {
578
+ process.stderr.write(
579
+ chalk.red(
580
+ `Error: Failed to write report to ${options.output}: ${err instanceof Error ? err.message : String(err)}
581
+ `
582
+ )
583
+ );
584
+ process.exit(1);
585
+ }
586
+ } else {
587
+ process.stdout.write(report + "\n");
588
+ }
589
+ process.exit(staleCount > 0 ? 1 : 0);
590
+ }
591
+ );
592
+ }
593
+
594
+ // src/commands/migrate.ts
595
+ import { writeFile as writeFile2 } from "fs/promises";
596
+ import { stat as stat2 } from "fs/promises";
597
+ import { resolve as resolve3 } from "path";
598
+ import chalk2 from "chalk";
599
+ import ora2 from "ora";
600
+
601
+ // src/migrator/index.ts
602
+ function keyLiteral(usage) {
603
+ return usage.isDynamic ? "flagKey" : `'${usage.flagKey}'`;
604
+ }
605
+ function buildItem(usage) {
606
+ const k = keyLiteral(usage);
607
+ if (usage.isDynamic) {
608
+ return {
609
+ usage,
610
+ openFeatureEquivalent: "client.getBooleanValue()",
611
+ codeChangeBefore: `ldClient.variation(flagKey, context, false)`,
612
+ codeChangeAfter: `await client.getBooleanValue(flagKey, false) // server SDK is async`,
613
+ requiresManualReview: true,
614
+ reviewReason: "Flag key determined at runtime; OpenFeature server SDK methods are async \u2014 add await and make the enclosing function async"
615
+ };
616
+ }
617
+ switch (usage.callType) {
618
+ case "variation":
619
+ return {
620
+ usage,
621
+ openFeatureEquivalent: "client.getBooleanValue()",
622
+ codeChangeBefore: `ldClient.variation(${k}, context, false)`,
623
+ codeChangeAfter: `await client.getBooleanValue(${k}, false) // server SDK is async`,
624
+ requiresManualReview: true,
625
+ reviewReason: "OpenFeature server SDK methods are async \u2014 add await and make the enclosing function async"
626
+ };
627
+ case "variationDetail":
628
+ return {
629
+ usage,
630
+ openFeatureEquivalent: "client.getBooleanDetails()",
631
+ codeChangeBefore: `ldClient.variationDetail(${k}, context, false)`,
632
+ codeChangeAfter: `await client.getBooleanDetails(${k}, false) // server SDK is async`,
633
+ requiresManualReview: true,
634
+ reviewReason: "OpenFeature server SDK methods are async \u2014 add await and make the enclosing function async"
635
+ };
636
+ case "allFlags":
637
+ return {
638
+ usage,
639
+ openFeatureEquivalent: null,
640
+ codeChangeBefore: `ldClient.allFlags(context)`,
641
+ codeChangeAfter: `// No direct OpenFeature equivalent \u2014 requires manual implementation`,
642
+ requiresManualReview: true,
643
+ reviewReason: "allFlags() has no direct OpenFeature equivalent"
644
+ };
645
+ case "isFeatureEnabled":
646
+ return {
647
+ usage,
648
+ openFeatureEquivalent: "client.getBooleanValue()",
649
+ codeChangeBefore: `isFeatureEnabled(${k}, context)`,
650
+ codeChangeAfter: `await client.getBooleanValue(${k}, false) // server SDK is async`,
651
+ requiresManualReview: true,
652
+ reviewReason: "OpenFeature server SDK methods are async \u2014 add await and make the enclosing function async"
653
+ };
654
+ case "hook-useFlags":
655
+ return {
656
+ usage,
657
+ openFeatureEquivalent: "useBooleanFlagValue()",
658
+ codeChangeBefore: `const flags = useFlags()`,
659
+ codeChangeAfter: `const flagValue = useBooleanFlagValue('your-flag-key', false) // TODO: one hook call per flag`,
660
+ requiresManualReview: true,
661
+ reviewReason: "useFlags() returns all flags; OpenFeature requires one useBooleanFlagValue() call per flag"
662
+ };
663
+ case "hook-useLDClient":
664
+ return {
665
+ usage,
666
+ openFeatureEquivalent: "useOpenFeatureClient()",
667
+ codeChangeBefore: `const client = useLDClient()`,
668
+ codeChangeAfter: `const client = useOpenFeatureClient()`,
669
+ requiresManualReview: false
670
+ };
671
+ case "hoc":
672
+ return {
673
+ usage,
674
+ openFeatureEquivalent: null,
675
+ codeChangeBefore: `withLDConsumer()(Component)`,
676
+ codeChangeAfter: `// withOpenFeature() does not exist in OpenFeature SDK 0.4+
677
+ // Convert to a functional component and use useBooleanFlagValue() instead`,
678
+ requiresManualReview: true,
679
+ reviewReason: "withOpenFeature() HOC does not exist in OpenFeature SDK 0.4+; convert to a functional component with hooks"
680
+ };
681
+ case "provider":
682
+ return {
683
+ usage,
684
+ openFeatureEquivalent: "OpenFeatureProvider",
685
+ codeChangeBefore: `<LDProvider clientSideID="...">`,
686
+ codeChangeAfter: `<OpenFeatureProvider provider={...}>`,
687
+ requiresManualReview: false
688
+ };
689
+ default:
690
+ return {
691
+ usage,
692
+ openFeatureEquivalent: null,
693
+ codeChangeBefore: `// ${usage.callType} call`,
694
+ codeChangeAfter: `// Manual migration required`,
695
+ requiresManualReview: true,
696
+ reviewReason: `Unrecognized call type: ${usage.callType}`
697
+ };
698
+ }
699
+ }
700
+ function calcReadinessScore(usages) {
701
+ let score = 100;
702
+ const dynamicCount = usages.filter((u) => u.isDynamic).length;
703
+ score -= Math.min(dynamicCount * 10, 40);
704
+ const useFlagsCount = usages.filter((u) => u.callType === "hook-useFlags").length;
705
+ score -= useFlagsCount * 5;
706
+ const hasAllFlags = usages.some((u) => u.callType === "allFlags");
707
+ if (hasAllFlags) score -= 15;
708
+ const hocCount = usages.filter((u) => u.callType === "hoc").length;
709
+ score -= hocCount * 5;
710
+ const hasStaticKeys = usages.some((u) => !u.isDynamic && u.flagKey !== "*");
711
+ if (!hasStaticKeys) score -= 20;
712
+ return Math.max(0, score);
713
+ }
714
+ function calcRequiredPackages(usages) {
715
+ const REACT_CALL_TYPES = ["hook-useFlags", "hook-useLDClient", "hoc", "provider"];
716
+ const SERVER_CALL_TYPES = ["variation", "variationDetail", "allFlags", "isFeatureEnabled"];
717
+ const hasReactUsage = usages.some((u) => REACT_CALL_TYPES.includes(u.callType));
718
+ const hasServerUsage = usages.some((u) => SERVER_CALL_TYPES.includes(u.callType));
719
+ const pkgs = /* @__PURE__ */ new Set();
720
+ if (hasReactUsage && !hasServerUsage) {
721
+ pkgs.add("@openfeature/web-sdk");
722
+ pkgs.add("@openfeature/react-sdk");
723
+ } else if (hasReactUsage && hasServerUsage) {
724
+ pkgs.add("@openfeature/server-sdk");
725
+ pkgs.add("@openfeature/web-sdk");
726
+ pkgs.add("@openfeature/react-sdk");
727
+ } else {
728
+ pkgs.add("@openfeature/server-sdk");
729
+ }
730
+ return [...pkgs].sort();
731
+ }
732
+ function analyze(result) {
733
+ const items = result.usages.map(buildItem);
734
+ const readinessScore = calcReadinessScore(result.usages);
735
+ const requiredPackages = calcRequiredPackages(result.usages);
736
+ const manualReviewCount = items.filter((i) => i.requiresManualReview).length;
737
+ const autoMigrateCount = items.filter((i) => !i.requiresManualReview).length;
738
+ return { readinessScore, requiredPackages, items, manualReviewCount, autoMigrateCount };
739
+ }
740
+ function formatMigrationReport(analysis) {
741
+ const { readinessScore, requiredPackages, items, manualReviewCount, autoMigrateCount } = analysis;
742
+ const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
743
+ const version = true ? "0.1.1" : "0.1.0";
744
+ let scoreLabel;
745
+ if (readinessScore >= 80) scoreLabel = "\u2713 Your codebase is ready for migration";
746
+ else if (readinessScore >= 50) scoreLabel = "\u26A0 Some manual work required before migration";
747
+ else scoreLabel = "\u2717 Significant refactoring needed";
748
+ const lines = [];
749
+ lines.push(`# OpenFeature Migration Plan`);
750
+ lines.push(`Generated by FlagLint v${version} on ${date}`);
751
+ lines.push("");
752
+ lines.push(`## Migration Readiness Score: ${readinessScore}/100`);
753
+ lines.push(scoreLabel);
754
+ lines.push("");
755
+ lines.push(`**Auto-migratable:** ${autoMigrateCount} usages `);
756
+ lines.push(`**Requires manual review:** ${manualReviewCount} usages`);
757
+ lines.push("");
758
+ lines.push("## Required Packages");
759
+ lines.push("```");
760
+ lines.push(`npm install ${requiredPackages.join(" ")}`);
761
+ lines.push("```");
762
+ lines.push("");
763
+ lines.push("## Step-by-Step Checklist");
764
+ lines.push("- [ ] Install OpenFeature packages");
765
+ lines.push("- [ ] Configure your OpenFeature provider (LaunchDarkly, Unleash, etc.)");
766
+ lines.push("- [ ] Replace LDProvider with OpenFeatureProvider");
767
+ lines.push("- [ ] Update each flag evaluation call (see below)");
768
+ lines.push("- [ ] Remove LaunchDarkly SDK dependency");
769
+ lines.push("- [ ] Test all flagged features");
770
+ lines.push("");
771
+ const autoItems = items.filter((i) => !i.requiresManualReview);
772
+ const manualItems = items.filter((i) => i.requiresManualReview);
773
+ if (autoItems.length > 0) {
774
+ lines.push("## Code Changes Required");
775
+ for (const item of autoItems) {
776
+ const { usage } = item;
777
+ lines.push(`### ${usage.file}:${usage.line} \u2014 \`${usage.flagKey}\``);
778
+ lines.push("**Before:**");
779
+ lines.push("```typescript");
780
+ lines.push(item.codeChangeBefore);
781
+ lines.push("```");
782
+ lines.push("**After:**");
783
+ lines.push("```typescript");
784
+ lines.push(item.codeChangeAfter);
785
+ lines.push("```");
786
+ lines.push("");
787
+ }
788
+ }
789
+ if (manualItems.length > 0) {
790
+ lines.push("## Manual Review Required");
791
+ for (const item of manualItems) {
792
+ const { usage } = item;
793
+ lines.push(`### ${usage.file}:${usage.line} \u2014 \`${usage.flagKey}\``);
794
+ if (item.reviewReason) lines.push(`> ${item.reviewReason}`);
795
+ lines.push("**Before:**");
796
+ lines.push("```typescript");
797
+ lines.push(item.codeChangeBefore);
798
+ lines.push("```");
799
+ lines.push("**After:**");
800
+ lines.push("```typescript");
801
+ lines.push(item.codeChangeAfter);
802
+ lines.push("```");
803
+ lines.push("");
804
+ }
805
+ }
806
+ lines.push("## Resources");
807
+ lines.push("- OpenFeature docs: https://openfeature.dev/docs");
808
+ lines.push(
809
+ "- OpenFeature React SDK: https://openfeature.dev/docs/reference/technologies/client/web/react"
810
+ );
811
+ lines.push("");
812
+ return lines.join("\n");
813
+ }
814
+
815
+ // src/commands/migrate.ts
816
+ function registerMigrateCommand(program2) {
817
+ program2.command("migrate").description("Analyze migration readiness and generate an OpenFeature migration plan").argument("[dir]", "directory to analyze", process.cwd()).option("-o, --output <file>", "write migration plan to file", "MIGRATION.md").option("-c, --config <path>", "path to .flaglintrc config file").option("--dry-run", "print migration plan to stdout without writing file").addHelpText(
818
+ "after",
819
+ `
820
+ Examples:
821
+ $ flaglint migrate generate migration plan for current directory
822
+ $ flaglint migrate ./src analyze specific directory
823
+ $ flaglint migrate --dry-run preview without writing file
824
+ $ flaglint migrate --output plan.md write to custom file`
825
+ ).action(
826
+ async (dir, options) => {
827
+ try {
828
+ const s = await stat2(resolve3(dir));
829
+ if (!s.isDirectory()) {
830
+ process.stderr.write(chalk2.red(`Error: Not a directory: ${dir}
831
+ `));
832
+ process.exit(1);
833
+ }
834
+ } catch (err) {
835
+ const code = err.code;
836
+ if (code === "ENOENT") {
837
+ process.stderr.write(chalk2.red(`Error: Directory not found: ${dir}
838
+ `));
839
+ } else if (code === "EACCES") {
840
+ process.stderr.write(chalk2.red(`Error: Permission denied: ${dir}
841
+ `));
842
+ } else {
843
+ process.stderr.write(chalk2.red(`Error: Cannot access directory: ${dir}
844
+ `));
845
+ }
846
+ process.exit(1);
847
+ }
848
+ let config;
849
+ try {
850
+ config = loadConfig(options.config);
851
+ } catch (err) {
852
+ process.stderr.write(chalk2.red(String(err instanceof Error ? err.message : err)) + "\n");
853
+ process.exit(1);
854
+ }
855
+ const spinner = ora2(`Scanning ${dir}...`).start();
856
+ process.once("SIGINT", () => {
857
+ spinner.stop();
858
+ process.exit(130);
859
+ });
860
+ let scanResult;
861
+ try {
862
+ scanResult = await scan(dir, config);
863
+ spinner.text = "Analyzing migration readiness...";
864
+ } catch (err) {
865
+ spinner.fail("Scan failed");
866
+ process.stderr.write(chalk2.red(String(err)) + "\n");
867
+ process.exit(1);
868
+ }
869
+ if (scanResult.scannedFiles === 0) {
870
+ spinner.stop();
871
+ process.stderr.write(
872
+ chalk2.yellow("No matching files found. Check your .flaglintrc include patterns.\n")
873
+ );
874
+ process.exit(0);
875
+ }
876
+ if (scanResult.totalUsages === 0) {
877
+ spinner.stop();
878
+ process.stderr.write(
879
+ chalk2.dim(
880
+ `No LaunchDarkly SDK usage detected in ${scanResult.scannedFiles} files.
881
+ `
882
+ )
883
+ );
884
+ process.exit(0);
885
+ }
886
+ const analysis = analyze(scanResult);
887
+ spinner.stop();
888
+ for (const w of scanResult.warnings) {
889
+ process.stderr.write(chalk2.yellow(w + "\n"));
890
+ }
891
+ const { readinessScore } = analysis;
892
+ const scoreColor = readinessScore >= 80 ? chalk2.green : readinessScore >= 50 ? chalk2.yellow : chalk2.red;
893
+ process.stderr.write(scoreColor(`Migration Readiness Score: ${readinessScore}/100
894
+ `));
895
+ process.stderr.write(
896
+ chalk2.gray(
897
+ `Auto-migratable: ${analysis.autoMigrateCount} \xB7 Manual review: ${analysis.manualReviewCount}
898
+ `
899
+ )
900
+ );
901
+ const report = formatMigrationReport(analysis);
902
+ if (options.dryRun) {
903
+ process.stdout.write(report + "\n");
904
+ process.exit(0);
905
+ }
906
+ const outPath = resolve3(options.output);
907
+ try {
908
+ await writeFile2(outPath, report, "utf8");
909
+ process.stderr.write(chalk2.green(`Migration plan written to ${options.output}
910
+ `));
911
+ } catch (err) {
912
+ process.stderr.write(
913
+ chalk2.red(
914
+ `Error: Failed to write migration plan to ${options.output}: ${err instanceof Error ? err.message : String(err)}
915
+ `
916
+ )
917
+ );
918
+ process.exit(1);
919
+ }
920
+ process.exit(0);
921
+ }
922
+ );
923
+ }
924
+
925
+ // src/cli.ts
926
+ function createCLI() {
927
+ const program2 = new Command();
928
+ program2.name("flaglint").description("Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.").version("0.1.1", "-v, --version", "output the current version").addHelpText(
929
+ "after",
930
+ `
931
+ Examples:
932
+ $ flaglint scan scan current directory
933
+ $ flaglint scan ./src scan specific directory
934
+ $ flaglint scan --format json output as JSON
935
+ $ flaglint scan --output report.md save to file
936
+ $ flaglint migrate generate migration plan
937
+ $ flaglint migrate --dry-run preview without writing`
938
+ );
939
+ registerScanCommand(program2);
940
+ registerMigrateCommand(program2);
941
+ return program2;
942
+ }
943
+
944
+ // bin/flaglint.ts
945
+ var program = createCLI();
946
+ program.parse(process.argv);
947
+ //# sourceMappingURL=flaglint.js.map