flaglint 0.2.0 → 0.2.2

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,29 @@ 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.2] - 2026-05-23
9
+
10
+ ### Fixed
11
+
12
+ - **Config mutation**: `--exclude-tests` no longer mutates the loaded config object in both `scan` and `migrate` commands — uses spread instead of `push()` so the original config is never modified.
13
+ - **Typed scan warnings**: `ScanResult.warnings` is now a typed `ScanWarning` union (`read-failure` | `parse-failure`) instead of opaque strings, preserving structured data at the domain boundary.
14
+ - **StalenessEvaluator wired**: The `StalenessEvaluator` interface now has a call site in `scan()` — pass an `evaluator` to inject API-based staleness signals without touching core scanner logic.
15
+ - **ScanConfig boundary**: `scan()` now accepts `ScanConfig` (scan-relevant fields only) rather than the full `FlagLintConfig`, decoupling the scanner from CLI output concerns (`reportTitle`, `outputDir`).
16
+
17
+ ## [0.2.1] - 2026-05-23
18
+
19
+ ### Fixed
20
+
21
+ - **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.
22
+
23
+ ### Added
24
+
25
+ - **`wrappers` config option** — detect custom wrapper functions as flag usages. Add your wrapper names to `.flaglintrc`:
26
+ ```json
27
+ { "wrappers": ["flagPredicate", "useFlag", "getFlag", "isEnabled"] }
28
+ ```
29
+ FlagLint will treat calls to these functions as `variation`-equivalent. Supports static and dynamic flag keys. Default is `[]` — no behaviour change for existing users.
30
+
8
31
  ## [0.2.0] - 2026-05-22
9
32
 
10
33
  ### Breaking Changes
package/README.md CHANGED
@@ -118,6 +118,7 @@ Create `.flaglintrc` in your project root:
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
 
@@ -76,7 +76,7 @@ function walk(root, visit) {
76
76
  }
77
77
  }
78
78
  }
