flaglint 0.4.0 → 0.5.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 +92 -1
- package/README.md +163 -34
- package/dist/apply-ZYLA2N7Y.js +13 -0
- package/dist/bin/flaglint.js +295 -234
- package/dist/chunk-MJLXM6GZ.js +263 -0
- package/package.json +7 -2
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/migrator/apply.ts
|
|
4
|
+
import { execFile } from "child_process";
|
|
5
|
+
import { promisify } from "util";
|
|
6
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
7
|
+
import micromatch from "micromatch";
|
|
8
|
+
function moduleSpecifierMatchesGlob(specifier, pattern) {
|
|
9
|
+
const normalizeRelative = (value) => value.replace(/^(?:\.\.?\/)+/, "");
|
|
10
|
+
const normalizedSpecifier = normalizeRelative(specifier);
|
|
11
|
+
const normalizedPattern = normalizeRelative(pattern);
|
|
12
|
+
if (micromatch.isMatch(normalizedSpecifier, normalizedPattern)) return true;
|
|
13
|
+
if (!normalizedSpecifier.endsWith(".js")) return false;
|
|
14
|
+
return micromatch.isMatch(normalizedSpecifier.slice(0, -3), normalizedPattern);
|
|
15
|
+
}
|
|
16
|
+
var execFileAsync = promisify(execFile);
|
|
17
|
+
var ApplyError = class extends Error {
|
|
18
|
+
constructor(kind, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.kind = kind;
|
|
21
|
+
this.name = "ApplyError";
|
|
22
|
+
}
|
|
23
|
+
kind;
|
|
24
|
+
};
|
|
25
|
+
var OF_SERVER_SDK = "@openfeature/server-sdk";
|
|
26
|
+
function tryParse(code) {
|
|
27
|
+
for (const jsx of [false, true]) {
|
|
28
|
+
try {
|
|
29
|
+
return parse(code, { jsx, comment: false });
|
|
30
|
+
} catch {
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function walkNodes(root, visit) {
|
|
36
|
+
const stack = [root];
|
|
37
|
+
while (stack.length > 0) {
|
|
38
|
+
const node = stack.pop();
|
|
39
|
+
visit(node);
|
|
40
|
+
for (const val of Object.values(node)) {
|
|
41
|
+
if (Array.isArray(val)) {
|
|
42
|
+
for (const item of val) {
|
|
43
|
+
if (item !== null && typeof item === "object" && "type" in item) {
|
|
44
|
+
stack.push(item);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} else if (val !== null && typeof val === "object" && "type" in val) {
|
|
48
|
+
stack.push(val);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function getOpenFeatureClientBindingName(code, allowedBindings = []) {
|
|
54
|
+
const ast = tryParse(code);
|
|
55
|
+
if (!ast) return null;
|
|
56
|
+
const ofApiNames = /* @__PURE__ */ new Set();
|
|
57
|
+
for (const stmt of ast.body) {
|
|
58
|
+
if (stmt.type === "ImportDeclaration" && stmt.source.value === OF_SERVER_SDK) {
|
|
59
|
+
for (const spec of stmt.specifiers) {
|
|
60
|
+
if (spec.type === "ImportSpecifier") {
|
|
61
|
+
const importedName = spec.imported.type === "Identifier" ? spec.imported.name : spec.imported.value;
|
|
62
|
+
if (importedName === "OpenFeature") {
|
|
63
|
+
ofApiNames.add(spec.local.name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (stmt.type === "VariableDeclaration") {
|
|
70
|
+
for (const decl of stmt.declarations) {
|
|
71
|
+
const init = decl.init;
|
|
72
|
+
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") {
|
|
73
|
+
for (const prop of decl.id.properties) {
|
|
74
|
+
if (prop.type === "Property" && prop.key.type === "Identifier" && prop.key.name === "OpenFeature" && prop.value.type === "Identifier") {
|
|
75
|
+
ofApiNames.add(prop.value.name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const candidates = [];
|
|
83
|
+
walkNodes(ast, (node) => {
|
|
84
|
+
if (node.type !== "VariableDeclarator") return;
|
|
85
|
+
const decl = node;
|
|
86
|
+
if (decl.id.type !== "Identifier") return;
|
|
87
|
+
if (decl.init?.type !== "CallExpression") return;
|
|
88
|
+
const call = decl.init;
|
|
89
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
90
|
+
const member = call.callee;
|
|
91
|
+
if (member.computed) return;
|
|
92
|
+
if (member.object.type !== "Identifier") return;
|
|
93
|
+
if (!ofApiNames.has(member.object.name)) return;
|
|
94
|
+
if (member.property.type !== "Identifier") return;
|
|
95
|
+
if (member.property.name !== "getClient") return;
|
|
96
|
+
candidates.push(decl.id.name);
|
|
97
|
+
});
|
|
98
|
+
if (candidates.length === 1) return candidates[0];
|
|
99
|
+
if (candidates.length > 1) return null;
|
|
100
|
+
for (const stmt of ast.body) {
|
|
101
|
+
if (stmt.type === "ImportDeclaration") {
|
|
102
|
+
const source = String(stmt.source.value || "");
|
|
103
|
+
if (!source.startsWith(".")) continue;
|
|
104
|
+
for (const spec of stmt.specifiers) {
|
|
105
|
+
if (spec.type === "ImportSpecifier") {
|
|
106
|
+
const importedName = spec.imported.type === "Identifier" ? spec.imported.name : spec.imported.value;
|
|
107
|
+
const allowed = allowedBindings.find((b) => b.importName === importedName);
|
|
108
|
+
if (!allowed) continue;
|
|
109
|
+
if (!allowed.modulePatterns.some((p) => moduleSpecifierMatchesGlob(source, p))) continue;
|
|
110
|
+
candidates.push(spec.local.name);
|
|
111
|
+
}
|
|
112
|
+
if (spec.type === "ImportDefaultSpecifier") {
|
|
113
|
+
const local = spec.local.name;
|
|
114
|
+
const allowed = allowedBindings.find((b) => b.importName === local);
|
|
115
|
+
if (!allowed) continue;
|
|
116
|
+
if (!allowed.modulePatterns.some((p) => moduleSpecifierMatchesGlob(source, p))) continue;
|
|
117
|
+
candidates.push(local);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (stmt.type === "VariableDeclaration") {
|
|
122
|
+
for (const decl of stmt.declarations) {
|
|
123
|
+
const init = decl.init;
|
|
124
|
+
if (init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "require" && init.arguments.length === 1 && init.arguments[0]?.type === "Literal") {
|
|
125
|
+
const source = String(init.arguments[0].value || "");
|
|
126
|
+
if (!source.startsWith(".")) continue;
|
|
127
|
+
if (decl.id.type === "ObjectPattern") {
|
|
128
|
+
for (const prop of decl.id.properties) {
|
|
129
|
+
if (prop.type !== "Property" || prop.key.type !== "Identifier") continue;
|
|
130
|
+
const importedName = prop.key.name;
|
|
131
|
+
const local = prop.value.type === "Identifier" ? prop.value.name : null;
|
|
132
|
+
if (!local) continue;
|
|
133
|
+
const allowed = allowedBindings.find((b) => b.importName === importedName);
|
|
134
|
+
if (!allowed) continue;
|
|
135
|
+
if (!allowed.modulePatterns.some((p) => moduleSpecifierMatchesGlob(source, p))) continue;
|
|
136
|
+
candidates.push(local);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (candidates.length === 1) return candidates[0];
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
function hasOpenFeatureClientBinding(code) {
|
|
147
|
+
return getOpenFeatureClientBindingName(code) != null;
|
|
148
|
+
}
|
|
149
|
+
var DETAIL_METHODS = /* @__PURE__ */ new Set([
|
|
150
|
+
"variationDetail",
|
|
151
|
+
"boolVariationDetail",
|
|
152
|
+
"stringVariationDetail",
|
|
153
|
+
"numberVariationDetail",
|
|
154
|
+
"jsonVariationDetail"
|
|
155
|
+
]);
|
|
156
|
+
function methodForType(valueType) {
|
|
157
|
+
switch (valueType) {
|
|
158
|
+
case "boolean":
|
|
159
|
+
return "getBooleanValue";
|
|
160
|
+
case "string":
|
|
161
|
+
return "getStringValue";
|
|
162
|
+
case "number":
|
|
163
|
+
return "getNumberValue";
|
|
164
|
+
case "object":
|
|
165
|
+
return "getObjectValue";
|
|
166
|
+
case "unknown":
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function buildReplacement(item, code, clientBindingName) {
|
|
171
|
+
if (DETAIL_METHODS.has(item.launchDarklyMethod)) {
|
|
172
|
+
return {
|
|
173
|
+
item,
|
|
174
|
+
reason: "detail methods skipped: OpenFeature detail APIs exist, but LaunchDarkly/OpenFeature detail result parity requires manual review"
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (!item.safelyAutomatable) {
|
|
178
|
+
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";
|
|
179
|
+
return { item, reason };
|
|
180
|
+
}
|
|
181
|
+
if (item.rangeStart == null || item.rangeEnd == null || !item.callExpression) {
|
|
182
|
+
return { item, reason: "missing source range for apply" };
|
|
183
|
+
}
|
|
184
|
+
const currentText = code.slice(item.rangeStart, item.rangeEnd);
|
|
185
|
+
if (currentText !== item.callExpression) {
|
|
186
|
+
return {
|
|
187
|
+
item,
|
|
188
|
+
reason: "range content does not match original call \u2014 already transformed or stale analysis; skipping"
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (!item.flagKeyExpression || !item.fallbackExpression || !item.evaluationContextExpression) {
|
|
192
|
+
return { item, reason: "missing flag key, fallback, or evaluation context evidence" };
|
|
193
|
+
}
|
|
194
|
+
const method = methodForType(item.valueType);
|
|
195
|
+
if (!method) return { item, reason: "unsupported or unknown value type" };
|
|
196
|
+
const call = `${clientBindingName}.${method}(${item.flagKeyExpression}, ${item.fallbackExpression}, ${item.evaluationContextExpression})`;
|
|
197
|
+
return { item, replacement: call };
|
|
198
|
+
}
|
|
199
|
+
function applyReplacements(code, replacements) {
|
|
200
|
+
let next = code;
|
|
201
|
+
for (const r of [...replacements].sort((a, b) => b.item.rangeStart - a.item.rangeStart)) {
|
|
202
|
+
next = next.slice(0, r.item.rangeStart) + r.replacement + next.slice(r.item.rangeEnd);
|
|
203
|
+
}
|
|
204
|
+
return next;
|
|
205
|
+
}
|
|
206
|
+
async function defaultIsWorkingTreeDirty() {
|
|
207
|
+
try {
|
|
208
|
+
const { stdout } = await execFileAsync("git", ["status", "--porcelain"]);
|
|
209
|
+
return stdout.trim().length > 0;
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function applyMigration(analysis, source, options = {}) {
|
|
215
|
+
if (!options.allowDirty) {
|
|
216
|
+
const checkDirty = options.isWorkingTreeDirty ?? defaultIsWorkingTreeDirty;
|
|
217
|
+
if (await checkDirty()) {
|
|
218
|
+
throw new ApplyError(
|
|
219
|
+
"dirty-tree",
|
|
220
|
+
"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."
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const itemsByFile = /* @__PURE__ */ new Map();
|
|
225
|
+
for (const item of analysis.inventoryItems) {
|
|
226
|
+
if (!itemsByFile.has(item.file)) itemsByFile.set(item.file, []);
|
|
227
|
+
itemsByFile.get(item.file).push(item);
|
|
228
|
+
}
|
|
229
|
+
const transformedFiles = [];
|
|
230
|
+
const skippedFiles = [];
|
|
231
|
+
let transformed = 0;
|
|
232
|
+
for (const [file, items] of [...itemsByFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
233
|
+
const code = await source.readFile(file);
|
|
234
|
+
const bindingName = getOpenFeatureClientBindingName(code, options.allowedOpenFeatureClientBindings ?? []);
|
|
235
|
+
if (!bindingName) {
|
|
236
|
+
skippedFiles.push({
|
|
237
|
+
file,
|
|
238
|
+
reason: "skipped \u2014 OpenFeature client setup required. Review `flaglint migrate --dry-run` provider guidance first."
|
|
239
|
+
});
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const replacements = [];
|
|
243
|
+
for (const item of items) {
|
|
244
|
+
const result = buildReplacement(item, code, bindingName);
|
|
245
|
+
if ("reason" in result) continue;
|
|
246
|
+
replacements.push(result);
|
|
247
|
+
}
|
|
248
|
+
if (replacements.length === 0) continue;
|
|
249
|
+
const newCode = applyReplacements(code, replacements);
|
|
250
|
+
if (newCode === code) continue;
|
|
251
|
+
await source.writeFile(file, newCode);
|
|
252
|
+
transformedFiles.push(file);
|
|
253
|
+
transformed += replacements.length;
|
|
254
|
+
}
|
|
255
|
+
return { transformed, skipped: skippedFiles.length, transformedFiles, skippedFiles };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export {
|
|
259
|
+
ApplyError,
|
|
260
|
+
getOpenFeatureClientBindingName,
|
|
261
|
+
hasOpenFeatureClientBinding,
|
|
262
|
+
applyMigration
|
|
263
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flaglint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "LaunchDarkly Node.js server SDK -> OpenFeature migration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"LICENSE"
|
|
14
14
|
],
|
|
15
15
|
"engines": {
|
|
16
|
-
"node": ">=
|
|
16
|
+
"node": ">=20"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
19
19
|
"feature-flags",
|
|
@@ -39,8 +39,11 @@
|
|
|
39
39
|
"dev": "tsup --watch",
|
|
40
40
|
"typecheck": "tsc --noEmit",
|
|
41
41
|
"typecheck:agent": "tsc --project tsconfig.agent.json",
|
|
42
|
+
"pretest": "npm run build",
|
|
42
43
|
"test": "vitest",
|
|
44
|
+
"pretest:run": "npm run build",
|
|
43
45
|
"test:run": "vitest run",
|
|
46
|
+
"pretest:coverage": "npm run build",
|
|
44
47
|
"test:coverage": "vitest run --coverage",
|
|
45
48
|
"new-branch": "tsx scripts/new-branch.ts",
|
|
46
49
|
"release:patch": "tsx scripts/release.ts patch",
|
|
@@ -53,11 +56,13 @@
|
|
|
53
56
|
"chalk": "^5.3.0",
|
|
54
57
|
"commander": "^12.1.0",
|
|
55
58
|
"fast-glob": "^3.3.2",
|
|
59
|
+
"micromatch": "^4.0.8",
|
|
56
60
|
"ora": "^8.1.0",
|
|
57
61
|
"p-limit": "^7.3.0",
|
|
58
62
|
"zod": "^3.23.8"
|
|
59
63
|
},
|
|
60
64
|
"devDependencies": {
|
|
65
|
+
"@types/micromatch": "^4.0.10",
|
|
61
66
|
"@types/node": "^22.0.0",
|
|
62
67
|
"@vitest/coverage-v8": "^4.1.6",
|
|
63
68
|
"clipboardy": "^4.0.0",
|