flaglint 0.4.1 → 0.5.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 +99 -1
- package/README.md +159 -30
- package/dist/apply-ZYLA2N7Y.js +13 -0
- package/dist/bin/flaglint.js +298 -237
- package/dist/chunk-MJLXM6GZ.js +263 -0
- package/package.json +7 -2
package/dist/bin/flaglint.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ApplyError,
|
|
4
|
+
applyMigration
|
|
5
|
+
} from "../chunk-MJLXM6GZ.js";
|
|
2
6
|
|
|
3
7
|
// src/cli.ts
|
|
4
8
|
import { Command } from "commander";
|
|
@@ -672,6 +676,16 @@ function formatSARIF(result) {
|
|
|
672
676
|
2
|
|
673
677
|
);
|
|
674
678
|
}
|
|
679
|
+
function usagesByDirectory(usages) {
|
|
680
|
+
const map = /* @__PURE__ */ new Map();
|
|
681
|
+
for (const u of usages) {
|
|
682
|
+
const parts = u.file.replace(/\\/g, "/").split("/");
|
|
683
|
+
const dir = parts.length > 1 ? parts.slice(0, Math.min(2, parts.length - 1)).join("/") : ".";
|
|
684
|
+
if (!map.has(dir)) map.set(dir, []);
|
|
685
|
+
map.get(dir).push(u);
|
|
686
|
+
}
|
|
687
|
+
return new Map([...map.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
688
|
+
}
|
|
675
689
|
function formatHTML(result, options) {
|
|
676
690
|
const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
|
|
677
691
|
const staleCount = new Set(
|
|
@@ -679,6 +693,34 @@ function formatHTML(result, options) {
|
|
|
679
693
|
).size;
|
|
680
694
|
const dynamicCount = usages.filter((u) => u.isDynamic).length;
|
|
681
695
|
const date = new Date(result.scannedAt).toLocaleString();
|
|
696
|
+
const inv = result.migrationInventory ?? [];
|
|
697
|
+
const automatableCount = inv.filter((i) => i.safelyAutomatable).length;
|
|
698
|
+
const manualCount = inv.filter((i) => !i.safelyAutomatable).length;
|
|
699
|
+
const detailBulkCount = inv.filter(
|
|
700
|
+
(i) => i.manualReviewReason === "detail-method" || i.manualReviewReason === "bulk-inventory-call"
|
|
701
|
+
).length;
|
|
702
|
+
const affectedFiles = new Set(usages.map((u) => u.file)).size;
|
|
703
|
+
const automatablePct = inv.length > 0 ? Math.round(automatableCount / inv.length * 100) : 0;
|
|
704
|
+
const manualPct = inv.length > 0 ? Math.round(manualCount / inv.length * 100) : 0;
|
|
705
|
+
const markdownSummary = [
|
|
706
|
+
"## FlagLint Audit Summary",
|
|
707
|
+
"",
|
|
708
|
+
`- **Total call-sites:** ${totalUsages}`,
|
|
709
|
+
`- **Unique flags:** ${uniqueFlags.length}`,
|
|
710
|
+
`- **Files affected:** ${affectedFiles}`,
|
|
711
|
+
...inv.length > 0 ? [
|
|
712
|
+
`- **Safely automatable:** ${automatableCount} (${automatablePct}%)`,
|
|
713
|
+
`- **Manual review required:** ${manualCount} (${manualPct}%)`,
|
|
714
|
+
`- **Dynamic keys:** ${dynamicCount}`,
|
|
715
|
+
`- **Detail/bulk calls:** ${detailBulkCount}`
|
|
716
|
+
] : [`- **Dynamic keys:** ${dynamicCount}`, `- **Stale candidates:** ${staleCount}`],
|
|
717
|
+
"",
|
|
718
|
+
"### Recommended next steps",
|
|
719
|
+
"1. Configure OpenFeature provider (one-time manual step)",
|
|
720
|
+
"2. Review migration plan: `flaglint migrate --dry-run`",
|
|
721
|
+
"3. Apply automatable transformations: `flaglint migrate --apply`",
|
|
722
|
+
"4. Add CI enforcement: `flaglint validate --no-direct-launchdarkly`"
|
|
723
|
+
].join("\\n");
|
|
682
724
|
const flagMap = buildFlagMap(usages);
|
|
683
725
|
const sorted = sortedFlagEntries(flagMap);
|
|
684
726
|
const rows = sorted.map(([key, data]) => {
|
|
@@ -687,8 +729,18 @@ function formatHTML(result, options) {
|
|
|
687
729
|
const fileList = [...data.files].map((f) => esc(f)).join("<br>");
|
|
688
730
|
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>`;
|
|
689
731
|
}).join("\n ");
|
|
732
|
+
const byDir = usagesByDirectory(usages);
|
|
733
|
+
const dirRows = [...byDir.entries()].map(([dir, dirUsages]) => {
|
|
734
|
+
const flagKeys = new Set(dirUsages.map((u) => u.flagKey)).size;
|
|
735
|
+
const callTypes = new Set(dirUsages.map((u) => u.callType));
|
|
736
|
+
return `<tr><td><code>${esc(dir)}</code></td><td>${dirUsages.length}</td><td>${flagKeys}</td><td>${[...callTypes].map(esc).join(", ")}</td></tr>`;
|
|
737
|
+
}).join("\n ");
|
|
738
|
+
const auditCards = inv.length > 0 ? `
|
|
739
|
+
<div class="card"><div class="card-num green">${automatableCount}</div><div class="card-label">Auto-Migratable (${automatablePct}%)</div></div>
|
|
740
|
+
<div class="card"><div class="card-num orange">${manualCount}</div><div class="card-label">Manual Review (${manualPct}%)</div></div>
|
|
741
|
+
<div class="card"><div class="card-num blue">${detailBulkCount}</div><div class="card-label">Detail/Bulk Calls</div></div>` : "";
|
|
690
742
|
const title = options.title ? esc(options.title) : "FlagLint Scan Report";
|
|
691
|
-
const version = true ? "0.
|
|
743
|
+
const version = true ? "0.5.1" : "0.1.0";
|
|
692
744
|
return `<!DOCTYPE html>
|
|
693
745
|
<html lang="en">
|
|
694
746
|
<head>
|
|
@@ -702,12 +754,15 @@ function formatHTML(result, options) {
|
|
|
702
754
|
body{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;padding:2rem;max-width:1200px;margin:0 auto;line-height:1.5}
|
|
703
755
|
h1{font-size:1.75rem;margin-bottom:.25rem}
|
|
704
756
|
h2{font-size:1.125rem;margin:2rem 0 .75rem;padding-bottom:.5rem;border-bottom:1px solid var(--border)}
|
|
757
|
+
h3{font-size:.9375rem;margin:1.5rem 0 .5rem;color:var(--muted)}
|
|
705
758
|
.subtitle{color:var(--muted);margin-bottom:1.5rem;font-size:.875rem}
|
|
706
759
|
.cards{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
|
|
707
760
|
.card{flex:1;min-width:140px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;box-shadow:var(--card-shadow)}
|
|
708
761
|
.card-num{font-size:1.875rem;font-weight:700;line-height:1}
|
|
709
762
|
.card-num.yellow{color:#d97706}
|
|
710
763
|
.card-num.blue{color:#3b82f6}
|
|
764
|
+
.card-num.green{color:#16a34a}
|
|
765
|
+
.card-num.orange{color:#ea580c}
|
|
711
766
|
.card-label{color:var(--muted);font-size:.75rem;margin-top:.375rem;text-transform:uppercase;letter-spacing:.05em}
|
|
712
767
|
.filter-wrap{margin-bottom:.75rem}
|
|
713
768
|
#filter{width:100%;padding:.5rem .75rem;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font-size:.875rem;outline:none}
|
|
@@ -718,19 +773,25 @@ function formatHTML(result, options) {
|
|
|
718
773
|
tr.stale td{background:var(--stale-bg)}
|
|
719
774
|
tr.dynamic td{background:var(--dyn-bg)}
|
|
720
775
|
code{font-family:ui-monospace,monospace;font-size:.8em;background:var(--surface);padding:.1em .3em;border-radius:3px}
|
|
776
|
+
.steps{margin:.75rem 0 1rem 1.25rem;line-height:2}
|
|
777
|
+
.steps li{margin-bottom:.25rem}
|
|
778
|
+
.btn{display:inline-flex;align-items:center;gap:.4rem;background:#6366f1;color:#fff;border:none;border-radius:6px;padding:.5rem 1rem;font-size:.8125rem;cursor:pointer;margin-top:.75rem}
|
|
779
|
+
.btn:hover{background:#4f46e5}
|
|
780
|
+
.btn.copied{background:#16a34a}
|
|
721
781
|
footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border);color:var(--muted);font-size:.75rem;text-align:center}
|
|
722
782
|
</style>
|
|
723
783
|
</head>
|
|
724
784
|
<body>
|
|
725
785
|
<h1>${title}</h1>
|
|
726
|
-
<p class="subtitle">Scanned ${scannedFiles} files in ${scanDurationMs}ms</p>
|
|
786
|
+
<p class="subtitle">Scanned ${scannedFiles} files in ${scanDurationMs}ms \xB7 ${esc(date)}</p>
|
|
727
787
|
|
|
788
|
+
<h2>Executive Summary</h2>
|
|
728
789
|
<div class="cards">
|
|
729
|
-
<div class="card"><div class="card-num">${
|
|
790
|
+
<div class="card"><div class="card-num">${totalUsages}</div><div class="card-label">Total Call-Sites</div></div>
|
|
730
791
|
<div class="card"><div class="card-num">${uniqueFlags.length}</div><div class="card-label">Unique Flags</div></div>
|
|
731
|
-
<div class="card"><div class="card-num">${
|
|
792
|
+
<div class="card"><div class="card-num">${affectedFiles}</div><div class="card-label">Files Affected</div></div>
|
|
732
793
|
<div class="card"><div class="card-num yellow">${staleCount}</div><div class="card-label">Stale Candidates</div></div>
|
|
733
|
-
<div class="card"><div class="card-num blue">${dynamicCount}</div><div class="card-label">Dynamic Keys</div></div
|
|
794
|
+
<div class="card"><div class="card-num blue">${dynamicCount}</div><div class="card-label">Dynamic Keys</div></div>${auditCards}
|
|
734
795
|
</div>
|
|
735
796
|
|
|
736
797
|
<h2>Flag Inventory</h2>
|
|
@@ -744,15 +805,41 @@ function formatHTML(result, options) {
|
|
|
744
805
|
</tbody>
|
|
745
806
|
</table>
|
|
746
807
|
|
|
747
|
-
<
|
|
808
|
+
<h2>Findings by Directory</h2>
|
|
809
|
+
<table id="dir-table">
|
|
810
|
+
<thead><tr><th>Directory</th><th>Call-Sites</th><th>Unique Flags</th><th>Call Types</th></tr></thead>
|
|
811
|
+
<tbody>
|
|
812
|
+
${dirRows}
|
|
813
|
+
</tbody>
|
|
814
|
+
</table>
|
|
815
|
+
|
|
816
|
+
<h2>Recommended Next Steps</h2>
|
|
817
|
+
<ol class="steps">
|
|
818
|
+
<li>Configure the OpenFeature provider once at application startup (manual \u2014 see <code>flaglint migrate --dry-run</code> for guidance)</li>
|
|
819
|
+
<li>Review the migration plan: <code>flaglint migrate --dry-run</code></li>
|
|
820
|
+
<li>Apply automatable transformations: <code>flaglint migrate --apply</code></li>
|
|
821
|
+
<li>Add CI policy enforcement: <code>flaglint validate --no-direct-launchdarkly</code></li>
|
|
822
|
+
</ol>
|
|
823
|
+
<button class="btn" id="copy-btn" onclick="copyMarkdown()">\u{1F4CB} Copy Markdown Summary</button>
|
|
824
|
+
|
|
825
|
+
<footer>Generated by FlagLint ${esc(version)}</footer>
|
|
748
826
|
|
|
749
827
|
<script>
|
|
750
828
|
const input = document.getElementById('filter');
|
|
751
|
-
const
|
|
829
|
+
const tableRows = document.querySelectorAll('#flags-table tbody tr');
|
|
752
830
|
input.addEventListener('input', () => {
|
|
753
831
|
const q = input.value.toLowerCase();
|
|
754
|
-
|
|
832
|
+
tableRows.forEach(r => { r.style.display = r.textContent.toLowerCase().includes(q) ? '' : 'none'; });
|
|
755
833
|
});
|
|
834
|
+
function copyMarkdown() {
|
|
835
|
+
const md = ${JSON.stringify(markdownSummary)}.replace(/\\\\n/g, '\\n');
|
|
836
|
+
navigator.clipboard.writeText(md).then(() => {
|
|
837
|
+
const btn = document.getElementById('copy-btn');
|
|
838
|
+
btn.textContent = '\u2713 Copied!';
|
|
839
|
+
btn.className = 'btn copied';
|
|
840
|
+
setTimeout(() => { btn.textContent = '\u{1F4CB} Copy Markdown Summary'; btn.className = 'btn'; }, 2000);
|
|
841
|
+
});
|
|
842
|
+
}
|
|
756
843
|
</script>
|
|
757
844
|
</body>
|
|
758
845
|
</html>`;
|
|
@@ -788,6 +875,9 @@ var FlagLintConfigSchema = z.object({
|
|
|
788
875
|
// TODO v0.3: replace minFileCount with real date-based staleness via git log
|
|
789
876
|
minFileCount: z.number().int().min(0).default(0),
|
|
790
877
|
wrappers: z.array(z.string()).default([]),
|
|
878
|
+
openFeatureClientBindings: z.array(
|
|
879
|
+
z.object({ importName: z.string(), modulePatterns: z.array(z.string()).default([]) })
|
|
880
|
+
).default([]),
|
|
791
881
|
reportTitle: z.string().optional(),
|
|
792
882
|
outputDir: z.string().default(".")
|
|
793
883
|
});
|
|
@@ -962,7 +1052,7 @@ Examples:
|
|
|
962
1052
|
} else {
|
|
963
1053
|
process.stdout.write(report + "\n");
|
|
964
1054
|
}
|
|
965
|
-
process.
|
|
1055
|
+
process.exitCode = staleCount > 0 ? 1 : 0;
|
|
966
1056
|
}
|
|
967
1057
|
);
|
|
968
1058
|
}
|
|
@@ -1103,7 +1193,7 @@ function formatMigrationReport(analysis) {
|
|
|
1103
1193
|
unsupportedUnknownCount
|
|
1104
1194
|
} = analysis;
|
|
1105
1195
|
const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
|
|
1106
|
-
const version = true ? "0.
|
|
1196
|
+
const version = true ? "0.5.1" : "0.1.0";
|
|
1107
1197
|
const lines = [];
|
|
1108
1198
|
lines.push("# OpenFeature Migration Inventory");
|
|
1109
1199
|
lines.push(`Generated by FlagLint v${version} on ${date}`);
|
|
@@ -1194,7 +1284,7 @@ function manualReason(item) {
|
|
|
1194
1284
|
if (item.manualReviewReason === "bulk-inventory-call") return "bulk inventory call has no single-flag codemod";
|
|
1195
1285
|
return "manual review required";
|
|
1196
1286
|
}
|
|
1197
|
-
function buildReplacement(item) {
|
|
1287
|
+
function buildReplacement(item, clientBindingName) {
|
|
1198
1288
|
if (DETAIL_METHODS.has(item.launchDarklyMethod)) {
|
|
1199
1289
|
return {
|
|
1200
1290
|
item,
|
|
@@ -1212,11 +1302,13 @@ function buildReplacement(item) {
|
|
|
1212
1302
|
}
|
|
1213
1303
|
const method = methodForType(item.valueType);
|
|
1214
1304
|
if (!method) return { item, reason: "unsupported or unknown value type" };
|
|
1215
|
-
const
|
|
1305
|
+
const placeholder = "openFeatureClient";
|
|
1306
|
+
const target = clientBindingName ?? placeholder;
|
|
1307
|
+
const call = `${target}.${method}(${item.flagKeyExpression}, ${item.fallbackExpression}, ${item.evaluationContextExpression})`;
|
|
1216
1308
|
return {
|
|
1217
1309
|
item,
|
|
1218
1310
|
replacement: call,
|
|
1219
|
-
requiresProviderSetup:
|
|
1311
|
+
requiresProviderSetup: clientBindingName == null
|
|
1220
1312
|
};
|
|
1221
1313
|
}
|
|
1222
1314
|
function applyReplacements(code, replacements) {
|
|
@@ -1257,7 +1349,7 @@ function itemLabel(item) {
|
|
|
1257
1349
|
}
|
|
1258
1350
|
function formatProviderSetupSection() {
|
|
1259
1351
|
const lines = [];
|
|
1260
|
-
lines.push("## Provider Setup (
|
|
1352
|
+
lines.push("## Provider Setup Guidance (For Diffs Requiring Setup)");
|
|
1261
1353
|
lines.push("");
|
|
1262
1354
|
lines.push("LaunchDarkly remains your feature flag provider.");
|
|
1263
1355
|
lines.push("OpenFeature becomes the evaluation API your application code calls.");
|
|
@@ -1284,7 +1376,7 @@ function formatProviderSetupSection() {
|
|
|
1284
1376
|
lines.push("await OpenFeature.setProviderAndWait(ldProvider);");
|
|
1285
1377
|
lines.push("");
|
|
1286
1378
|
lines.push("// Share this client across your application.");
|
|
1287
|
-
lines.push("// Replace the `openFeatureClient` placeholder in
|
|
1379
|
+
lines.push("// Replace the `openFeatureClient` placeholder in affected diffs.");
|
|
1288
1380
|
lines.push("const openFeatureClient = OpenFeature.getClient();");
|
|
1289
1381
|
lines.push("```");
|
|
1290
1382
|
lines.push("");
|
|
@@ -1302,33 +1394,58 @@ function formatProviderSetupSection() {
|
|
|
1302
1394
|
lines.push("");
|
|
1303
1395
|
return lines;
|
|
1304
1396
|
}
|
|
1305
|
-
async function formatDryRunDiff(analysis, source) {
|
|
1397
|
+
async function formatDryRunDiff(analysis, source, allowedBindings = []) {
|
|
1306
1398
|
const replacementsByFile = /* @__PURE__ */ new Map();
|
|
1307
1399
|
const skipped = [];
|
|
1400
|
+
const itemsByFile = /* @__PURE__ */ new Map();
|
|
1308
1401
|
for (const item of analysis.inventoryItems) {
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1402
|
+
if (!itemsByFile.has(item.file)) itemsByFile.set(item.file, []);
|
|
1403
|
+
itemsByFile.get(item.file).push(item);
|
|
1404
|
+
}
|
|
1405
|
+
for (const [file, items] of [...itemsByFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1406
|
+
const before = await source.readFile(file);
|
|
1407
|
+
const { getOpenFeatureClientBindingName } = await import("../apply-ZYLA2N7Y.js");
|
|
1408
|
+
const clientBindingName = getOpenFeatureClientBindingName(before, allowedBindings);
|
|
1409
|
+
for (const item of items) {
|
|
1410
|
+
const result = buildReplacement(item, clientBindingName);
|
|
1411
|
+
if ("reason" in result) {
|
|
1412
|
+
skipped.push(result);
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
if (!replacementsByFile.has(item.file)) replacementsByFile.set(item.file, []);
|
|
1416
|
+
replacementsByFile.get(item.file).push(result);
|
|
1313
1417
|
}
|
|
1314
|
-
if (!replacementsByFile.has(item.file)) replacementsByFile.set(item.file, []);
|
|
1315
|
-
replacementsByFile.get(item.file).push(result);
|
|
1316
1418
|
}
|
|
1317
1419
|
const lines = [];
|
|
1420
|
+
const reviewableDiffCount = [...replacementsByFile.values()].reduce((sum, items) => sum + items.length, 0);
|
|
1421
|
+
const setupRequiredCount = [...replacementsByFile.values()].reduce(
|
|
1422
|
+
(sum, items) => sum + items.filter((item) => item.requiresProviderSetup).length,
|
|
1423
|
+
0
|
|
1424
|
+
);
|
|
1318
1425
|
lines.push("# FlagLint migrate --dry-run");
|
|
1319
1426
|
lines.push("");
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1427
|
+
if (reviewableDiffCount > 0 && setupRequiredCount === 0) {
|
|
1428
|
+
lines.push(
|
|
1429
|
+
"The transformations below use proven OpenFeature client bindings already present in the affected files."
|
|
1430
|
+
);
|
|
1431
|
+
} else if (setupRequiredCount > 0 && setupRequiredCount === reviewableDiffCount) {
|
|
1432
|
+
lines.push(
|
|
1433
|
+
"These diffs use the placeholder `openFeatureClient` and require OpenFeature provider/client setup before they can be applied."
|
|
1434
|
+
);
|
|
1435
|
+
} else if (setupRequiredCount > 0) {
|
|
1436
|
+
lines.push(
|
|
1437
|
+
"Some diffs use proven OpenFeature client bindings; diffs using the `openFeatureClient` placeholder require provider/client setup before they can be applied."
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1323
1440
|
lines.push("No files are modified by dry-run output.");
|
|
1324
1441
|
lines.push("");
|
|
1325
|
-
lines.push(`Reviewable diffs: ${
|
|
1326
|
-
lines.push(
|
|
1327
|
-
`Diffs requiring provider setup: ${[...replacementsByFile.values()].reduce((sum, items) => sum + items.filter((item) => item.requiresProviderSetup).length, 0)}`
|
|
1328
|
-
);
|
|
1442
|
+
lines.push(`Reviewable diffs: ${reviewableDiffCount}`);
|
|
1443
|
+
lines.push(`Diffs requiring provider setup: ${setupRequiredCount}`);
|
|
1329
1444
|
lines.push(`Skipped usages: ${skipped.length}`);
|
|
1330
1445
|
lines.push("");
|
|
1331
|
-
|
|
1446
|
+
if (setupRequiredCount > 0) {
|
|
1447
|
+
lines.push(...formatProviderSetupSection());
|
|
1448
|
+
}
|
|
1332
1449
|
if (replacementsByFile.size > 0) {
|
|
1333
1450
|
lines.push("## Diffs");
|
|
1334
1451
|
lines.push("```diff");
|
|
@@ -1350,205 +1467,6 @@ async function formatDryRunDiff(analysis, source) {
|
|
|
1350
1467
|
return lines.join("\n");
|
|
1351
1468
|
}
|
|
1352
1469
|
|
|
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
|
-
|
|
1552
1470
|
// src/commands/migrate.ts
|
|
1553
1471
|
function registerMigrateCommand(program2) {
|
|
1554
1472
|
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(
|
|
@@ -1655,14 +1573,14 @@ Examples:
|
|
|
1655
1573
|
)
|
|
1656
1574
|
);
|
|
1657
1575
|
if (options.dryRun) {
|
|
1658
|
-
const report2 = await formatDryRunDiff(analysis, source);
|
|
1576
|
+
const report2 = await formatDryRunDiff(analysis, source, config.openFeatureClientBindings);
|
|
1659
1577
|
process.stdout.write(report2 + "\n");
|
|
1660
1578
|
process.exit(0);
|
|
1661
1579
|
}
|
|
1662
1580
|
if (options.apply) {
|
|
1663
1581
|
let result;
|
|
1664
1582
|
try {
|
|
1665
|
-
result = await applyMigration(analysis, source, { allowDirty: options.allowDirty });
|
|
1583
|
+
result = await applyMigration(analysis, source, { allowDirty: options.allowDirty, allowedOpenFeatureClientBindings: config.openFeatureClientBindings });
|
|
1666
1584
|
} catch (err) {
|
|
1667
1585
|
if (err instanceof ApplyError && err.kind === "dirty-tree") {
|
|
1668
1586
|
process.stderr.write(chalk2.red(`
|
|
@@ -1725,12 +1643,15 @@ Error: ${err.message}
|
|
|
1725
1643
|
}
|
|
1726
1644
|
|
|
1727
1645
|
// src/commands/validate.ts
|
|
1646
|
+
import { writeFile as writeFile4 } from "fs/promises";
|
|
1728
1647
|
import { stat as stat3 } from "fs/promises";
|
|
1729
|
-
import { resolve as
|
|
1648
|
+
import { resolve as resolve7 } from "path";
|
|
1730
1649
|
import chalk3 from "chalk";
|
|
1731
1650
|
import ora3 from "ora";
|
|
1732
1651
|
|
|
1733
1652
|
// src/validator/index.ts
|
|
1653
|
+
import { resolve as resolve6 } from "path";
|
|
1654
|
+
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
1734
1655
|
function matchesBootstrapPattern(file, patterns) {
|
|
1735
1656
|
const clean = (s) => s.replace(/^\.\//, "");
|
|
1736
1657
|
const cleanFile = clean(file);
|
|
@@ -1801,8 +1722,106 @@ function formatValidationReport(result, options = {}) {
|
|
|
1801
1722
|
lines.push("Run `flaglint migrate --dry-run` to review the migration plan.");
|
|
1802
1723
|
return lines.join("\n") + "\n";
|
|
1803
1724
|
}
|
|
1725
|
+
var SARIF_RULE_NO_DIRECT_LD = {
|
|
1726
|
+
id: "flaglint.direct-launchdarkly",
|
|
1727
|
+
name: "DirectLaunchDarklySDKUsage",
|
|
1728
|
+
shortDescription: {
|
|
1729
|
+
text: "Direct LaunchDarkly SDK evaluation call detected"
|
|
1730
|
+
},
|
|
1731
|
+
fullDescription: {
|
|
1732
|
+
text: "A direct LaunchDarkly Node.js server SDK evaluation call was found. Migrate this call to OpenFeature so the codebase is provider-independent."
|
|
1733
|
+
},
|
|
1734
|
+
helpUri: "https://github.com/flaglint/flaglint#flaglint-validate-dir",
|
|
1735
|
+
properties: {
|
|
1736
|
+
tags: ["openfeature", "migration", "launchdarkly"]
|
|
1737
|
+
}
|
|
1738
|
+
};
|
|
1739
|
+
function sarifViolationUri(file) {
|
|
1740
|
+
return file.split(/[\\/]/).join("/");
|
|
1741
|
+
}
|
|
1742
|
+
function sarifScanRootUri(scanRoot) {
|
|
1743
|
+
const uri = pathToFileURL2(resolve6(scanRoot)).href;
|
|
1744
|
+
return uri.endsWith("/") ? uri : `${uri}/`;
|
|
1745
|
+
}
|
|
1746
|
+
function violationSarifMessage(v) {
|
|
1747
|
+
if (v.isDynamic) {
|
|
1748
|
+
return `Direct LaunchDarkly SDK call ${v.callType}() with a dynamic flag key at ${v.file}:${v.line}. Migrate to OpenFeature using flaglint migrate --dry-run.`;
|
|
1749
|
+
}
|
|
1750
|
+
if (v.flagKey === "*") {
|
|
1751
|
+
return `Direct LaunchDarkly bulk inventory call ${v.callType}() at ${v.file}:${v.line}. Migrate to OpenFeature using flaglint migrate --dry-run.`;
|
|
1752
|
+
}
|
|
1753
|
+
return `Direct LaunchDarkly SDK call ${v.callType}("${v.flagKey}") at ${v.file}:${v.line}. Migrate to OpenFeature using flaglint migrate --dry-run.`;
|
|
1754
|
+
}
|
|
1755
|
+
function formatValidationSarif(result, scanRoot, scannedAt) {
|
|
1756
|
+
return JSON.stringify(
|
|
1757
|
+
{
|
|
1758
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
1759
|
+
version: "2.1.0",
|
|
1760
|
+
runs: [
|
|
1761
|
+
{
|
|
1762
|
+
tool: {
|
|
1763
|
+
driver: {
|
|
1764
|
+
name: "FlagLint",
|
|
1765
|
+
informationUri: "https://github.com/flaglint/flaglint",
|
|
1766
|
+
rules: [SARIF_RULE_NO_DIRECT_LD]
|
|
1767
|
+
}
|
|
1768
|
+
},
|
|
1769
|
+
invocations: [
|
|
1770
|
+
{
|
|
1771
|
+
executionSuccessful: true,
|
|
1772
|
+
startTimeUtc: scannedAt,
|
|
1773
|
+
properties: {
|
|
1774
|
+
scannedFiles: result.scannedFiles,
|
|
1775
|
+
totalUsages: result.totalUsages,
|
|
1776
|
+
violations: result.violations.length,
|
|
1777
|
+
passed: result.passed
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
],
|
|
1781
|
+
originalUriBaseIds: {
|
|
1782
|
+
"%SRCROOT%": {
|
|
1783
|
+
uri: sarifScanRootUri(scanRoot)
|
|
1784
|
+
}
|
|
1785
|
+
},
|
|
1786
|
+
results: result.violations.map((v) => ({
|
|
1787
|
+
ruleId: SARIF_RULE_NO_DIRECT_LD.id,
|
|
1788
|
+
level: "error",
|
|
1789
|
+
message: {
|
|
1790
|
+
text: violationSarifMessage(v)
|
|
1791
|
+
},
|
|
1792
|
+
locations: [
|
|
1793
|
+
{
|
|
1794
|
+
physicalLocation: {
|
|
1795
|
+
artifactLocation: {
|
|
1796
|
+
uri: sarifViolationUri(v.file),
|
|
1797
|
+
uriBaseId: "%SRCROOT%"
|
|
1798
|
+
},
|
|
1799
|
+
region: {
|
|
1800
|
+
startLine: Math.max(v.line, 1),
|
|
1801
|
+
startColumn: Math.max(v.column + 1, 1)
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
],
|
|
1806
|
+
partialFingerprints: {
|
|
1807
|
+
"flagKey/v1": v.flagKey
|
|
1808
|
+
},
|
|
1809
|
+
properties: {
|
|
1810
|
+
flagKey: v.flagKey,
|
|
1811
|
+
callType: v.callType,
|
|
1812
|
+
isDynamic: v.isDynamic
|
|
1813
|
+
}
|
|
1814
|
+
}))
|
|
1815
|
+
}
|
|
1816
|
+
]
|
|
1817
|
+
},
|
|
1818
|
+
null,
|
|
1819
|
+
2
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1804
1822
|
|
|
1805
1823
|
// src/commands/validate.ts
|
|
1824
|
+
var VALID_VALIDATE_FORMATS = ["text", "sarif"];
|
|
1806
1825
|
function registerValidateCommand(program2) {
|
|
1807
1826
|
program2.command("validate").description("Validate that your codebase complies with feature flag usage policies").argument("[dir]", "directory to validate", process.cwd()).option(
|
|
1808
1827
|
"--no-direct-launchdarkly",
|
|
@@ -1812,7 +1831,11 @@ function registerValidateCommand(program2) {
|
|
|
1812
1831
|
"glob pattern for files allowed to use LaunchDarkly SDK directly (repeatable)",
|
|
1813
1832
|
(val, prev) => [...prev, val],
|
|
1814
1833
|
[]
|
|
1815
|
-
).option(
|
|
1834
|
+
).option(
|
|
1835
|
+
"-f, --format <format>",
|
|
1836
|
+
"output format: text | sarif",
|
|
1837
|
+
"text"
|
|
1838
|
+
).option("-o, --output <file>", "write report to file instead of stdout").option("-c, --config <path>", "path to .flaglintrc config file").addHelpText(
|
|
1816
1839
|
"after",
|
|
1817
1840
|
`
|
|
1818
1841
|
Examples:
|
|
@@ -1822,13 +1845,25 @@ Examples:
|
|
|
1822
1845
|
--bootstrap-exclude src/provider/setup.ts allow bootstrap file
|
|
1823
1846
|
$ flaglint validate --no-direct-launchdarkly \\
|
|
1824
1847
|
--bootstrap-exclude "src/provider/**" allow all provider files
|
|
1848
|
+
$ flaglint validate --no-direct-launchdarkly \\
|
|
1849
|
+
--format sarif --output flaglint.sarif emit SARIF for GitHub Code Scanning
|
|
1825
1850
|
$ flaglint validate --no-direct-launchdarkly \\
|
|
1826
1851
|
--bootstrap-exclude "src/provider/*.ts" \\
|
|
1827
1852
|
--bootstrap-exclude "src/bootstrap/**" multiple exclusion patterns`
|
|
1828
1853
|
).action(
|
|
1829
1854
|
async (dir, options) => {
|
|
1855
|
+
if (!VALID_VALIDATE_FORMATS.includes(options.format)) {
|
|
1856
|
+
process.stderr.write(
|
|
1857
|
+
chalk3.red(
|
|
1858
|
+
`Error: Invalid format '${options.format}'. Must be one of: ${VALID_VALIDATE_FORMATS.join(", ")}
|
|
1859
|
+
`
|
|
1860
|
+
)
|
|
1861
|
+
);
|
|
1862
|
+
process.exit(2);
|
|
1863
|
+
}
|
|
1864
|
+
const format = options.format;
|
|
1830
1865
|
try {
|
|
1831
|
-
const s = await stat3(
|
|
1866
|
+
const s = await stat3(resolve7(dir));
|
|
1832
1867
|
if (!s.isDirectory()) {
|
|
1833
1868
|
process.stderr.write(chalk3.red(`Error: Not a directory: ${dir}
|
|
1834
1869
|
`));
|
|
@@ -1883,8 +1918,34 @@ Examples:
|
|
|
1883
1918
|
bootstrapExclude
|
|
1884
1919
|
};
|
|
1885
1920
|
const validationResult = validateScanResult(scanResult, validateOptions);
|
|
1886
|
-
|
|
1887
|
-
|
|
1921
|
+
let report;
|
|
1922
|
+
if (format === "sarif") {
|
|
1923
|
+
report = formatValidationSarif(
|
|
1924
|
+
validationResult,
|
|
1925
|
+
scanResult.scanRoot,
|
|
1926
|
+
scanResult.scannedAt
|
|
1927
|
+
);
|
|
1928
|
+
} else {
|
|
1929
|
+
report = formatValidationReport(validationResult, validateOptions);
|
|
1930
|
+
}
|
|
1931
|
+
if (options.output) {
|
|
1932
|
+
const outPath = resolve7(options.output);
|
|
1933
|
+
try {
|
|
1934
|
+
await writeFile4(outPath, report, "utf8");
|
|
1935
|
+
process.stderr.write(chalk3.dim(` Report written to ${options.output}
|
|
1936
|
+
`));
|
|
1937
|
+
} catch (err) {
|
|
1938
|
+
process.stderr.write(
|
|
1939
|
+
chalk3.red(
|
|
1940
|
+
`Error: Failed to write report to ${options.output}: ${err instanceof Error ? err.message : String(err)}
|
|
1941
|
+
`
|
|
1942
|
+
)
|
|
1943
|
+
);
|
|
1944
|
+
process.exit(1);
|
|
1945
|
+
}
|
|
1946
|
+
} else {
|
|
1947
|
+
process.stdout.write(report);
|
|
1948
|
+
}
|
|
1888
1949
|
if (!validationResult.passed) {
|
|
1889
1950
|
process.exit(1);
|
|
1890
1951
|
}
|
|
@@ -1896,7 +1957,7 @@ Examples:
|
|
|
1896
1957
|
// src/cli.ts
|
|
1897
1958
|
function createCLI() {
|
|
1898
1959
|
const program2 = new Command();
|
|
1899
|
-
program2.name("flaglint").description("LaunchDarkly Node.js server SDK -> OpenFeature migration.").version("0.
|
|
1960
|
+
program2.name("flaglint").description("LaunchDarkly Node.js server SDK -> OpenFeature migration.").version("0.5.1", "-v, --version", "output the current version").addHelpText(
|
|
1900
1961
|
"after",
|
|
1901
1962
|
`
|
|
1902
1963
|
Examples:
|