flaglint 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/commands/scan.ts
7
- import { writeFile } from "fs/promises";
7
+ import { writeFile as writeFile2 } from "fs/promises";
8
8
  import { stat } from "fs/promises";
9
9
  import { resolve as resolve4 } from "path";
10
10
  import chalk from "chalk";
@@ -36,8 +36,23 @@ function staleReason(u) {
36
36
  }
37
37
 
38
38
  // src/scanner/index.ts
39
- var LD_MEMBER_METHODS = /* @__PURE__ */ new Set(["variation", "variationDetail", "allFlags"]);
40
- var LD_CLIENT_PATTERN = /^ld|client/i;
39
+ var LD_NODE_SERVER_PACKAGES = /* @__PURE__ */ new Set([
40
+ "launchdarkly-node-server-sdk",
41
+ "@launchdarkly/node-server-sdk"
42
+ ]);
43
+ var LD_FLAG_KEY_METHODS = /* @__PURE__ */ new Set([
44
+ "variation",
45
+ "variationDetail",
46
+ "boolVariation",
47
+ "boolVariationDetail",
48
+ "stringVariation",
49
+ "stringVariationDetail",
50
+ "numberVariation",
51
+ "numberVariationDetail",
52
+ "jsonVariation",
53
+ "jsonVariationDetail"
54
+ ]);
55
+ var LD_ALL_FLAGS_METHODS = /* @__PURE__ */ new Set(["allFlags", "allFlagsState"]);
41
56
  var LD_HOOKS = /* @__PURE__ */ new Set(["useFlags", "useLDClient"]);