79
- function detectUsages(ast, filePath) {
79
+ function detectUsages(ast, filePath, wrappers) {
80
80
  const usages = [];
81
81
  walk(ast, (node) => {
82
82
  if (node.type === "CallExpression") {
@@ -141,6 +141,20 @@ function detectUsages(ast, filePath) {
141
141
  return;
142
142
  }
143
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
+ }
144
158
  if (callee.type === "CallExpression" && callee.callee.type === "Identifier" && callee.callee.name === "withLDConsumer") {
145
159
  const sig = checkStale("*", filePath);
146
160
  usages.push({
@@ -174,7 +188,7 @@ function detectUsages(ast, filePath) {
174
188
  });
175
189
  return usages;
176
190
  }
177
- async function scan(source, config, onProgress) {
191
+ async function scan(source, config, onProgress, evaluator) {
178
192
  const start = Date.now();
179
193
  for (const pattern of config.include) {
180
194
  if (pattern.startsWith("/") || pattern.startsWith("..")) {
@@ -193,7 +207,7 @@ async function scan(source, config, onProgress) {
193
207
  code = await source.readFile(file);
194
208
  } catch (err) {
195
209
  const fsCode = err.code ?? "UNKNOWN";
196
- return { usages: [], warning: `warn: could not read ${file} (${fsCode})` };
210
+ return { usages: [], warning: { kind: "read-failure", file, fsCode } };
197
211
  }
198
212
  let ast;
199
213
  try {
@@ -202,12 +216,13 @@ async function scan(source, config, onProgress) {
202
216
  loc: true,
203
217
  range: false,
204
218
  comment: false,
205
- tokens: false
219
+ tokens: false,
220
+ filePath: file
206
221
  });
207
222
  } catch {
208
- return { usages: [], warning: `warn: failed to parse ${file}` };
223
+ return { usages: [], warning: { kind: "parse-failure", file } };
209
224
  }
210
- return { usages: detectUsages(ast, file), warning: null };
225
+ return { usages: detectUsages(ast, file, config.wrappers), warning: null };
211
226
  }
212
227
  const limit = pLimit(50);
213
228
  const results = await Promise.all(
@@ -246,6 +261,9 @@ async function scan(source, config, onProgress) {
246
261
  }
247
262
  }
248
263
  }
264
+ if (evaluator) {
265
+ await evaluator.evaluate(allUsages, config);
266
+ }
249
267
  const uniqueFlags = [
250
268
  ...new Set(
251
269
  allUsages.filter((u) => !u.isDynamic && u.flagKey !== "*").map((u) => u.flagKey)
@@ -389,7 +407,7 @@ function formatHTML(result, options) {
389
407
  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>`;
390
408
  }).join("\n ");
391
409
  const title = options.title ? esc(options.title) : "FlagLint Scan Report";
392
- const version = true ? "0.2.0" : "0.1.0";
410
+ const version = true ? "0.2.2" : "0.1.0";
393
411
  return `<!DOCTYPE html>
394
412
  <html lang="en">
395
413
  <head>
@@ -486,6 +504,7 @@ var FlagLintConfigSchema = z.object({
486
504
  provider: z.enum(["launchdarkly", "unleash", "growthbook", "custom"]).default("launchdarkly"),
487
505
  // TODO v0.3: replace minFileCount with real date-based staleness via git log
488
506
  minFileCount: z.number().int().min(0).default(1),
507
+ wrappers: z.array(z.string()).default([]),
489
508
  reportTitle: z.string().optional(),
490
509
  outputDir: z.string().default(".")
491
510
  });
@@ -569,16 +588,15 @@ Examples:
569
588
  process.stderr.write(chalk.red(String(err instanceof Error ? err.message : err)) + "\n");
570
589
  process.exit(1);
571
590
  }
572
- if (options.excludeTests) {
573
- config.exclude.push(
574
- "**/*.test.ts",
575
- "**/*.test.tsx",
576
- "**/*.spec.ts",
577
- "**/*.spec.tsx",
578
- "**/__tests__/**",
579
- "**/tests/**"
580
- );
581
- }
591
+ const TEST_PATTERNS = [
592
+ "**/*.test.ts",
593
+ "**/*.test.tsx",
594
+ "**/*.spec.ts",
595
+ "**/*.spec.tsx",
596
+ "**/__tests__/**",
597
+ "**/tests/**"
598
+ ];
599
+ const scanConfig = options.excludeTests ? { ...config, exclude: [...config.exclude, ...TEST_PATTERNS] } : config;
582
600
  const format = options.format;
583
601
  const spinner = ora(`Scanning ${dir}...`).start();
584
602
  process.once("SIGINT", () => {
@@ -588,7 +606,7 @@ Examples:
588
606
  let lastSpinnerUpdate = 0;
589
607
  let result;
590
608
  try {
591
- result = await scan(new LocalFileSource(dir), config, (filesScanned) => {
609
+ result = await scan(new LocalFileSource(dir), scanConfig, (filesScanned) => {
592
610
  if (filesScanned - lastSpinnerUpdate >= 50) {
593
611
  spinner.text = `Scanning... (${filesScanned} files)`;
594
612
  lastSpinnerUpdate = filesScanned;
@@ -601,7 +619,8 @@ Examples:
601
619
  process.exit(1);
602
620
  }
603
621
  for (const w of result.warnings) {
604
- process.stderr.write(chalk.yellow(w + "\n"));
622
+ const msg = w.kind === "read-failure" ? `warn: could not read ${w.file} (${w.fsCode})` : `warn: failed to parse ${w.file}`;
623
+ process.stderr.write(chalk.yellow(msg + "\n"));
605
624
  }
606
625
  if (result.scannedFiles === 0) {
607
626
  process.stderr.write(
@@ -808,7 +827,7 @@ function analyze(result) {
808
827
  function formatMigrationReport(analysis) {
809
828
  const { readinessScore, requiredPackages, items, manualReviewCount, autoMigrateCount } = analysis;
810
829
  const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
811
- const version = true ? "0.2.0" : "0.1.0";
830
+ const version = true ? "0.2.2" : "0.1.0";
812
831
  let scoreLabel;
813
832
  if (readinessScore >= 80) scoreLabel = "\u2713 Your codebase is ready for migration";
814
833
  else if (readinessScore >= 50) scoreLabel = "\u26A0 Some manual work required before migration";
@@ -921,16 +940,15 @@ Examples:
921
940
  process.stderr.write(chalk2.red(String(err instanceof Error ? err.message : err)) + "\n");
922
941
  process.exit(1);
923
942
  }
924
- if (options.excludeTests) {
925
- config.exclude.push(
926
- "**/*.test.ts",
927
- "**/*.test.tsx",
928
- "**/*.spec.ts",
929
- "**/*.spec.tsx",
930
- "**/__tests__/**",
931
- "**/tests/**"
932
- );
933
- }
943
+ const TEST_PATTERNS = [
944
+ "**/*.test.ts",
945
+ "**/*.test.tsx",
946
+ "**/*.spec.ts",
947
+ "**/*.spec.tsx",
948
+ "**/__tests__/**",
949
+ "**/tests/**"
950
+ ];
951
+ const scanConfig = options.excludeTests ? { ...config, exclude: [...config.exclude, ...TEST_PATTERNS] } : config;
934
952
  const spinner = ora2(`Scanning ${dir}...`).start();
935
953
  process.once("SIGINT", () => {
936
954
  spinner.stop();
@@ -938,7 +956,7 @@ Examples:
938
956
  });
939
957
  let scanResult;
940
958
  try {
941
- scanResult = await scan(new LocalFileSource(dir), config, (filesScanned) => {
959
+ scanResult = await scan(new LocalFileSource(dir), scanConfig, (filesScanned) => {
942
960
  spinner.text = `Scanning files... ${filesScanned}`;
943
961
  });
944
962
  spinner.text = "Analyzing migration readiness...";
@@ -967,7 +985,8 @@ Examples:
967
985
  const analysis = analyze(scanResult);
968
986
  spinner.stop();
969
987
  for (const w of scanResult.warnings) {
970
- process.stderr.write(chalk2.yellow(w + "\n"));
988
+ const msg = w.kind === "read-failure" ? `warn: could not read ${w.file} (${w.fsCode})` : `warn: failed to parse ${w.file}`;
989
+ process.stderr.write(chalk2.yellow(msg + "\n"));
971
990
  }
972
991
  const { readinessScore } = analysis;
973
992
  const scoreColor = readinessScore >= 80 ? chalk2.green : readinessScore >= 50 ? chalk2.yellow : chalk2.red;
@@ -1006,7 +1025,7 @@ Examples:
1006
1025
  // src/cli.ts
1007
1026
  function createCLI() {
1008
1027
  const program2 = new Command();
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(
1028
+ program2.name("flaglint").description("Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.").version("0.2.2", "-v, --version", "output the current version").addHelpText(
1010
1029
  "after",
1011
1030
  `
1012
1031
  Examples:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flaglint",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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"