flaglint 0.2.2 → 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.
- package/CHANGELOG.md +44 -1
- package/README.md +237 -45
- package/dist/bin/flaglint.js +1085 -227
- package/package.json +6 -4
package/dist/bin/flaglint.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
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
|
-
import { resolve as
|
|
9
|
+
import { resolve as resolve4 } from "path";
|
|
10
10
|
import chalk from "chalk";
|
|
11
11
|
import ora from "ora";
|
|
12
12
|
|
|
@@ -36,8 +36,23 @@ 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"]);
|
|
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
|
|
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" &&
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
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:
|
|
231
|
+
callType: methodName,
|
|
97
232
|
stalenessSignals: sig ? [sig] : []
|
|
98
233
|
});
|
|
99
|
-
|
|
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:
|
|
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,10 +330,11 @@ 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();
|
|
337
|
+
const scannedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
193
338
|
for (const pattern of config.include) {
|
|
194
339
|
if (pattern.startsWith("/") || pattern.startsWith("..")) {
|
|
195
340
|
throw new Error(
|
|
@@ -199,6 +344,7 @@ async function scan(source, config, onProgress, evaluator) {
|
|
|
199
344
|
}
|
|
200
345
|
const files = await source.listFiles(config.include, config.exclude);
|
|
201
346
|
const allUsages = [];
|
|
347
|
+
const migrationInventory = [];
|
|
202
348
|
const warnings = [];
|
|
203
349
|
let scannedFiles = 0;
|
|
204
350
|
async function processFile(file) {
|
|
@@ -207,22 +353,22 @@ async function scan(source, config, onProgress, evaluator) {
|
|
|
207
353
|
code = await source.readFile(file);
|
|
208
354
|
} catch (err) {
|
|
209
355
|
const fsCode = err.code ?? "UNKNOWN";
|
|
210
|
-
return { usages: [], warning: { kind: "read-failure", file, fsCode } };
|
|
356
|
+
return { usages: [], migrationInventory: [], warning: { kind: "read-failure", file, fsCode } };
|
|
211
357
|
}
|
|
212
358
|
let ast;
|
|
213
359
|
try {
|
|
214
360
|
ast = parse(code, {
|
|
215
361
|
jsx: true,
|
|
216
362
|
loc: true,
|
|
217
|
-
range:
|
|
363
|
+
range: true,
|
|
218
364
|
comment: false,
|
|
219
365
|
tokens: false,
|
|
220
366
|
filePath: file
|
|
221
367
|
});
|
|
222
368
|
} catch {
|
|
223
|
-
return { usages: [], warning: { kind: "parse-failure", file } };
|
|
369
|
+
return { usages: [], migrationInventory: [], warning: { kind: "parse-failure", file } };
|
|
224
370
|
}
|
|
225
|
-
return {
|
|
371
|
+
return { ...detectUsages(ast, code, file, config.wrappers), warning: null };
|
|
226
372
|
}
|
|
227
373
|
const limit = pLimit(50);
|
|
228
374
|
const results = await Promise.all(
|
|
@@ -236,6 +382,7 @@ async function scan(source, config, onProgress, evaluator) {
|
|
|
236
382
|
);
|
|
237
383
|
for (const r of results) {
|
|
238
384
|
allUsages.push(...r.usages);
|
|
385
|
+
migrationInventory.push(...r.migrationInventory);
|
|
239
386
|
if (r.warning) warnings.push(r.warning);
|
|
240
387
|
}
|
|
241
388
|
if (config.minFileCount > 0) {
|
|
@@ -270,10 +417,13 @@ async function scan(source, config, onProgress, evaluator) {
|
|
|
270
417
|
)
|
|
271
418
|
];
|
|
272
419
|
return {
|
|
420
|
+
scannedAt,
|
|
421
|
+
scanRoot: source.root ?? ".",
|
|
273
422
|
scannedFiles,
|
|
274
423
|
totalUsages: allUsages.length,
|
|
275
424
|
uniqueFlags,
|
|
276
425
|
usages: allUsages,
|
|
426
|
+
migrationInventory,
|
|
277
427
|
scanDurationMs: Date.now() - start,
|
|
278
428
|
warnings
|
|
279
429
|
};
|
|
@@ -281,13 +431,15 @@ async function scan(source, config, onProgress, evaluator) {
|
|
|
281
431
|
|
|
282
432
|
// src/scanner/local-source.ts
|
|
283
433
|
import fg from "fast-glob";
|
|
284
|
-
import { readFile } from "fs/promises";
|
|
285
|
-
import { join, relative } from "path";
|
|
434
|
+
import { readFile, writeFile } from "fs/promises";
|
|
435
|
+
import { join, relative, resolve } from "path";
|
|
286
436
|
var LocalFileSource = class {
|
|
287
437
|
constructor(dir) {
|
|
288
438
|
this.dir = dir;
|
|
439
|
+
this.root = resolve(dir);
|
|
289
440
|
}
|
|
290
441
|
dir;
|
|
442
|
+
root;
|
|
291
443
|
async listFiles(include, exclude) {
|
|
292
444
|
const files = await fg(include, {
|
|
293
445
|
cwd: this.dir,
|
|
@@ -300,8 +452,15 @@ var LocalFileSource = class {
|
|
|
300
452
|
async readFile(path) {
|
|
301
453
|
return readFile(join(this.dir, path), "utf8");
|
|
302
454
|
}
|
|
455
|
+
async writeFile(path, content) {
|
|
456
|
+
await writeFile(join(this.dir, path), content, "utf8");
|
|
457
|
+
}
|
|
303
458
|
};
|
|
304
459
|
|
|
460
|
+
// src/reporter/index.ts
|
|
461
|
+
import { resolve as resolve2 } from "path";
|
|
462
|
+
import { pathToFileURL } from "url";
|
|
463
|
+
|
|
305
464
|
// src/types.ts
|
|
306
465
|
var isStale = (u) => u.stalenessSignals.length > 0;
|
|
307
466
|
|
|
@@ -331,11 +490,11 @@ function sortedFlagEntries(map) {
|
|
|
331
490
|
}
|
|
332
491
|
function formatMarkdown(result, options) {
|
|
333
492
|
const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
|
|
334
|
-
const staleUsages = usages.filter(isStale);
|
|
493
|
+
const staleUsages = usages.filter((u) => isStale(u) && !u.isDynamic && u.flagKey !== "*");
|
|
335
494
|
const dynamicUsages = usages.filter((u) => u.isDynamic);
|
|
336
495
|
const flagMap = buildFlagMap(usages);
|
|
337
496
|
const sorted = sortedFlagEntries(flagMap);
|
|
338
|
-
const staleFlags = sorted.filter(([, d]) => d.isStale);
|
|
497
|
+
const staleFlags = sorted.filter(([key, d]) => key !== "*" && d.isStale);
|
|
339
498
|
const lines = [];
|
|
340
499
|
lines.push("# FlagLint Scan Report");
|
|
341
500
|
if (options.title) lines.push("", options.title);
|
|
@@ -391,13 +550,128 @@ function formatMarkdown(result, options) {
|
|
|
391
550
|
return lines.join("\n");
|
|
392
551
|
}
|
|
393
552
|
function formatJSON(result) {
|
|
394
|
-
return JSON.stringify({ generatedAt:
|
|
553
|
+
return JSON.stringify({ generatedAt: result.scannedAt, ...result }, null, 2);
|
|
554
|
+
}
|
|
555
|
+
function signalRuleId(usage) {
|
|
556
|
+
const signal = usage.stalenessSignals[0];
|
|
557
|
+
if (!signal) return "flaglint.stale";
|
|
558
|
+
return `flaglint.${signal.source}`;
|
|
559
|
+
}
|
|
560
|
+
function signalMessage(usage) {
|
|
561
|
+
const reasons = usage.stalenessSignals.map((signal) => {
|
|
562
|
+
switch (signal.source) {
|
|
563
|
+
case "keyword":
|
|
564
|
+
return `keyword "${signal.keyword}"`;
|
|
565
|
+
case "path":
|
|
566
|
+
return `path pattern "${signal.pattern}"`;
|
|
567
|
+
case "minFileCount":
|
|
568
|
+
return `file count ${signal.fileCount} <= ${signal.threshold}`;
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
return reasons.length > 0 ? reasons.join(", ") : "staleness signal";
|
|
572
|
+
}
|
|
573
|
+
function sarifUri(file) {
|
|
574
|
+
return file.split(/[\\/]/).join("/");
|
|
575
|
+
}
|
|
576
|
+
function sarifRootUri(scanRoot) {
|
|
577
|
+
const uri = pathToFileURL(resolve2(scanRoot)).href;
|
|
578
|
+
return uri.endsWith("/") ? uri : `${uri}/`;
|
|
579
|
+
}
|
|
580
|
+
function formatSARIF(result) {
|
|
581
|
+
const staleUsages = result.usages.filter((u) => isStale(u) && !u.isDynamic && u.flagKey !== "*");
|
|
582
|
+
const rules = [
|
|
583
|
+
{
|
|
584
|
+
id: "flaglint.keyword",
|
|
585
|
+
name: "Stale flag keyword",
|
|
586
|
+
shortDescription: { text: "Flag key contains a stale keyword" },
|
|
587
|
+
helpUri: "https://github.com/flaglint/flaglint#what-flaglint-detects"
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
id: "flaglint.path",
|
|
591
|
+
name: "Stale flag path",
|
|
592
|
+
shortDescription: { text: "Flag usage appears in a stale path" },
|
|
593
|
+
helpUri: "https://github.com/flaglint/flaglint#what-flaglint-detects"
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
id: "flaglint.minFileCount",
|
|
597
|
+
name: "Low file count",
|
|
598
|
+
shortDescription: { text: "Flag appears in too few files" },
|
|
599
|
+
helpUri: "https://github.com/flaglint/flaglint#configuration"
|
|
600
|
+
}
|
|
601
|
+
];
|
|
602
|
+
return JSON.stringify(
|
|
603
|
+
{
|
|
604
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
605
|
+
version: "2.1.0",
|
|
606
|
+
runs: [
|
|
607
|
+
{
|
|
608
|
+
tool: {
|
|
609
|
+
driver: {
|
|
610
|
+
name: "FlagLint",
|
|
611
|
+
informationUri: "https://github.com/flaglint/flaglint",
|
|
612
|
+
rules
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
invocations: [
|
|
616
|
+
{
|
|
617
|
+
executionSuccessful: true,
|
|
618
|
+
startTimeUtc: result.scannedAt,
|
|
619
|
+
properties: {
|
|
620
|
+
scannedFiles: result.scannedFiles,
|
|
621
|
+
totalUsages: result.totalUsages,
|
|
622
|
+
uniqueFlags: result.uniqueFlags.length,
|
|
623
|
+
scanDurationMs: result.scanDurationMs
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
],
|
|
627
|
+
originalUriBaseIds: {
|
|
628
|
+
"%SRCROOT%": {
|
|
629
|
+
uri: sarifRootUri(result.scanRoot)
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
results: staleUsages.map((usage) => ({
|
|
633
|
+
ruleId: signalRuleId(usage),
|
|
634
|
+
level: "warning",
|
|
635
|
+
message: {
|
|
636
|
+
text: `Potentially stale feature flag "${usage.flagKey}" detected: ${signalMessage(usage)}.`
|
|
637
|
+
},
|
|
638
|
+
locations: [
|
|
639
|
+
{
|
|
640
|
+
physicalLocation: {
|
|
641
|
+
artifactLocation: {
|
|
642
|
+
uri: sarifUri(usage.file),
|
|
643
|
+
uriBaseId: "%SRCROOT%"
|
|
644
|
+
},
|
|
645
|
+
region: {
|
|
646
|
+
startLine: Math.max(usage.line, 1),
|
|
647
|
+
startColumn: Math.max(usage.column + 1, 1)
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
],
|
|
652
|
+
partialFingerprints: {
|
|
653
|
+
"flagKey/v1": usage.flagKey
|
|
654
|
+
},
|
|
655
|
+
properties: {
|
|
656
|
+
flagKey: usage.flagKey,
|
|
657
|
+
callType: usage.callType,
|
|
658
|
+
stalenessSignals: usage.stalenessSignals
|
|
659
|
+
}
|
|
660
|
+
}))
|
|
661
|
+
}
|
|
662
|
+
]
|
|
663
|
+
},
|
|
664
|
+
null,
|
|
665
|
+
2
|
|
666
|
+
);
|
|
395
667
|
}
|
|
396
668
|
function formatHTML(result, options) {
|
|
397
669
|
const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
|
|
398
|
-
const staleCount = new Set(
|
|
670
|
+
const staleCount = new Set(
|
|
671
|
+
usages.filter((u) => isStale(u) && !u.isDynamic && u.flagKey !== "*").map((u) => u.flagKey)
|
|
672
|
+
).size;
|
|
399
673
|
const dynamicCount = usages.filter((u) => u.isDynamic).length;
|
|
400
|
-
const date =
|
|
674
|
+
const date = new Date(result.scannedAt).toLocaleString();
|
|
401
675
|
const flagMap = buildFlagMap(usages);
|
|
402
676
|
const sorted = sortedFlagEntries(flagMap);
|
|
403
677
|
const rows = sorted.map(([key, data]) => {
|
|
@@ -407,7 +681,7 @@ function formatHTML(result, options) {
|
|
|
407
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>`;
|
|
408
682
|
}).join("\n ");
|
|
409
683
|
const title = options.title ? esc(options.title) : "FlagLint Scan Report";
|
|
410
|
-
const version = true ? "0.
|
|
684
|
+
const version = true ? "0.4.0" : "0.1.0";
|
|
411
685
|
return `<!DOCTYPE html>
|
|
412
686
|
<html lang="en">
|
|
413
687
|
<head>
|
|
@@ -482,14 +756,16 @@ function formatReport(result, options) {
|
|
|
482
756
|
return formatJSON(result);
|
|
483
757
|
case "html":
|
|
484
758
|
return formatHTML(result, options);
|
|
485
|
-
|
|
759
|
+
case "markdown":
|
|
486
760
|
return formatMarkdown(result, options);
|
|
761
|
+
case "sarif":
|
|
762
|
+
return formatSARIF(result);
|
|
487
763
|
}
|
|
488
764
|
}
|
|
489
765
|
|
|
490
766
|
// src/config.ts
|
|
491
767
|
import { readFile as readFile2, access } from "fs/promises";
|
|
492
|
-
import { resolve } from "path";
|
|
768
|
+
import { resolve as resolve3 } from "path";
|
|
493
769
|
import { z, ZodError } from "zod";
|
|
494
770
|
var FlagLintConfigSchema = z.object({
|
|
495
771
|
include: z.array(z.string()).default(["**/*.{ts,tsx,js,jsx}"]),
|
|
@@ -512,7 +788,7 @@ var SEARCH_PATHS = [".flaglintrc", ".flaglintrc.json", "flaglint.config.json"];
|
|
|
512
788
|
async function loadConfig(configPath) {
|
|
513
789
|
const candidates = configPath ? [configPath] : SEARCH_PATHS;
|
|
514
790
|
for (const candidate of candidates) {
|
|
515
|
-
const full =
|
|
791
|
+
const full = resolve3(candidate);
|
|
516
792
|
try {
|
|
517
793
|
await access(full);
|
|
518
794
|
} catch {
|
|
@@ -538,15 +814,16 @@ async function loadConfig(configPath) {
|
|
|
538
814
|
}
|
|
539
815
|
|
|
540
816
|
// src/commands/scan.ts
|
|
541
|
-
var VALID_FORMATS = ["json", "markdown", "html"];
|
|
817
|
+
var VALID_FORMATS = ["json", "markdown", "html", "sarif"];
|
|
542
818
|
function registerScanCommand(program2) {
|
|
543
|
-
program2.command("scan").description("Scan a directory for feature flag usages and detect stale flags").argument("[dir]", "directory to scan", process.cwd()).option("-f, --format <format>", "output format: json | markdown | html", "markdown").option("-o, --output <file>", "write report to file").option("-c, --config <path>", "path to .flaglintrc config file").option("--exclude-tests", "exclude test files (*.test.*, *.spec.*, __tests__/, tests/)").addHelpText(
|
|
819
|
+
program2.command("scan").description("Scan a directory for feature flag usages and detect stale flags").argument("[dir]", "directory to scan", process.cwd()).option("-f, --format <format>", "output format: json | markdown | html | sarif", "markdown").option("-o, --output <file>", "write report to file").option("-c, --config <path>", "path to .flaglintrc config file").option("--exclude-tests", "exclude test files (*.test.*, *.spec.*, __tests__/, tests/)").addHelpText(
|
|
544
820
|
"after",
|
|
545
821
|
`
|
|
546
822
|
Examples:
|
|
547
823
|
$ flaglint scan scan current directory
|
|
548
824
|
$ flaglint scan ./src scan specific directory
|
|
549
825
|
$ flaglint scan --format json output as JSON
|
|
826
|
+
$ flaglint scan --format sarif output as SARIF for GitHub Code Scanning
|
|
550
827
|
$ flaglint scan --output report.md save to file
|
|
551
828
|
$ flaglint scan --exclude-tests skip test and spec files`
|
|
552
829
|
).action(
|
|
@@ -561,7 +838,7 @@ Examples:
|
|
|
561
838
|
process.exit(2);
|
|
562
839
|
}
|
|
563
840
|
try {
|
|
564
|
-
const s = await stat(
|
|
841
|
+
const s = await stat(resolve4(dir));
|
|
565
842
|
if (!s.isDirectory()) {
|
|
566
843
|
process.stderr.write(chalk.red(`Error: Not a directory: ${dir}
|
|
567
844
|
`));
|
|
@@ -661,9 +938,9 @@ Examples:
|
|
|
661
938
|
}
|
|
662
939
|
const report = formatReport(result, { format, title: config.reportTitle });
|
|
663
940
|
if (options.output) {
|
|
664
|
-
const outPath =
|
|
941
|
+
const outPath = resolve4(options.output);
|
|
665
942
|
try {
|
|
666
|
-
await
|
|
943
|
+
await writeFile2(outPath, report, "utf8");
|
|
667
944
|
process.stderr.write(chalk.dim(` Report written to ${options.output}
|
|
668
945
|
`));
|
|
669
946
|
} catch (err) {
|
|
@@ -684,236 +961,600 @@ Examples:
|
|
|
684
961
|
}
|
|
685
962
|
|
|
686
963
|
// src/commands/migrate.ts
|
|
687
|
-
import { writeFile as
|
|
964
|
+
import { writeFile as writeFile3 } from "fs/promises";
|
|
688
965
|
import { stat as stat2 } from "fs/promises";
|
|
689
|
-
import { resolve as
|
|
966
|
+
import { resolve as resolve5 } from "path";
|
|
690
967
|
import chalk2 from "chalk";
|
|
691
968
|
import ora2 from "ora";
|
|
692
969
|
|
|
693
970
|
// src/migrator/index.ts
|
|
694
|
-
function
|
|
695
|
-
|
|
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
|
+
};
|
|
696
986
|
}
|
|
697
|
-
function
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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;
|
|
710
1006
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
};
|
|
721
|
-
case "variationDetail":
|
|
722
|
-
return {
|
|
723
|
-
usage,
|
|
724
|
-
openFeatureEquivalent: "client.getBooleanDetails()",
|
|
725
|
-
codeChangeBefore: `ldClient.variationDetail(${k}, context, false)`,
|
|
726
|
-
codeChangeAfter: `await client.getBooleanDetails(${k}, false) // server SDK is async`,
|
|
727
|
-
requiresManualReview: true,
|
|
728
|
-
reviewReason: "OpenFeature server SDK methods are async \u2014 add await and make the enclosing function async"
|
|
729
|
-
};
|
|
730
|
-
case "allFlags":
|
|
731
|
-
return {
|
|
732
|
-
usage,
|
|
733
|
-
openFeatureEquivalent: null,
|
|
734
|
-
codeChangeBefore: `ldClient.allFlags(context)`,
|
|
735
|
-
codeChangeAfter: `// No direct OpenFeature equivalent \u2014 requires manual implementation`,
|
|
736
|
-
requiresManualReview: true,
|
|
737
|
-
reviewReason: "allFlags() has no direct OpenFeature equivalent"
|
|
738
|
-
};
|
|
739
|
-
case "isFeatureEnabled":
|
|
740
|
-
return {
|
|
741
|
-
usage,
|
|
742
|
-
openFeatureEquivalent: "client.getBooleanValue()",
|
|
743
|
-
codeChangeBefore: `isFeatureEnabled(${k}, context)`,
|
|
744
|
-
codeChangeAfter: `await client.getBooleanValue(${k}, false) // server SDK is async`,
|
|
745
|
-
requiresManualReview: true,
|
|
746
|
-
reviewReason: "OpenFeature server SDK methods are async \u2014 add await and make the enclosing function async"
|
|
747
|
-
};
|
|
748
|
-
case "hook-useFlags":
|
|
749
|
-
return {
|
|
750
|
-
usage,
|
|
751
|
-
openFeatureEquivalent: "useBooleanFlagValue()",
|
|
752
|
-
codeChangeBefore: `const flags = useFlags()`,
|
|
753
|
-
codeChangeAfter: `const flagValue = useBooleanFlagValue('your-flag-key', false) // TODO: one hook call per flag`,
|
|
754
|
-
requiresManualReview: true,
|
|
755
|
-
reviewReason: "useFlags() returns all flags; OpenFeature requires one useBooleanFlagValue() call per flag"
|
|
756
|
-
};
|
|
757
|
-
case "hook-useLDClient":
|
|
758
|
-
return {
|
|
759
|
-
usage,
|
|
760
|
-
openFeatureEquivalent: "useOpenFeatureClient()",
|
|
761
|
-
codeChangeBefore: `const client = useLDClient()`,
|
|
762
|
-
codeChangeAfter: `const client = useOpenFeatureClient()`,
|
|
763
|
-
requiresManualReview: false
|
|
764
|
-
};
|
|
765
|
-
case "hoc":
|
|
766
|
-
return {
|
|
767
|
-
usage,
|
|
768
|
-
openFeatureEquivalent: null,
|
|
769
|
-
codeChangeBefore: `withLDConsumer()(Component)`,
|
|
770
|
-
codeChangeAfter: `// withOpenFeature() does not exist in OpenFeature SDK 0.4+
|
|
771
|
-
// Convert to a functional component and use useBooleanFlagValue() instead`,
|
|
772
|
-
requiresManualReview: true,
|
|
773
|
-
reviewReason: "withOpenFeature() HOC does not exist in OpenFeature SDK 0.4+; convert to a functional component with hooks"
|
|
774
|
-
};
|
|
775
|
-
case "provider":
|
|
776
|
-
return {
|
|
777
|
-
usage,
|
|
778
|
-
openFeatureEquivalent: "OpenFeatureProvider",
|
|
779
|
-
codeChangeBefore: `<LDProvider clientSideID="...">`,
|
|
780
|
-
codeChangeAfter: `<OpenFeatureProvider provider={...}>`,
|
|
781
|
-
requiresManualReview: false
|
|
782
|
-
};
|
|
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";
|
|
783
1016
|
default:
|
|
784
|
-
|
|
1017
|
+
return "manual review required";
|
|
785
1018
|
}
|
|
786
1019
|
}
|
|
787
|
-
function
|
|
788
|
-
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
+
};
|
|
800
1045
|
}
|
|
801
|
-
function
|
|
802
|
-
|
|
803
|
-
const
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
pkgs.add("@openfeature/react-sdk");
|
|
810
|
-
} else if (hasReactUsage && hasServerUsage) {
|
|
811
|
-
pkgs.add("@openfeature/server-sdk");
|
|
812
|
-
pkgs.add("@openfeature/web-sdk");
|
|
813
|
-
pkgs.add("@openfeature/react-sdk");
|
|
814
|
-
} else {
|
|
815
|
-
pkgs.add("@openfeature/server-sdk");
|
|
816
|
-
}
|
|
817
|
-
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"] : [];
|
|
818
1054
|
}
|
|
819
1055
|
function analyze(result) {
|
|
820
|
-
const
|
|
821
|
-
const
|
|
822
|
-
const
|
|
823
|
-
const
|
|
824
|
-
const
|
|
825
|
-
|
|
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
|
+
};
|
|
826
1084
|
}
|
|
827
1085
|
function formatMigrationReport(analysis) {
|
|
828
|
-
const {
|
|
1086
|
+
const {
|
|
1087
|
+
requiredPackages,
|
|
1088
|
+
inventoryItems,
|
|
1089
|
+
totalLaunchDarklyUsages,
|
|
1090
|
+
safelyAutomatableCount,
|
|
1091
|
+
manualReviewCount,
|
|
1092
|
+
dynamicKeyCount,
|
|
1093
|
+
bulkInventoryCallCount,
|
|
1094
|
+
unsupportedUnknownCount
|
|
1095
|
+
} = analysis;
|
|
829
1096
|
const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
|
|
830
|
-
const version = true ? "0.
|
|
831
|
-
let scoreLabel;
|
|
832
|
-
if (readinessScore >= 80) scoreLabel = "\u2713 Your codebase is ready for migration";
|
|
833
|
-
else if (readinessScore >= 50) scoreLabel = "\u26A0 Some manual work required before migration";
|
|
834
|
-
else scoreLabel = "\u2717 Significant refactoring needed";
|
|
1097
|
+
const version = true ? "0.4.0" : "0.1.0";
|
|
835
1098
|
const lines = [];
|
|
836
|
-
lines.push(
|
|
1099
|
+
lines.push("# OpenFeature Migration Inventory");
|
|
837
1100
|
lines.push(`Generated by FlagLint v${version} on ${date}`);
|
|
838
1101
|
lines.push("");
|
|
839
|
-
lines.push(
|
|
840
|
-
lines.push(
|
|
841
|
-
lines.push(
|
|
842
|
-
lines.push(`**
|
|
843
|
-
lines.push(`**
|
|
844
|
-
lines.push(
|
|
845
|
-
lines.push(
|
|
846
|
-
lines.push("```");
|
|
847
|
-
lines.push(`npm install ${requiredPackages.join(" ")}`);
|
|
848
|
-
lines.push("```");
|
|
849
|
-
lines.push("");
|
|
850
|
-
lines.push("## Step-by-Step Checklist");
|
|
851
|
-
lines.push("- [ ] Install OpenFeature packages");
|
|
852
|
-
lines.push("- [ ] Configure your OpenFeature provider (LaunchDarkly, Unleash, etc.)");
|
|
853
|
-
lines.push("- [ ] Replace LDProvider with OpenFeatureProvider");
|
|
854
|
-
lines.push("- [ ] Update each flag evaluation call (see below)");
|
|
855
|
-
lines.push("- [ ] Remove LaunchDarkly SDK dependency");
|
|
856
|
-
lines.push("- [ ] Test all flagged features");
|
|
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}`);
|
|
857
1109
|
lines.push("");
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
+
);
|
|
874
1135
|
}
|
|
1136
|
+
lines.push("");
|
|
875
1137
|
}
|
|
876
1138
|
if (manualItems.length > 0) {
|
|
877
1139
|
lines.push("## Manual Review Required");
|
|
878
1140
|
for (const item of manualItems) {
|
|
879
|
-
const
|
|
880
|
-
lines.push(
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
lines.push(
|
|
884
|
-
lines.push(item.
|
|
885
|
-
lines.push("```");
|
|
886
|
-
lines.push("**After:**");
|
|
887
|
-
lines.push("```typescript");
|
|
888
|
-
lines.push(item.codeChangeAfter);
|
|
889
|
-
lines.push("```");
|
|
890
|
-
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}\``);
|
|
891
1147
|
}
|
|
1148
|
+
lines.push("");
|
|
892
1149
|
}
|
|
893
1150
|
lines.push("## Resources");
|
|
894
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");
|
|
895
1258
|
lines.push(
|
|
896
|
-
"
|
|
1259
|
+
"npm install @openfeature/server-sdk @launchdarkly/node-server-sdk @launchdarkly/openfeature-node-server"
|
|
897
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:");
|
|
898
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("");
|
|
1305
|
+
lines.push(
|
|
1306
|
+
"These diffs use the placeholder `openFeatureClient` and require OpenFeature provider/client setup before they can be applied."
|
|
1307
|
+
);
|
|
1308
|
+
lines.push("No files are modified by dry-run output.");
|
|
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
|
+
}
|
|
899
1335
|
return lines.join("\n");
|
|
900
1336
|
}
|
|
901
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
|
+
|
|
902
1537
|
// src/commands/migrate.ts
|
|
903
1538
|
function registerMigrateCommand(program2) {
|
|
904
|
-
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
|
|
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(
|
|
905
1540
|
"after",
|
|
906
1541
|
`
|
|
907
1542
|
Examples:
|
|
908
1543
|
$ flaglint migrate generate migration plan for current directory
|
|
909
1544
|
$ flaglint migrate ./src analyze specific directory
|
|
910
|
-
$ flaglint migrate --dry-run preview without writing
|
|
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
|
|
911
1548
|
$ flaglint migrate --output plan.md write to custom file
|
|
912
1549
|
$ flaglint migrate --exclude-tests skip test and spec files`
|
|
913
1550
|
).action(
|
|
914
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
|
+
}
|
|
915
1556
|
try {
|
|
916
|
-
const s = await stat2(
|
|
1557
|
+
const s = await stat2(resolve5(dir));
|
|
917
1558
|
if (!s.isDirectory()) {
|
|
918
1559
|
process.stderr.write(chalk2.red(`Error: Not a directory: ${dir}
|
|
919
1560
|
`));
|
|
@@ -954,12 +1595,13 @@ Examples:
|
|
|
954
1595
|
spinner.stop();
|
|
955
1596
|
process.exit(130);
|
|
956
1597
|
});
|
|
1598
|
+
const source = new LocalFileSource(dir);
|
|
957
1599
|
let scanResult;
|
|
958
1600
|
try {
|
|
959
|
-
scanResult = await scan(
|
|
1601
|
+
scanResult = await scan(source, scanConfig, (filesScanned) => {
|
|
960
1602
|
spinner.text = `Scanning files... ${filesScanned}`;
|
|
961
1603
|
});
|
|
962
|
-
spinner.text = "Analyzing migration
|
|
1604
|
+
spinner.text = "Analyzing migration inventory...";
|
|
963
1605
|
} catch (err) {
|
|
964
1606
|
spinner.fail("Scan failed");
|
|
965
1607
|
process.stderr.write(chalk2.red(String(err)) + "\n");
|
|
@@ -988,24 +1630,69 @@ Examples:
|
|
|
988
1630
|
const msg = w.kind === "read-failure" ? `warn: could not read ${w.file} (${w.fsCode})` : `warn: failed to parse ${w.file}`;
|
|
989
1631
|
process.stderr.write(chalk2.yellow(msg + "\n"));
|
|
990
1632
|
}
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
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}
|
|
994
1635
|
`));
|
|
995
1636
|
process.stderr.write(
|
|
996
1637
|
chalk2.gray(
|
|
997
|
-
`
|
|
1638
|
+
`Safely automatable: ${analysis.safelyAutomatableCount} \xB7 Manual review: ${analysis.manualReviewCount}
|
|
998
1639
|
`
|
|
999
1640
|
)
|
|
1000
1641
|
);
|
|
1001
|
-
const report = formatMigrationReport(analysis);
|
|
1002
1642
|
if (options.dryRun) {
|
|
1003
|
-
|
|
1643
|
+
const report2 = await formatDryRunDiff(analysis, source);
|
|
1644
|
+
process.stdout.write(report2 + "\n");
|
|
1004
1645
|
process.exit(0);
|
|
1005
1646
|
}
|
|
1006
|
-
|
|
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);
|
|
1693
|
+
const outPath = resolve5(options.output);
|
|
1007
1694
|
try {
|
|
1008
|
-
await
|
|
1695
|
+
await writeFile3(outPath, report, "utf8");
|
|
1009
1696
|
process.stderr.write(chalk2.green(`Migration plan written to ${options.output}
|
|
1010
1697
|
`));
|
|
1011
1698
|
} catch (err) {
|
|
@@ -1022,10 +1709,179 @@ Examples:
|
|
|
1022
1709
|
);
|
|
1023
1710
|
}
|
|
1024
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
|
+
|
|
1025
1881
|
// src/cli.ts
|
|
1026
1882
|
function createCLI() {
|
|
1027
1883
|
const program2 = new Command();
|
|
1028
|
-
program2.name("flaglint").description("
|
|
1884
|
+
program2.name("flaglint").description("LaunchDarkly Node.js server SDK -> OpenFeature migration.").version("0.4.0", "-v, --version", "output the current version").addHelpText(
|
|
1029
1885
|
"after",
|
|
1030
1886
|
`
|
|
1031
1887
|
Examples:
|
|
@@ -1034,10 +1890,12 @@ Examples:
|
|
|
1034
1890
|
$ flaglint scan --format json output as JSON
|
|
1035
1891
|
$ flaglint scan --output report.md save to file
|
|
1036
1892
|
$ flaglint migrate generate migration plan
|
|
1037
|
-
$ 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`
|
|
1038
1895
|
);
|
|
1039
1896
|
registerScanCommand(program2);
|
|
1040
1897
|
registerMigrateCommand(program2);
|
|
1898
|
+
registerValidateCommand(program2);
|
|
1041
1899
|
return program2;
|
|
1042
1900
|
}
|
|
1043
1901
|
|