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