42
57
  var DEFAULT_EXCLUDE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.next/**"];
43
58
  function extractFlagKey(arg) {
@@ -51,6 +66,80 @@ function extractFlagKey(arg) {
51
66
  }
52
67
  return { flagKey: "dynamic", isDynamic: true };
53
68
  }
69
+ function expressionText(code, node) {
70
+ const range = node?.range;
71
+ if (!range) return void 0;
72
+ return code.slice(range[0], range[1]);
73
+ }
74
+ function expressionRange(node) {
75
+ return node?.range;
76
+ }
77
+ function isAwaitedCall(code, call) {
78
+ const range = expressionRange(call);
79
+ if (!range) return false;
80
+ let i = range[0] - 1;
81
+ while (i >= 0 && /\s/.test(code[i])) i--;
82
+ const end = i + 1;
83
+ while (i >= 0 && /[A-Za-z_$]/.test(code[i])) i--;
84
+ return code.slice(i + 1, end) === "await";
85
+ }
86
+ function inferValueType(methodName, fallback) {
87
+ if (methodName === "boolVariation" || methodName === "boolVariationDetail") return "boolean";
88
+ if (methodName === "stringVariation" || methodName === "stringVariationDetail") return "string";
89
+ if (methodName === "numberVariation" || methodName === "numberVariationDetail") return "number";
90
+ if (methodName === "jsonVariation" || methodName === "jsonVariationDetail") return "object";
91
+ if (!fallback) return "unknown";
92
+ if (fallback.type === "Literal") {
93
+ const value = fallback.value;
94
+ if (typeof value === "boolean") return "boolean";
95
+ if (typeof value === "string") return "string";
96
+ if (typeof value === "number") return "number";
97
+ return "unknown";
98
+ }
99
+ if (fallback.type === "ObjectExpression" || fallback.type === "ArrayExpression") return "object";
100
+ return "unknown";
101
+ }
102
+ function buildMigrationInventoryItem(code, filePath, loc, call, methodName, args, flagKey, isDynamic) {
103
+ const callRange = expressionRange(call);
104
+ if (LD_ALL_FLAGS_METHODS.has(methodName)) {
105
+ return {
106
+ file: filePath,
107
+ line: loc.line,
108
+ column: loc.column,
109
+ launchDarklyMethod: methodName,
110
+ callExpression: expressionText(code, call),
111
+ rangeStart: callRange?.[0],
112
+ rangeEnd: callRange?.[1],
113
+ isAwaited: isAwaitedCall(code, call),
114
+ isDynamic: false,
115
+ valueType: "unknown",
116
+ evaluationContextExpression: expressionText(code, args[0]),
117
+ safelyAutomatable: false,
118
+ manualReviewReason: "bulk-inventory-call"
119
+ };
120
+ }
121
+ const fallback = args[2];
122
+ const valueType = inferValueType(methodName, fallback);
123
+ const manualReviewReason = isDynamic ? "dynamic-key" : valueType === "unknown" ? "unknown-fallback" : void 0;
124
+ return {
125
+ file: filePath,
126
+ line: loc.line,
127
+ column: loc.column,
128
+ launchDarklyMethod: methodName,
129
+ callExpression: expressionText(code, call),
130
+ rangeStart: callRange?.[0],
131
+ rangeEnd: callRange?.[1],
132
+ isAwaited: isAwaitedCall(code, call),
133
+ flagKeyExpression: expressionText(code, args[0]),
134
+ staticFlagKey: isDynamic ? void 0 : flagKey,
135
+ isDynamic,
136
+ valueType,
137
+ fallbackExpression: expressionText(code, fallback),
138
+ evaluationContextExpression: expressionText(code, args[1]),
139
+ safelyAutomatable: manualReviewReason == null,
140
+ manualReviewReason
141
+ };
142
+ }
54
143
  function walk(root, visit) {
55
144
  if (!root || typeof root !== "object") return;
56
145
  const stack = [root];
@@ -76,16 +165,62 @@ function walk(root, visit) {
76
165
  }
77
166
  }
78
167
  }
79
- function detectUsages(ast, filePath, wrappers) {
168
+ function collectLDClients(ast) {
169
+ const ldNamespaces = /* @__PURE__ */ new Set();
170
+ for (const stmt of ast.body) {
171
+ if (stmt.type === "ImportDeclaration") {
172
+ const importDecl = stmt;
173
+ if (LD_NODE_SERVER_PACKAGES.has(importDecl.source.value)) {
174
+ for (const spec of importDecl.specifiers) {
175
+ if (spec.type === "ImportNamespaceSpecifier" || spec.type === "ImportDefaultSpecifier") {
176
+ ldNamespaces.add(spec.local.name);
177
+ }
178
+ }
179
+ }
180
+ continue;
181
+ }
182
+ if (stmt.type === "VariableDeclaration") {
183
+ const varDecl = stmt;
184
+ for (const decl of varDecl.declarations) {
185
+ if (decl.id.type !== "Identifier" || !decl.init) continue;
186
+ const init = decl.init;
187
+ if (init.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "require" && init.arguments.length >= 1 && init.arguments[0].type === "Literal" && LD_NODE_SERVER_PACKAGES.has(
188
+ init.arguments[0].value
189
+ )) {
190
+ ldNamespaces.add(decl.id.name);
191
+ }
192
+ }
193
+ }
194
+ }
195
+ if (ldNamespaces.size === 0) return /* @__PURE__ */ new Set();
196
+ const ldClients = /* @__PURE__ */ new Set();
197
+ walk(ast, (node) => {
198
+ if (node.type !== "VariableDeclaration") return;
199
+ const varDecl = node;
200
+ for (const decl of varDecl.declarations) {
201
+ if (decl.id.type !== "Identifier" || !decl.init || decl.init.type !== "CallExpression") continue;
202
+ const initCall = decl.init;
203
+ if (initCall.callee.type !== "MemberExpression" || initCall.callee.computed) continue;
204
+ const initCallee = initCall.callee;
205
+ if (initCallee.object.type === "Identifier" && initCallee.property.type === "Identifier" && ldNamespaces.has(initCallee.object.name) && initCallee.property.name === "init") {
206
+ ldClients.add(decl.id.name);
207
+ }
208
+ }
209
+ });
210
+ return ldClients;
211
+ }
212
+ function detectUsages(ast, code, filePath, wrappers) {
80
213
  const usages = [];
214
+ const migrationInventory = [];
215
+ const ldClients = collectLDClients(ast);
81
216
  walk(ast, (node) => {
82
217
  if (node.type === "CallExpression") {
83
218
  const call = node;
84
219
  const { callee } = call;
85
220
  const loc = call.loc?.start ?? { line: 0, column: 0 };
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)) {
87
- const method = callee.property.name;
88
- if (method === "allFlags") {
221
+ if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.property.type === "Identifier" && ldClients.has(callee.object.name)) {
222
+ const methodName = callee.property.name;
223
+ if (LD_ALL_FLAGS_METHODS.has(methodName)) {
89
224
  const sig = checkStale("*", filePath);
90
225
  usages.push({
91
226
  flagKey: "*",
@@ -93,10 +228,15 @@ function detectUsages(ast, filePath, wrappers) {
93
228
  file: filePath,
94
229
  line: loc.line,
95
230
  column: loc.column,
96
- callType: "allFlags",
231
+ callType: methodName,
97
232
  stalenessSignals: sig ? [sig] : []
98
233
  });
99
- } else {
234
+ migrationInventory.push(
235
+ buildMigrationInventoryItem(code, filePath, loc, call, methodName, call.arguments, "*", false)
236
+ );
237
+ return;
238
+ }
239
+ if (LD_FLAG_KEY_METHODS.has(methodName)) {
100
240
  const { flagKey, isDynamic } = extractFlagKey(call.arguments[0]);
101
241
  const sig = checkStale(flagKey, filePath);
102
242
  usages.push({
@@ -105,9 +245,13 @@ function detectUsages(ast, filePath, wrappers) {
105
245
  file: filePath,
106
246
  line: loc.line,
107
247
  column: loc.column,
108
- callType: method,
248
+ callType: methodName,
109
249
  stalenessSignals: sig ? [sig] : []
110
250
  });
251
+ migrationInventory.push(
252
+ buildMigrationInventoryItem(code, filePath, loc, call, methodName, call.arguments, flagKey, isDynamic)
253
+ );
254
+ return;
111
255
  }
112
256
  return;
113
257
  }
@@ -186,7 +330,7 @@ function detectUsages(ast, filePath, wrappers) {
186
330
  }
187
331
  }
188
332
  });
189
- return usages;
333
+ return { usages, migrationInventory };
190
334
  }
191
335
  async function scan(source, config, onProgress, evaluator) {
192
336
  const start = Date.now();
@@ -200,6 +344,7 @@ async function scan(source, config, onProgress, evaluator) {
200
344
  }
201
345
  const files = await source.listFiles(config.include, config.exclude);
202
346
  const allUsages = [];
347
+ const migrationInventory = [];
203
348
  const warnings = [];
204
349
  let scannedFiles = 0;
205
350
  async function processFile(file) {
@@ -208,22 +353,22 @@ async function scan(source, config, onProgress, evaluator) {
208
353
  code = await source.readFile(file);
209
354
  } catch (err) {
210
355
  const fsCode = err.code ?? "UNKNOWN";
211
- return { usages: [], warning: { kind: "read-failure", file, fsCode } };
356
+ return { usages: [], migrationInventory: [], warning: { kind: "read-failure", file, fsCode } };
212
357
  }
213
358
  let ast;
214
359
  try {
215
360
  ast = parse(code, {
216
361
  jsx: true,
217
362
  loc: true,
218
- range: false,
363
+ range: true,
219
364
  comment: false,
220
365
  tokens: false,
221
366
  filePath: file
222
367
  });
223
368
  } catch {
224
- return { usages: [], warning: { kind: "parse-failure", file } };
369
+ return { usages: [], migrationInventory: [], warning: { kind: "parse-failure", file } };
225
370
  }
226
- return { usages: detectUsages(ast, file, config.wrappers), warning: null };
371
+ return { ...detectUsages(ast, code, file, config.wrappers), warning: null };
227
372
  }
228
373
  const limit = pLimit(50);
229
374
  const results = await Promise.all(
@@ -237,6 +382,7 @@ async function scan(source, config, onProgress, evaluator) {
237
382
  );
238
383
  for (const r of results) {
239
384
  allUsages.push(...r.usages);
385
+ migrationInventory.push(...r.migrationInventory);
240
386
  if (r.warning) warnings.push(r.warning);
241
387
  }
242
388
  if (config.minFileCount > 0) {
@@ -277,6 +423,7 @@ async function scan(source, config, onProgress, evaluator) {
277
423
  totalUsages: allUsages.length,
278
424
  uniqueFlags,
279
425
  usages: allUsages,
426
+ migrationInventory,
280
427
  scanDurationMs: Date.now() - start,
281
428
  warnings
282
429
  };
@@ -284,7 +431,7 @@ async function scan(source, config, onProgress, evaluator) {
284
431
 
285
432
  // src/scanner/local-source.ts
286
433
  import fg from "fast-glob";
287
- import { readFile } from "fs/promises";
434
+ import { readFile, writeFile } from "fs/promises";
288
435
  import { join, relative, resolve } from "path";
289
436
  var LocalFileSource = class {
290
437
  constructor(dir) {
@@ -305,6 +452,9 @@ var LocalFileSource = class {
305
452
  async readFile(path) {
306
453
  return readFile(join(this.dir, path), "utf8");
307
454
  }
455
+ async writeFile(path, content) {
456
+ await writeFile(join(this.dir, path), content, "utf8");
457
+ }
308
458
  };
309
459
 
310
460
  // src/reporter/index.ts
@@ -531,7 +681,7 @@ function formatHTML(result, options) {
531
681
  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>`;
532
682
  }).join("\n ");
533
683
  const title = options.title ? esc(options.title) : "FlagLint Scan Report";
534
- const version = true ? "0.3.0" : "0.1.0";
684
+ const version = true ? "0.4.0" : "0.1.0";
535
685
  return `<!DOCTYPE html>
536
686
  <html lang="en">
537
687
  <head>
@@ -790,7 +940,7 @@ Examples:
790
940
  if (options.output) {
791
941
  const outPath = resolve4(options.output);
792
942
  try {
793
- await writeFile(outPath, report, "utf8");
943
+ await writeFile2(outPath, report, "utf8");
794
944
  process.stderr.write(chalk.dim(` Report written to ${options.output}
795
945
  `));
796
946
  } catch (err) {
@@ -811,234 +961,598 @@ Examples:
811
961
  }
812
962
 
813
963
  // src/commands/migrate.ts
814
- import { writeFile as writeFile2 } from "fs/promises";
964
+ import { writeFile as writeFile3 } from "fs/promises";
815
965
  import { stat as stat2 } from "fs/promises";
816
966
  import { resolve as resolve5 } from "path";
817
967
  import chalk2 from "chalk";
818
968
  import ora2 from "ora";
819
969
 
820
970
  // src/migrator/index.ts
821
- function keyLiteral(usage) {
822
- return usage.isDynamic ? "flagKey" : `'${usage.flagKey}'`;
971
+ function legacyInventoryFromUsage(usage) {
972
+ const isBulk = usage.flagKey === "*" || usage.callType === "allFlags" || usage.callType === "allFlagsState";
973
+ const manualReviewReason = isBulk ? "bulk-inventory-call" : usage.isDynamic ? "dynamic-key" : "unknown-fallback";
974
+ return {
975
+ file: usage.file,
976
+ line: usage.line,
977
+ column: usage.column,
978
+ launchDarklyMethod: usage.callType,
979
+ flagKeyExpression: isBulk ? void 0 : usage.isDynamic ? "flagKey" : `'${usage.flagKey}'`,
980
+ staticFlagKey: usage.isDynamic || isBulk ? void 0 : usage.flagKey,
981
+ isDynamic: usage.isDynamic,
982
+ valueType: "unknown",
983
+ safelyAutomatable: false,
984
+ manualReviewReason
985
+ };
823
986
  }
824
- function buildItem(usage) {
825
- const k = keyLiteral(usage);
826
- if (usage.isDynamic) {
827
- const isDetail = usage.callType === "variationDetail";
828
- const methodName = isDetail ? "variationDetail" : "variation";
829
- return {
830
- usage,
831
- openFeatureEquivalent: isDetail ? "client.getBooleanDetails()" : "client.getBooleanValue()",
832
- codeChangeBefore: `ldClient.${methodName}(flagKey, context, false)`,
833
- codeChangeAfter: isDetail ? `await client.getBooleanDetails(flagKey, false) // server SDK is async` : `await client.getBooleanValue(flagKey, false) // server SDK is async`,
834
- requiresManualReview: true,
835
- reviewReason: "Flag key determined at runtime; OpenFeature server SDK methods are async \u2014 add await and make the enclosing function async"
836
- };
987
+ function inventoryFrom(result) {
988
+ if (result.migrationInventory) return result.migrationInventory;
989
+ return result.usages.map(legacyInventoryFromUsage);
990
+ }
991
+ function isServerInventoryItem(item) {
992
+ return item.launchDarklyMethod === "variation" || item.launchDarklyMethod === "variationDetail" || item.launchDarklyMethod === "boolVariation" || item.launchDarklyMethod === "boolVariationDetail" || item.launchDarklyMethod === "stringVariation" || item.launchDarklyMethod === "stringVariationDetail" || item.launchDarklyMethod === "numberVariation" || item.launchDarklyMethod === "numberVariationDetail" || item.launchDarklyMethod === "jsonVariation" || item.launchDarklyMethod === "jsonVariationDetail" || item.launchDarklyMethod === "allFlags" || item.launchDarklyMethod === "allFlagsState";
993
+ }
994
+ function openFeatureMethod(valueType) {
995
+ switch (valueType) {
996
+ case "boolean":
997
+ return "client.getBooleanValue()";
998
+ case "string":
999
+ return "client.getStringValue()";
1000
+ case "number":
1001
+ return "client.getNumberValue()";
1002
+ case "object":
1003
+ return "client.getObjectValue()";
1004
+ case "unknown":
1005
+ return null;
837
1006
  }
838
- switch (usage.callType) {
839
- case "variation":
840
- return {
841
- usage,
842
- openFeatureEquivalent: "client.getBooleanValue()",
843
- codeChangeBefore: `ldClient.variation(${k}, context, false)`,
844
- codeChangeAfter: `await client.getBooleanValue(${k}, false) // server SDK is async`,
845
- requiresManualReview: true,
846
- reviewReason: "OpenFeature server SDK methods are async \u2014 add await and make the enclosing function async"
847
- };
848
- case "variationDetail":
849
- return {
850
- usage,
851
- openFeatureEquivalent: "client.getBooleanDetails()",
852
- codeChangeBefore: `ldClient.variationDetail(${k}, context, false)`,
853
- codeChangeAfter: `await client.getBooleanDetails(${k}, false) // server SDK is async`,
854
- requiresManualReview: true,
855
- reviewReason: "OpenFeature server SDK methods are async \u2014 add await and make the enclosing function async"
856
- };
857
- case "allFlags":
858
- return {
859
- usage,
860
- openFeatureEquivalent: null,
861
- codeChangeBefore: `ldClient.allFlags(context)`,
862
- codeChangeAfter: `// No direct OpenFeature equivalent \u2014 requires manual implementation`,
863
- requiresManualReview: true,
864
- reviewReason: "allFlags() has no direct OpenFeature equivalent"
865
- };
866
- case "isFeatureEnabled":
867
- return {
868
- usage,
869
- openFeatureEquivalent: "client.getBooleanValue()",
870
- codeChangeBefore: `isFeatureEnabled(${k}, context)`,
871
- codeChangeAfter: `await client.getBooleanValue(${k}, false) // server SDK is async`,
872
- requiresManualReview: true,
873
- reviewReason: "OpenFeature server SDK methods are async \u2014 add await and make the enclosing function async"
874
- };
875
- case "hook-useFlags":
876
- return {
877
- usage,
878
- openFeatureEquivalent: "useBooleanFlagValue()",
879
- codeChangeBefore: `const flags = useFlags()`,
880
- codeChangeAfter: `const flagValue = useBooleanFlagValue('your-flag-key', false) // TODO: one hook call per flag`,
881
- requiresManualReview: true,
882
- reviewReason: "useFlags() returns all flags; OpenFeature requires one useBooleanFlagValue() call per flag"
883
- };
884
- case "hook-useLDClient":
885
- return {
886
- usage,
887
- openFeatureEquivalent: "useOpenFeatureClient()",
888
- codeChangeBefore: `const client = useLDClient()`,
889
- codeChangeAfter: `const client = useOpenFeatureClient()`,
890
- requiresManualReview: false
891
- };
892
- case "hoc":
893
- return {
894
- usage,
895
- openFeatureEquivalent: null,
896
- codeChangeBefore: `withLDConsumer()(Component)`,
897
- codeChangeAfter: `// withOpenFeature() does not exist in OpenFeature SDK 0.4+
898
- // Convert to a functional component and use useBooleanFlagValue() instead`,
899
- requiresManualReview: true,
900
- reviewReason: "withOpenFeature() HOC does not exist in OpenFeature SDK 0.4+; convert to a functional component with hooks"
901
- };
902
- case "provider":
903
- return {
904
- usage,
905
- openFeatureEquivalent: "OpenFeatureProvider",
906
- codeChangeBefore: `<LDProvider clientSideID="...">`,
907
- codeChangeAfter: `<OpenFeatureProvider provider={...}>`,
908
- requiresManualReview: false
909
- };
1007
+ }
1008
+ function reasonLabel(reason) {
1009
+ switch (reason) {
1010
+ case "dynamic-key":
1011
+ return "dynamic key";
1012
+ case "unknown-fallback":
1013
+ return "unsupported or unknown fallback";
1014
+ case "bulk-inventory-call":
1015
+ return "bulk inventory call";
910
1016
  default:
911
- throw new Error(`Unhandled callType: ${usage.callType}`);
1017
+ return "manual review required";
912
1018
  }
913
1019
  }
914
- function calcReadinessScore(usages) {
915
- let score = 100;
916
- const dynamicCount = usages.filter((u) => u.isDynamic).length;
917
- score -= Math.min(dynamicCount * 10, 40);
918
- const useFlagsCount = usages.filter((u) => u.callType === "hook-useFlags").length;
919
- score -= useFlagsCount * 5;
920
- const hasAllFlags = usages.some((u) => u.callType === "allFlags");
921
- if (hasAllFlags) score -= 15;
922
- const hocCount = usages.filter((u) => u.callType === "hoc").length;
923
- score -= hocCount * 5;
924
- const hasStaticKeys = usages.some((u) => !u.isDynamic && u.flagKey !== "*");
925
- if (!hasStaticKeys) score -= 20;
926
- return Math.max(0, score);
1020
+ function buildEvidenceItem(item) {
1021
+ const flagLabel = item.staticFlagKey ?? item.flagKeyExpression ?? "*";
1022
+ const beforeArgs = [
1023
+ item.flagKeyExpression,
1024
+ item.evaluationContextExpression,
1025
+ item.fallbackExpression
1026
+ ].filter((value) => Boolean(value));
1027
+ const before = `${item.launchDarklyMethod}(${beforeArgs.join(", ")})`;
1028
+ const equivalent = item.safelyAutomatable ? openFeatureMethod(item.valueType) : null;
1029
+ return {
1030
+ usage: {
1031
+ flagKey: item.staticFlagKey ?? (item.isDynamic ? "dynamic" : "*"),
1032
+ isDynamic: item.isDynamic,
1033
+ file: item.file,
1034
+ line: item.line,
1035
+ column: item.column,
1036
+ callType: item.launchDarklyMethod,
1037
+ stalenessSignals: []
1038
+ },
1039
+ openFeatureEquivalent: equivalent,
1040
+ codeChangeBefore: before,
1041
+ codeChangeAfter: "// No codemod generated by this report",
1042
+ requiresManualReview: !item.safelyAutomatable,
1043
+ reviewReason: item.safelyAutomatable ? void 0 : reasonLabel(item.manualReviewReason)
1044
+ };
927
1045
  }
928
- function calcRequiredPackages(usages) {
929
- const REACT_CALL_TYPES = ["hook-useFlags", "hook-useLDClient", "hoc", "provider"];
930
- const SERVER_CALL_TYPES = ["variation", "variationDetail", "allFlags", "isFeatureEnabled"];
931
- const hasReactUsage = usages.some((u) => REACT_CALL_TYPES.includes(u.callType));
932
- const hasServerUsage = usages.some((u) => SERVER_CALL_TYPES.includes(u.callType));
933
- const pkgs = /* @__PURE__ */ new Set();
934
- if (hasReactUsage && !hasServerUsage) {
935
- pkgs.add("@openfeature/web-sdk");
936
- pkgs.add("@openfeature/react-sdk");
937
- } else if (hasReactUsage && hasServerUsage) {
938
- pkgs.add("@openfeature/server-sdk");
939
- pkgs.add("@openfeature/web-sdk");
940
- pkgs.add("@openfeature/react-sdk");
941
- } else {
942
- pkgs.add("@openfeature/server-sdk");
943
- }
944
- return [...pkgs].sort();
1046
+ function calcReadinessScore(items) {
1047
+ if (items.length === 0) return 0;
1048
+ const safeCount = items.filter((item) => item.safelyAutomatable).length;
1049
+ const score = Math.round(safeCount / items.length * 100);
1050
+ return safeCount === items.length ? 100 : Math.min(score, 99);
1051
+ }
1052
+ function calcRequiredPackages(items) {
1053
+ return items.some(isServerInventoryItem) ? ["@openfeature/server-sdk"] : [];
945
1054
  }
946
1055
  function analyze(result) {
947
- const items = result.usages.map(buildItem);
948
- const readinessScore = calcReadinessScore(result.usages);
949
- const requiredPackages = calcRequiredPackages(result.usages);
950
- const manualReviewCount = items.filter((i) => i.requiresManualReview).length;
951
- const autoMigrateCount = items.filter((i) => !i.requiresManualReview).length;
952
- return { readinessScore, requiredPackages, items, manualReviewCount, autoMigrateCount };
1056
+ const inventoryItems = inventoryFrom(result);
1057
+ const items = inventoryItems.map(buildEvidenceItem);
1058
+ const totalLaunchDarklyUsages = inventoryItems.length;
1059
+ const safelyAutomatableCount = inventoryItems.filter((item) => item.safelyAutomatable).length;
1060
+ const dynamicKeyCount = inventoryItems.filter((item) => item.manualReviewReason === "dynamic-key").length;
1061
+ const bulkInventoryCallCount = inventoryItems.filter(
1062
+ (item) => item.manualReviewReason === "bulk-inventory-call"
1063
+ ).length;
1064
+ const unsupportedUnknownCount = inventoryItems.filter(
1065
+ (item) => item.manualReviewReason === "unknown-fallback"
1066
+ ).length;
1067
+ const manualReviewCount = totalLaunchDarklyUsages - safelyAutomatableCount;
1068
+ const autoMigrateCount = safelyAutomatableCount;
1069
+ const readinessScore = calcReadinessScore(inventoryItems);
1070
+ const requiredPackages = calcRequiredPackages(inventoryItems);
1071
+ return {
1072
+ readinessScore,
1073
+ requiredPackages,
1074
+ items,
1075
+ inventoryItems,
1076
+ totalLaunchDarklyUsages,
1077
+ safelyAutomatableCount,
1078
+ dynamicKeyCount,
1079
+ bulkInventoryCallCount,
1080
+ unsupportedUnknownCount,
1081
+ manualReviewCount,
1082
+ autoMigrateCount
1083
+ };
953
1084
  }
954
1085
  function formatMigrationReport(analysis) {
955
- const { readinessScore, requiredPackages, items, manualReviewCount, autoMigrateCount } = analysis;
1086
+ const {
1087
+ requiredPackages,
1088
+ inventoryItems,
1089
+ totalLaunchDarklyUsages,
1090
+ safelyAutomatableCount,
1091
+ manualReviewCount,
1092
+ dynamicKeyCount,
1093
+ bulkInventoryCallCount,
1094
+ unsupportedUnknownCount
1095
+ } = analysis;
956
1096
  const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
957
- const version = true ? "0.3.0" : "0.1.0";
958
- let scoreLabel;
959
- if (readinessScore >= 80) scoreLabel = "\u2713 Your codebase is ready for migration";
960
- else if (readinessScore >= 50) scoreLabel = "\u26A0 Some manual work required before migration";
961
- else scoreLabel = "\u2717 Significant refactoring needed";
1097
+ const version = true ? "0.4.0" : "0.1.0";
962
1098
  const lines = [];
963
- lines.push(`# OpenFeature Migration Plan`);
1099
+ lines.push("# OpenFeature Migration Inventory");
964
1100
  lines.push(`Generated by FlagLint v${version} on ${date}`);
965
1101
  lines.push("");
966
- lines.push(`## Migration Readiness Score: ${readinessScore}/100`);
967
- lines.push(scoreLabel);
968
- lines.push("");
969
- lines.push(`**Auto-migratable:** ${autoMigrateCount} usages `);
970
- lines.push(`**Requires manual review:** ${manualReviewCount} usages`);
971
- lines.push("");
972
- lines.push("## Required Packages");
973
- lines.push("```");
974
- lines.push(`npm install ${requiredPackages.join(" ")}`);
975
- lines.push("```");
1102
+ lines.push("## Evidence Summary");
1103
+ lines.push(`**Total LaunchDarkly usages found:** ${totalLaunchDarklyUsages} `);
1104
+ lines.push(`**Safely automatable usages:** ${safelyAutomatableCount} `);
1105
+ lines.push(`**Manual review required:** ${manualReviewCount} `);
1106
+ lines.push(`**Dynamic keys:** ${dynamicKeyCount} `);
1107
+ lines.push(`**Bulk inventory calls:** ${bulkInventoryCallCount} `);
1108
+ lines.push(`**Unsupported/unknown cases:** ${unsupportedUnknownCount}`);
976
1109
  lines.push("");
977
- lines.push("## Step-by-Step Checklist");
978
- lines.push("- [ ] Install OpenFeature packages");
979
- lines.push("- [ ] Configure your OpenFeature provider (LaunchDarkly, Unleash, etc.)");
980
- lines.push("- [ ] Replace LDProvider with OpenFeatureProvider");
981
- lines.push("- [ ] Update each flag evaluation call (see below)");
982
- lines.push("- [ ] Remove LaunchDarkly SDK dependency");
983
- lines.push("- [ ] Test all flagged features");
984
- lines.push("");
985
- const autoItems = items.filter((i) => !i.requiresManualReview);
986
- const manualItems = items.filter((i) => i.requiresManualReview);
987
- if (autoItems.length > 0) {
988
- lines.push("## Code Changes Required");
989
- for (const item of autoItems) {
990
- const { usage } = item;
991
- lines.push(`### ${usage.file}:${usage.line} \u2014 \`${usage.flagKey}\``);
992
- lines.push("**Before:**");
993
- lines.push("```typescript");
994
- lines.push(item.codeChangeBefore);
995
- lines.push("```");
996
- lines.push("**After:**");
997
- lines.push("```typescript");
998
- lines.push(item.codeChangeAfter);
999
- lines.push("```");
1000
- lines.push("");
1110
+ if (manualReviewCount > 0) {
1111
+ lines.push(
1112
+ "> This report is an inventory, not a codemod. Manual-review items must be resolved before treating the migration as safe."
1113
+ );
1114
+ lines.push("");
1115
+ }
1116
+ if (requiredPackages.length > 0) {
1117
+ lines.push("## OpenFeature Packages To Evaluate");
1118
+ lines.push("```");
1119
+ lines.push(`npm install ${requiredPackages.join(" ")}`);
1120
+ lines.push("```");
1121
+ lines.push("");
1122
+ }
1123
+ const safeItems = inventoryItems.filter((item) => item.safelyAutomatable);
1124
+ const manualItems = inventoryItems.filter((item) => !item.safelyAutomatable);
1125
+ if (safeItems.length > 0) {
1126
+ lines.push("## Safely Automatable Inventory");
1127
+ for (const item of safeItems) {
1128
+ const flag = item.staticFlagKey ?? item.flagKeyExpression ?? "unknown";
1129
+ lines.push(
1130
+ `- ${item.file}:${item.line}:${item.column} \u2014 \`${flag}\` via \`${item.launchDarklyMethod}\` (${item.valueType})`
1131
+ );
1132
+ lines.push(
1133
+ ` context: \`${item.evaluationContextExpression ?? "unknown"}\`; fallback: \`${item.fallbackExpression ?? "unknown"}\``
1134
+ );
1001
1135
  }
1136
+ lines.push("");
1002
1137
  }
1003
1138
  if (manualItems.length > 0) {
1004
1139
  lines.push("## Manual Review Required");
1005
1140
  for (const item of manualItems) {
1006
- const { usage } = item;
1007
- lines.push(`### ${usage.file}:${usage.line} \u2014 \`${usage.flagKey}\``);
1008
- if (item.reviewReason) lines.push(`> ${item.reviewReason}`);
1009
- lines.push("**Before:**");
1010
- lines.push("```typescript");
1011
- lines.push(item.codeChangeBefore);
1012
- lines.push("```");
1013
- lines.push("**After:**");
1014
- lines.push("```typescript");
1015
- lines.push(item.codeChangeAfter);
1016
- lines.push("```");
1017
- lines.push("");
1141
+ const flag = item.staticFlagKey ?? item.flagKeyExpression ?? "*";
1142
+ lines.push(
1143
+ `- ${item.file}:${item.line}:${item.column} \u2014 \`${flag}\` via \`${item.launchDarklyMethod}\`: ${reasonLabel(item.manualReviewReason)}`
1144
+ );
1145
+ if (item.evaluationContextExpression) lines.push(` context: \`${item.evaluationContextExpression}\``);
1146
+ if (item.fallbackExpression) lines.push(` fallback: \`${item.fallbackExpression}\``);
1018
1147
  }
1148
+ lines.push("");
1019
1149
  }
1020
1150
  lines.push("## Resources");
1021
1151
  lines.push("- OpenFeature docs: https://openfeature.dev/docs");
1152
+ lines.push("- OpenFeature server SDK: https://openfeature.dev/docs/reference/technologies/server/javascript");
1153
+ lines.push("");
1154
+ return lines.join("\n");
1155
+ }
1156
+
1157
+ // src/migrator/dry-run.ts
1158
+ var DETAIL_METHODS = /* @__PURE__ */ new Set([
1159
+ "variationDetail",
1160
+ "boolVariationDetail",
1161
+ "stringVariationDetail",
1162
+ "numberVariationDetail",
1163
+ "jsonVariationDetail"
1164
+ ]);
1165
+ function methodForType(valueType) {
1166
+ switch (valueType) {
1167
+ case "boolean":
1168
+ return "getBooleanValue";
1169
+ case "string":
1170
+ return "getStringValue";
1171
+ case "number":
1172
+ return "getNumberValue";
1173
+ case "object":
1174
+ return "getObjectValue";
1175
+ case "unknown":
1176
+ return null;
1177
+ }
1178
+ }
1179
+ function manualReason(item) {
1180
+ if (item.manualReviewReason === "dynamic-key") return "dynamic key requires manual review";
1181
+ if (item.manualReviewReason === "unknown-fallback") return "unknown fallback type requires manual review";
1182
+ if (item.manualReviewReason === "bulk-inventory-call") return "bulk inventory call has no single-flag codemod";
1183
+ return "manual review required";
1184
+ }
1185
+ function buildReplacement(item) {
1186
+ if (DETAIL_METHODS.has(item.launchDarklyMethod)) {
1187
+ return {
1188
+ item,
1189
+ reason: "detail methods skipped: OpenFeature detail APIs exist, but LaunchDarkly/OpenFeature detail result parity requires manual review"
1190
+ };
1191
+ }
1192
+ if (!item.safelyAutomatable) {
1193
+ return { item, reason: manualReason(item) };
1194
+ }
1195
+ if (item.rangeStart == null || item.rangeEnd == null || !item.callExpression) {
1196
+ return { item, reason: "missing source range for reviewable diff" };
1197
+ }
1198
+ if (!item.flagKeyExpression || !item.fallbackExpression || !item.evaluationContextExpression) {
1199
+ return { item, reason: "missing flag key, fallback, or evaluation context evidence" };
1200
+ }
1201
+ const method = methodForType(item.valueType);
1202
+ if (!method) return { item, reason: "unsupported or unknown value type" };
1203
+ const call = `openFeatureClient.${method}(${item.flagKeyExpression}, ${item.fallbackExpression}, ${item.evaluationContextExpression})`;
1204
+ return {
1205
+ item,
1206
+ replacement: call,
1207
+ requiresProviderSetup: true
1208
+ };
1209
+ }
1210
+ function applyReplacements(code, replacements) {
1211
+ let next = code;
1212
+ for (const replacement of [...replacements].sort((a, b) => b.item.rangeStart - a.item.rangeStart)) {
1213
+ next = next.slice(0, replacement.item.rangeStart) + replacement.replacement + next.slice(replacement.item.rangeEnd);
1214
+ }
1215
+ return next;
1216
+ }
1217
+ function changedLineNumbers(before, after) {
1218
+ const changed = [];
1219
+ const length = Math.max(before.length, after.length);
1220
+ for (let i = 0; i < length; i++) {
1221
+ if (before[i] !== after[i]) changed.push(i);
1222
+ }
1223
+ return changed;
1224
+ }
1225
+ function formatFileDiff(file, beforeCode, afterCode) {
1226
+ const before = beforeCode.split("\n");
1227
+ const after = afterCode.split("\n");
1228
+ const changed = changedLineNumbers(before, after);
1229
+ if (changed.length === 0) return [];
1230
+ const lines = [];
1231
+ lines.push(`diff --git a/${file} b/${file}`);
1232
+ lines.push(`--- a/${file}`);
1233
+ lines.push(`+++ b/${file}`);
1234
+ for (const index of changed) {
1235
+ const oldLine = before[index] ?? "";
1236
+ const newLine = after[index] ?? "";
1237
+ lines.push(`@@ -${index + 1},1 +${index + 1},1 @@`);
1238
+ lines.push(`-${oldLine}`);
1239
+ lines.push(`+${newLine}`);
1240
+ }
1241
+ return lines;
1242
+ }
1243
+ function itemLabel(item) {
1244
+ return item.staticFlagKey ?? item.flagKeyExpression ?? "*";
1245
+ }
1246
+ function formatProviderSetupSection() {
1247
+ const lines = [];
1248
+ lines.push("## Provider Setup (Required Before Applying Diffs)");
1249
+ lines.push("");
1250
+ lines.push("LaunchDarkly remains your feature flag provider.");
1251
+ lines.push("OpenFeature becomes the evaluation API your application code calls.");
1252
+ lines.push("You add one initialization step; **do not remove any LaunchDarkly packages** \u2014");
1253
+ lines.push("the OpenFeature provider depends on them at runtime.");
1254
+ lines.push("");
1255
+ lines.push("### 1. Install packages");
1256
+ lines.push("");
1257
+ lines.push("```sh");
1258
+ lines.push(
1259
+ "npm install @openfeature/server-sdk @launchdarkly/node-server-sdk @launchdarkly/openfeature-node-server"
1260
+ );
1261
+ lines.push("```");
1262
+ lines.push("");
1263
+ lines.push("### 2. Initialize once at application startup");
1264
+ lines.push("");
1265
+ lines.push("Add the following to your application bootstrap (do not apply automatically):");
1266
+ lines.push("");
1267
+ lines.push("```typescript");
1268
+ lines.push('import { OpenFeature } from "@openfeature/server-sdk";');
1269
+ lines.push('import { LaunchDarklyProvider } from "@launchdarkly/openfeature-node-server";');
1270
+ lines.push("");
1271
+ lines.push('const ldProvider = new LaunchDarklyProvider("<your-sdk-key>");');
1272
+ lines.push("await OpenFeature.setProviderAndWait(ldProvider);");
1273
+ lines.push("");
1274
+ lines.push("// Share this client across your application.");
1275
+ lines.push("// Replace the `openFeatureClient` placeholder in the diffs below.");
1276
+ lines.push("const openFeatureClient = OpenFeature.getClient();");
1277
+ lines.push("```");
1278
+ lines.push("");
1279
+ lines.push("### 3. Evaluation context \u2014 targeting key");
1280
+ lines.push("");
1281
+ lines.push("LaunchDarkly requires a `targetingKey` field in every evaluation context.");
1282
+ lines.push("Replace the context arguments shown in the diffs with an object that includes it:");
1283
+ lines.push("");
1284
+ lines.push("```typescript");
1285
+ lines.push("{ targetingKey: user.key, ...otherAttributes }");
1286
+ lines.push("```");
1287
+ lines.push("");
1288
+ return lines;
1289
+ }
1290
+ async function formatDryRunDiff(analysis, source) {
1291
+ const replacementsByFile = /* @__PURE__ */ new Map();
1292
+ const skipped = [];
1293
+ for (const item of analysis.inventoryItems) {
1294
+ const result = buildReplacement(item);
1295
+ if ("reason" in result) {
1296
+ skipped.push(result);
1297
+ continue;
1298
+ }
1299
+ if (!replacementsByFile.has(item.file)) replacementsByFile.set(item.file, []);
1300
+ replacementsByFile.get(item.file).push(result);
1301
+ }
1302
+ const lines = [];
1303
+ lines.push("# FlagLint migrate --dry-run");
1304
+ lines.push("");
1022
1305
  lines.push(
1023
- "- OpenFeature React SDK: https://openfeature.dev/docs/reference/technologies/client/web/react"
1306
+ "These diffs use the placeholder `openFeatureClient` and require OpenFeature provider/client setup before they can be applied."
1024
1307
  );
1308
+ lines.push("No files are modified by dry-run output.");
1025
1309
  lines.push("");
1310
+ lines.push(`Reviewable diffs: ${[...replacementsByFile.values()].reduce((sum, items) => sum + items.length, 0)}`);
1311
+ lines.push(
1312
+ `Diffs requiring provider setup: ${[...replacementsByFile.values()].reduce((sum, items) => sum + items.filter((item) => item.requiresProviderSetup).length, 0)}`
1313
+ );
1314
+ lines.push(`Skipped usages: ${skipped.length}`);
1315
+ lines.push("");
1316
+ lines.push(...formatProviderSetupSection());
1317
+ if (replacementsByFile.size > 0) {
1318
+ lines.push("## Diffs");
1319
+ lines.push("```diff");
1320
+ for (const [file, replacements] of [...replacementsByFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
1321
+ const before = await source.readFile(file);
1322
+ const after = applyReplacements(before, replacements);
1323
+ lines.push(...formatFileDiff(file, before, after));
1324
+ }
1325
+ lines.push("```");
1326
+ lines.push("");
1327
+ }
1328
+ if (skipped.length > 0) {
1329
+ lines.push("## Skipped Usages");
1330
+ for (const { item, reason } of skipped) {
1331
+ lines.push(`- ${item.file}:${item.line}:${item.column} \u2014 \`${itemLabel(item)}\` via \`${item.launchDarklyMethod}\`: ${reason}`);
1332
+ }
1333
+ lines.push("");
1334
+ }
1026
1335
  return lines.join("\n");
1027
1336
  }
1028
1337
 
1338
+ // src/migrator/apply.ts
1339
+ import { execFile } from "child_process";
1340
+ import { promisify } from "util";
1341
+ import { parse as parse2 } from "@typescript-eslint/typescript-estree";
1342
+ var execFileAsync = promisify(execFile);
1343
+ var ApplyError = class extends Error {
1344
+ constructor(kind, message) {
1345
+ super(message);
1346
+ this.kind = kind;
1347
+ this.name = "ApplyError";
1348
+ }
1349
+ kind;
1350
+ };
1351
+ var OF_SERVER_SDK = "@openfeature/server-sdk";
1352
+ function tryParse(code) {
1353
+ for (const jsx of [false, true]) {
1354
+ try {
1355
+ return parse2(code, { jsx, comment: false });
1356
+ } catch {
1357
+ }
1358
+ }
1359
+ return null;
1360
+ }
1361
+ function walkNodes(root, visit) {
1362
+ const stack = [root];
1363
+ while (stack.length > 0) {
1364
+ const node = stack.pop();
1365
+ visit(node);
1366
+ for (const val of Object.values(node)) {
1367
+ if (Array.isArray(val)) {
1368
+ for (const item of val) {
1369
+ if (item !== null && typeof item === "object" && "type" in item) {
1370
+ stack.push(item);
1371
+ }
1372
+ }
1373
+ } else if (val !== null && typeof val === "object" && "type" in val) {
1374
+ stack.push(val);
1375
+ }
1376
+ }
1377
+ }
1378
+ }
1379
+ function hasOpenFeatureClientBinding(code) {
1380
+ const ast = tryParse(code);
1381
+ if (!ast) return false;
1382
+ const ofApiNames = /* @__PURE__ */ new Set();
1383
+ for (const stmt of ast.body) {
1384
+ if (stmt.type === "ImportDeclaration" && stmt.source.value === OF_SERVER_SDK) {
1385
+ for (const spec of stmt.specifiers) {
1386
+ if (spec.type === "ImportSpecifier") {
1387
+ const importedName = spec.imported.type === "Identifier" ? spec.imported.name : spec.imported.value;
1388
+ if (importedName === "OpenFeature") {
1389
+ ofApiNames.add(spec.local.name);
1390
+ }
1391
+ }
1392
+ }
1393
+ continue;
1394
+ }
1395
+ if (stmt.type === "VariableDeclaration") {
1396
+ for (const decl of stmt.declarations) {
1397
+ const init = decl.init;
1398
+ if (init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "require" && init.arguments.length === 1 && init.arguments[0]?.type === "Literal" && init.arguments[0].value === OF_SERVER_SDK && decl.id.type === "ObjectPattern") {
1399
+ for (const prop of decl.id.properties) {
1400
+ if (prop.type === "Property" && prop.key.type === "Identifier" && prop.key.name === "OpenFeature" && prop.value.type === "Identifier") {
1401
+ ofApiNames.add(prop.value.name);
1402
+ }
1403
+ }
1404
+ }
1405
+ }
1406
+ }
1407
+ }
1408
+ if (ofApiNames.size === 0) return false;
1409
+ let proven = false;
1410
+ walkNodes(ast, (node) => {
1411
+ if (proven) return;
1412
+ if (node.type !== "VariableDeclarator") return;
1413
+ const decl = node;
1414
+ if (decl.id.type !== "Identifier") return;
1415
+ if (decl.id.name !== "openFeatureClient") return;
1416
+ if (decl.init?.type !== "CallExpression") return;
1417
+ const call = decl.init;
1418
+ if (call.callee.type !== "MemberExpression") return;
1419
+ const member = call.callee;
1420
+ if (member.computed) return;
1421
+ if (member.object.type !== "Identifier") return;
1422
+ if (!ofApiNames.has(member.object.name)) return;
1423
+ if (member.property.type !== "Identifier") return;
1424
+ if (member.property.name !== "getClient") return;
1425
+ proven = true;
1426
+ });
1427
+ return proven;
1428
+ }
1429
+ var DETAIL_METHODS2 = /* @__PURE__ */ new Set([
1430
+ "variationDetail",
1431
+ "boolVariationDetail",
1432
+ "stringVariationDetail",
1433
+ "numberVariationDetail",
1434
+ "jsonVariationDetail"
1435
+ ]);
1436
+ function methodForType2(valueType) {
1437
+ switch (valueType) {
1438
+ case "boolean":
1439
+ return "getBooleanValue";
1440
+ case "string":
1441
+ return "getStringValue";
1442
+ case "number":
1443
+ return "getNumberValue";
1444
+ case "object":
1445
+ return "getObjectValue";
1446
+ case "unknown":
1447
+ return null;
1448
+ }
1449
+ }
1450
+ function buildReplacement2(item, code) {
1451
+ if (DETAIL_METHODS2.has(item.launchDarklyMethod)) {
1452
+ return {
1453
+ item,
1454
+ reason: "detail methods skipped: OpenFeature detail APIs exist, but LaunchDarkly/OpenFeature detail result parity requires manual review"
1455
+ };
1456
+ }
1457
+ if (!item.safelyAutomatable) {
1458
+ const reason = item.manualReviewReason === "dynamic-key" ? "dynamic key requires manual review" : item.manualReviewReason === "unknown-fallback" ? "unknown fallback type requires manual review" : item.manualReviewReason === "bulk-inventory-call" ? "bulk inventory call has no single-flag codemod" : "manual review required";
1459
+ return { item, reason };
1460
+ }
1461
+ if (item.rangeStart == null || item.rangeEnd == null || !item.callExpression) {
1462
+ return { item, reason: "missing source range for apply" };
1463
+ }
1464
+ const currentText = code.slice(item.rangeStart, item.rangeEnd);
1465
+ if (currentText !== item.callExpression) {
1466
+ return {
1467
+ item,
1468
+ reason: "range content does not match original call \u2014 already transformed or stale analysis; skipping"
1469
+ };
1470
+ }
1471
+ if (!item.flagKeyExpression || !item.fallbackExpression || !item.evaluationContextExpression) {
1472
+ return { item, reason: "missing flag key, fallback, or evaluation context evidence" };
1473
+ }
1474
+ const method = methodForType2(item.valueType);
1475
+ if (!method) return { item, reason: "unsupported or unknown value type" };
1476
+ const call = `openFeatureClient.${method}(${item.flagKeyExpression}, ${item.fallbackExpression}, ${item.evaluationContextExpression})`;
1477
+ return { item, replacement: call };
1478
+ }
1479
+ function applyReplacements2(code, replacements) {
1480
+ let next = code;
1481
+ for (const r of [...replacements].sort((a, b) => b.item.rangeStart - a.item.rangeStart)) {
1482
+ next = next.slice(0, r.item.rangeStart) + r.replacement + next.slice(r.item.rangeEnd);
1483
+ }
1484
+ return next;
1485
+ }
1486
+ async function defaultIsWorkingTreeDirty() {
1487
+ try {
1488
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain"]);
1489
+ return stdout.trim().length > 0;
1490
+ } catch {
1491
+ return false;
1492
+ }
1493
+ }
1494
+ async function applyMigration(analysis, source, options = {}) {
1495
+ if (!options.allowDirty) {
1496
+ const checkDirty = options.isWorkingTreeDirty ?? defaultIsWorkingTreeDirty;
1497
+ if (await checkDirty()) {
1498
+ throw new ApplyError(
1499
+ "dirty-tree",
1500
+ "Working tree has uncommitted changes.\nCommit or stash your changes first, or pass --allow-dirty to override.\nReview `flaglint migrate --dry-run` for provider setup guidance before applying."
1501
+ );
1502
+ }
1503
+ }
1504
+ const itemsByFile = /* @__PURE__ */ new Map();
1505
+ for (const item of analysis.inventoryItems) {
1506
+ if (!itemsByFile.has(item.file)) itemsByFile.set(item.file, []);
1507
+ itemsByFile.get(item.file).push(item);
1508
+ }
1509
+ const transformedFiles = [];
1510
+ const skippedFiles = [];
1511
+ let transformed = 0;
1512
+ for (const [file, items] of [...itemsByFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
1513
+ const code = await source.readFile(file);
1514
+ if (!hasOpenFeatureClientBinding(code)) {
1515
+ skippedFiles.push({
1516
+ file,
1517
+ reason: "skipped \u2014 OpenFeature client setup required. Review `flaglint migrate --dry-run` provider guidance first."
1518
+ });
1519
+ continue;
1520
+ }
1521
+ const replacements = [];
1522
+ for (const item of items) {
1523
+ const result = buildReplacement2(item, code);
1524
+ if ("reason" in result) continue;
1525
+ replacements.push(result);
1526
+ }
1527
+ if (replacements.length === 0) continue;
1528
+ const newCode = applyReplacements2(code, replacements);
1529
+ if (newCode === code) continue;
1530
+ await source.writeFile(file, newCode);
1531
+ transformedFiles.push(file);
1532
+ transformed += replacements.length;
1533
+ }
1534
+ return { transformed, skipped: skippedFiles.length, transformedFiles, skippedFiles };
1535
+ }
1536
+
1029
1537
  // src/commands/migrate.ts
1030
1538
  function registerMigrateCommand(program2) {
1031
- 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").option("--exclude-tests", "exclude test files (*.test.*, *.spec.*, __tests__/, tests/)").addHelpText(
1539
+ 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 reviewable diffs to stdout without writing files").option("--apply", "apply safe transformations to source files in-place").option("--allow-dirty", "allow --apply on a dirty git working tree").option("--exclude-tests", "exclude test files (*.test.*, *.spec.*, __tests__/, tests/)").addHelpText(
1032
1540
  "after",
1033
1541
  `
1034
1542
  Examples:
1035
1543
  $ flaglint migrate generate migration plan for current directory
1036
1544
  $ flaglint migrate ./src analyze specific directory
1037
- $ flaglint migrate --dry-run preview without writing file
1545
+ $ flaglint migrate --dry-run preview diffs without writing files
1546
+ $ flaglint migrate --apply apply safe transformations in-place
1547
+ $ flaglint migrate --apply --allow-dirty apply even on a dirty working tree
1038
1548
  $ flaglint migrate --output plan.md write to custom file
1039
1549
  $ flaglint migrate --exclude-tests skip test and spec files`
1040
1550
  ).action(
1041
1551
  async (dir, options) => {
1552
+ if (options.dryRun && options.apply) {
1553
+ process.stderr.write(chalk2.red("Error: --dry-run and --apply are mutually exclusive.\n"));
1554
+ process.exit(1);
1555
+ }
1042
1556
  try {
1043
1557
  const s = await stat2(resolve5(dir));
1044
1558
  if (!s.isDirectory()) {
@@ -1081,12 +1595,13 @@ Examples:
1081
1595
  spinner.stop();
1082
1596
  process.exit(130);
1083
1597
  });
1598
+ const source = new LocalFileSource(dir);
1084
1599
  let scanResult;
1085
1600
  try {
1086
- scanResult = await scan(new LocalFileSource(dir), scanConfig, (filesScanned) => {
1601
+ scanResult = await scan(source, scanConfig, (filesScanned) => {
1087
1602
  spinner.text = `Scanning files... ${filesScanned}`;
1088
1603
  });
1089
- spinner.text = "Analyzing migration readiness...";
1604
+ spinner.text = "Analyzing migration inventory...";
1090
1605
  } catch (err) {
1091
1606
  spinner.fail("Scan failed");
1092
1607
  process.stderr.write(chalk2.red(String(err)) + "\n");
@@ -1115,24 +1630,69 @@ Examples:
1115
1630
  const msg = w.kind === "read-failure" ? `warn: could not read ${w.file} (${w.fsCode})` : `warn: failed to parse ${w.file}`;
1116
1631
  process.stderr.write(chalk2.yellow(msg + "\n"));
1117
1632
  }
1118
- const { readinessScore } = analysis;
1119
- const scoreColor = readinessScore >= 80 ? chalk2.green : readinessScore >= 50 ? chalk2.yellow : chalk2.red;
1120
- process.stderr.write(scoreColor(`Migration Readiness Score: ${readinessScore}/100
1633
+ const summaryColor = analysis.manualReviewCount > 0 ? chalk2.yellow : chalk2.green;
1634
+ process.stderr.write(summaryColor(`LaunchDarkly usages found: ${analysis.totalLaunchDarklyUsages}
1121
1635
  `));
1122
1636
  process.stderr.write(
1123
1637
  chalk2.gray(
1124
- `Auto-migratable: ${analysis.autoMigrateCount} \xB7 Manual review: ${analysis.manualReviewCount}
1638
+ `Safely automatable: ${analysis.safelyAutomatableCount} \xB7 Manual review: ${analysis.manualReviewCount}
1125
1639
  `
1126
1640
  )
1127
1641
  );
1128
- const report = formatMigrationReport(analysis);
1129
1642
  if (options.dryRun) {
1130
- process.stdout.write(report + "\n");
1643
+ const report2 = await formatDryRunDiff(analysis, source);
1644
+ process.stdout.write(report2 + "\n");
1131
1645
  process.exit(0);
1132
1646
  }
1647
+ if (options.apply) {
1648
+ let result;
1649
+ try {
1650
+ result = await applyMigration(analysis, source, { allowDirty: options.allowDirty });
1651
+ } catch (err) {
1652
+ if (err instanceof ApplyError && err.kind === "dirty-tree") {
1653
+ process.stderr.write(chalk2.red(`
1654
+ Error: ${err.message}
1655
+ `));
1656
+ process.exit(1);
1657
+ }
1658
+ process.stderr.write(chalk2.red(String(err)) + "\n");
1659
+ process.exit(1);
1660
+ }
1661
+ if (result.transformed > 0) {
1662
+ process.stderr.write(
1663
+ chalk2.green(
1664
+ `Transformed: ${result.transformed} call-site(s) across ${result.transformedFiles.length} file(s)
1665
+ `
1666
+ )
1667
+ );
1668
+ for (const file of result.transformedFiles) {
1669
+ process.stderr.write(chalk2.dim(` \u2713 ${file}
1670
+ `));
1671
+ }
1672
+ } else {
1673
+ process.stderr.write(chalk2.dim("No call-sites were transformed.\n"));
1674
+ }
1675
+ if (result.skipped > 0) {
1676
+ process.stderr.write(
1677
+ chalk2.yellow(`Skipped: ${result.skipped} file(s) \u2014 OpenFeature client setup required
1678
+ `)
1679
+ );
1680
+ for (const { file } of result.skippedFiles) {
1681
+ process.stderr.write(
1682
+ chalk2.dim(` \u26A0 ${file}: no openFeatureClient binding found
1683
+ `)
1684
+ );
1685
+ process.stderr.write(
1686
+ chalk2.dim(" Run `flaglint migrate --dry-run` for provider setup guidance.\n")
1687
+ );
1688
+ }
1689
+ }
1690
+ process.exit(0);
1691
+ }
1692
+ const report = formatMigrationReport(analysis);
1133
1693
  const outPath = resolve5(options.output);
1134
1694
  try {
1135
- await writeFile2(outPath, report, "utf8");
1695
+ await writeFile3(outPath, report, "utf8");
1136
1696
  process.stderr.write(chalk2.green(`Migration plan written to ${options.output}
1137
1697
  `));
1138
1698
  } catch (err) {
@@ -1149,10 +1709,179 @@ Examples:
1149
1709
  );
1150
1710
  }
1151
1711
 
1712
+ // src/commands/validate.ts
1713
+ import { stat as stat3 } from "fs/promises";
1714
+ import { resolve as resolve6 } from "path";
1715
+ import chalk3 from "chalk";
1716
+ import ora3 from "ora";
1717
+
1718
+ // src/validator/index.ts
1719
+ function matchesBootstrapPattern(file, patterns) {
1720
+ const clean = (s) => s.replace(/^\.\//, "");
1721
+ const cleanFile = clean(file);
1722
+ return patterns.some((pattern) => {
1723
+ const cleanPattern = clean(pattern);
1724
+ if (cleanFile === cleanPattern) return true;
1725
+ const regexStr = cleanPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\x00/g, ".*").replace(/\?/g, "[^/]");
1726
+ try {
1727
+ return new RegExp(`^${regexStr}$`).test(cleanFile);
1728
+ } catch {
1729
+ return false;
1730
+ }
1731
+ });
1732
+ }
1733
+ function validateScanResult(result, options = {}) {
1734
+ const violations = [];
1735
+ if (options.noDirectLaunchDarkly) {
1736
+ const bootstrapExclude = options.bootstrapExclude ?? [];
1737
+ for (const usage of result.usages) {
1738
+ if (matchesBootstrapPattern(usage.file, bootstrapExclude)) continue;
1739
+ violations.push({
1740
+ file: usage.file,
1741
+ line: usage.line,
1742
+ column: usage.column,
1743
+ callType: usage.callType,
1744
+ flagKey: usage.flagKey,
1745
+ isDynamic: usage.isDynamic
1746
+ });
1747
+ }
1748
+ }
1749
+ return {
1750
+ scannedFiles: result.scannedFiles,
1751
+ totalUsages: result.totalUsages,
1752
+ violations,
1753
+ passed: violations.length === 0
1754
+ };
1755
+ }
1756
+ function violationLabel(v) {
1757
+ if (v.isDynamic) return `${v.callType}(dynamic key \u2014 manual review required)`;
1758
+ if (v.flagKey === "*") return `${v.callType}(bulk inventory)`;
1759
+ return `${v.callType}("${v.flagKey}")`;
1760
+ }
1761
+ function formatValidationReport(result, options = {}) {
1762
+ const lines = [];
1763
+ if (!options.noDirectLaunchDarkly) {
1764
+ lines.push(
1765
+ `Scanned ${result.scannedFiles} file(s). Found ${result.totalUsages} LaunchDarkly usage(s).`
1766
+ );
1767
+ return lines.join("\n") + "\n";
1768
+ }
1769
+ if (result.passed) {
1770
+ lines.push(
1771
+ `\u2713 validate --no-direct-launchdarkly: no direct LaunchDarkly evaluation calls found.`
1772
+ );
1773
+ lines.push(` Scanned ${result.scannedFiles} file(s).`);
1774
+ return lines.join("\n") + "\n";
1775
+ }
1776
+ const count = result.violations.length;
1777
+ lines.push(
1778
+ `\u2717 validate --no-direct-launchdarkly: ${count} direct LaunchDarkly evaluation call(s) found.`
1779
+ );
1780
+ lines.push("");
1781
+ for (const v of result.violations) {
1782
+ lines.push(` ${v.file}:${v.line}:${v.column} \u2014 ${violationLabel(v)}`);
1783
+ }
1784
+ lines.push("");
1785
+ lines.push("These files must migrate to OpenFeature before this rule passes.");
1786
+ lines.push("Run `flaglint migrate --dry-run` to review the migration plan.");
1787
+ return lines.join("\n") + "\n";
1788
+ }
1789
+
1790
+ // src/commands/validate.ts
1791
+ function registerValidateCommand(program2) {
1792
+ program2.command("validate").description("Validate that your codebase complies with feature flag usage policies").argument("[dir]", "directory to validate", process.cwd()).option(
1793
+ "--no-direct-launchdarkly",
1794
+ "fail if any direct LaunchDarkly Node server SDK evaluation calls are found"
1795
+ ).option(
1796
+ "--bootstrap-exclude <glob>",
1797
+ "glob pattern for files allowed to use LaunchDarkly SDK directly (repeatable)",
1798
+ (val, prev) => [...prev, val],
1799
+ []
1800
+ ).option("-c, --config <path>", "path to .flaglintrc config file").addHelpText(
1801
+ "after",
1802
+ `
1803
+ Examples:
1804
+ $ flaglint validate scan and report LD usages
1805
+ $ flaglint validate --no-direct-launchdarkly fail on any direct LD eval calls
1806
+ $ flaglint validate --no-direct-launchdarkly \\
1807
+ --bootstrap-exclude src/provider/setup.ts allow bootstrap file
1808
+ $ flaglint validate --no-direct-launchdarkly \\
1809
+ --bootstrap-exclude "src/provider/**" allow all provider files
1810
+ $ flaglint validate --no-direct-launchdarkly \\
1811
+ --bootstrap-exclude "src/provider/*.ts" \\
1812
+ --bootstrap-exclude "src/bootstrap/**" multiple exclusion patterns`
1813
+ ).action(
1814
+ async (dir, options) => {
1815
+ try {
1816
+ const s = await stat3(resolve6(dir));
1817
+ if (!s.isDirectory()) {
1818
+ process.stderr.write(chalk3.red(`Error: Not a directory: ${dir}
1819
+ `));
1820
+ process.exit(1);
1821
+ }
1822
+ } catch (err) {
1823
+ const code = err.code;
1824
+ if (code === "ENOENT") {
1825
+ process.stderr.write(chalk3.red(`Error: Directory not found: ${dir}
1826
+ `));
1827
+ } else if (code === "EACCES") {
1828
+ process.stderr.write(chalk3.red(`Error: Permission denied: ${dir}
1829
+ `));
1830
+ } else {
1831
+ process.stderr.write(chalk3.red(`Error: Cannot access directory: ${dir}
1832
+ `));
1833
+ }
1834
+ process.exit(1);
1835
+ }
1836
+ let config;
1837
+ try {
1838
+ config = await loadConfig(options.config);
1839
+ } catch (err) {
1840
+ process.stderr.write(chalk3.red(String(err instanceof Error ? err.message : err)) + "\n");
1841
+ process.exit(1);
1842
+ }
1843
+ const spinner = ora3(`Scanning ${dir}...`).start();
1844
+ process.once("SIGINT", () => {
1845
+ spinner.stop();
1846
+ process.exit(130);
1847
+ });
1848
+ const source = new LocalFileSource(dir);
1849
+ let scanResult;
1850
+ try {
1851
+ scanResult = await scan(source, config, (filesScanned) => {
1852
+ spinner.text = `Scanning files... ${filesScanned}`;
1853
+ });
1854
+ spinner.stop();
1855
+ } catch (err) {
1856
+ spinner.fail("Scan failed");
1857
+ process.stderr.write(chalk3.red(String(err)) + "\n");
1858
+ process.exit(1);
1859
+ }
1860
+ for (const w of scanResult.warnings) {
1861
+ const msg = w.kind === "read-failure" ? `warn: could not read ${w.file} (${w.fsCode})` : `warn: failed to parse ${w.file}`;
1862
+ process.stderr.write(chalk3.yellow(msg + "\n"));
1863
+ }
1864
+ const noDirectLaunchDarkly = options.directLaunchdarkly === false;
1865
+ const bootstrapExclude = options.bootstrapExclude ?? [];
1866
+ const validateOptions = {
1867
+ noDirectLaunchDarkly,
1868
+ bootstrapExclude
1869
+ };
1870
+ const validationResult = validateScanResult(scanResult, validateOptions);
1871
+ const report = formatValidationReport(validationResult, validateOptions);
1872
+ process.stdout.write(report);
1873
+ if (!validationResult.passed) {
1874
+ process.exit(1);
1875
+ }
1876
+ process.exit(0);
1877
+ }
1878
+ );
1879
+ }
1880
+
1152
1881
  // src/cli.ts
1153
1882
  function createCLI() {
1154
1883
  const program2 = new Command();
1155
- program2.name("flaglint").description("Find stale feature flags. Detect flag debt. Plan your OpenFeature migration.").version("0.3.0", "-v, --version", "output the current version").addHelpText(
1884
+ program2.name("flaglint").description("LaunchDarkly Node.js server SDK -> OpenFeature migration.").version("0.4.0", "-v, --version", "output the current version").addHelpText(
1156
1885
  "after",
1157
1886
  `
1158
1887
  Examples:
@@ -1161,10 +1890,12 @@ Examples:
1161
1890
  $ flaglint scan --format json output as JSON
1162
1891
  $ flaglint scan --output report.md save to file
1163
1892
  $ flaglint migrate generate migration plan
1164
- $ flaglint migrate --dry-run preview without writing`
1893
+ $ flaglint migrate --dry-run preview without writing
1894
+ $ flaglint validate --no-direct-launchdarkly enforce OF migration in CI`
1165
1895
  );
1166
1896
  registerScanCommand(program2);
1167
1897
  registerMigrateCommand(program2);
1898
+ registerValidateCommand(program2);
1168
1899
  return program2;
1169
1900
  }
1170
1901