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.
@@ -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";
@@ -53,6 +57,13 @@ var LD_FLAG_KEY_METHODS = /* @__PURE__ */ new Set([
53
57
  "jsonVariationDetail"
54
58
  ]);
55
59
  var LD_ALL_FLAGS_METHODS = /* @__PURE__ */ new Set(["allFlags", "allFlagsState"]);
60
+ var LD_DETAIL_METHODS = /* @__PURE__ */ new Set([
61
+ "variationDetail",
62
+ "boolVariationDetail",
63
+ "stringVariationDetail",
64
+ "numberVariationDetail",
65
+ "jsonVariationDetail"
66
+ ]);
56
67
  var LD_HOOKS = /* @__PURE__ */ new Set(["useFlags", "useLDClient"]);
57
68
  var DEFAULT_EXCLUDE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.next/**"];
58
69
  function extractFlagKey(arg) {
@@ -120,7 +131,7 @@ function buildMigrationInventoryItem(code, filePath, loc, call, methodName, args
120
131
  }
121
132
  const fallback = args[2];
122
133
  const valueType = inferValueType(methodName, fallback);
123
- const manualReviewReason = isDynamic ? "dynamic-key" : valueType === "unknown" ? "unknown-fallback" : void 0;
134
+ const manualReviewReason = isDynamic ? "dynamic-key" : LD_DETAIL_METHODS.has(methodName) ? "detail-method" : valueType === "unknown" ? "unknown-fallback" : void 0;
124
135
  return {
125
136
  file: filePath,
126
137
  line: loc.line,
@@ -528,7 +539,7 @@ function formatMarkdown(result, options) {
528
539
  }
529
540
  if (staleFlags.length > 0) {
530
541
  lines.push("## \u26A0 Stale Flag Candidates");
531
- lines.push("Flags that may be safe to remove:");
542
+ lines.push("Flags with review signals:");
532
543
  lines.push("| Flag Key | Reason | Location |");
533
544
  lines.push("|----------|--------|----------|");
534
545
  for (const [key, data] of staleFlags) {
@@ -665,6 +676,16 @@ function formatSARIF(result) {
665
676
  2
666
677
  );
667
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
+ }
668
689
  function formatHTML(result, options) {
669
690
  const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
670
691
  const staleCount = new Set(
@@ -672,6 +693,34 @@ function formatHTML(result, options) {
672
693
  ).size;
673
694
  const dynamicCount = usages.filter((u) => u.isDynamic).length;
674
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");
675
724
  const flagMap = buildFlagMap(usages);
676
725
  const sorted = sortedFlagEntries(flagMap);
677
726
  const rows = sorted.map(([key, data]) => {
@@ -680,8 +729,18 @@ function formatHTML(result, options) {
680
729
  const fileList = [...data.files].map((f) => esc(f)).join("<br>");
681
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>`;
682
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>` : "";
683
742
  const title = options.title ? esc(options.title) : "FlagLint Scan Report";
684
- const version = true ? "0.4.0" : "0.1.0";
743
+ const version = true ? "0.5.0" : "0.1.0";
685
744
  return `<!DOCTYPE html>
686
745
  <html lang="en">
687
746
  <head>
@@ -695,12 +754,15 @@ function formatHTML(result, options) {
695
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}
696
755
  h1{font-size:1.75rem;margin-bottom:.25rem}
697
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)}
698
758
  .subtitle{color:var(--muted);margin-bottom:1.5rem;font-size:.875rem}
699
759
  .cards{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
700
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)}
701
761
  .card-num{font-size:1.875rem;font-weight:700;line-height:1}
702
762
  .card-num.yellow{color:#d97706}
703
763
  .card-num.blue{color:#3b82f6}
764
+ .card-num.green{color:#16a34a}
765
+ .card-num.orange{color:#ea580c}
704
766
  .card-label{color:var(--muted);font-size:.75rem;margin-top:.375rem;text-transform:uppercase;letter-spacing:.05em}
705
767
  .filter-wrap{margin-bottom:.75rem}
706
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}
@@ -711,19 +773,25 @@ function formatHTML(result, options) {
711
773
  tr.stale td{background:var(--stale-bg)}
712
774
  tr.dynamic td{background:var(--dyn-bg)}
713
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}
714
781
  footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border);color:var(--muted);font-size:.75rem;text-align:center}
715
782
  </style>
716
783
  </head>
717
784
  <body>
718
785
  <h1>${title}</h1>
719
- <p class="subtitle">Scanned ${scannedFiles} files in ${scanDurationMs}ms</p>
786
+ <p class="subtitle">Scanned ${scannedFiles} files in ${scanDurationMs}ms \xB7 ${esc(date)}</p>
720
787
 
788
+ <h2>Executive Summary</h2>
721
789
  <div class="cards">
722
- <div class="card"><div class="card-num">${scannedFiles}</div><div class="card-label">Files Scanned</div></div>
790
+ <div class="card"><div class="card-num">${totalUsages}</div><div class="card-label">Total Call-Sites</div></div>
723
791
  <div class="card"><div class="card-num">${uniqueFlags.length}</div><div class="card-label">Unique Flags</div></div>
724
- <div class="card"><div class="card-num">${totalUsages}</div><div class="card-label">Total Usages</div></div>
792
+ <div class="card"><div class="card-num">${affectedFiles}</div><div class="card-label">Files Affected</div></div>
725
793
  <div class="card"><div class="card-num yellow">${staleCount}</div><div class="card-label">Stale Candidates</div></div>
726
- <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}
727
795
  </div>
728
796
 
729
797
  <h2>Flag Inventory</h2>
@@ -737,15 +805,41 @@ function formatHTML(result, options) {
737
805
  </tbody>
738
806
  </table>
739
807
 
740
- <footer>Generated by FlagLint ${esc(version)} on ${esc(date)}</footer>
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>
741
826
 
742
827
  <script>
743
828
  const input = document.getElementById('filter');
744
- const rows = document.querySelectorAll('#flags-table tbody tr');
829
+ const tableRows = document.querySelectorAll('#flags-table tbody tr');
745
830
  input.addEventListener('input', () => {
746
831
  const q = input.value.toLowerCase();
747
- rows.forEach(r => { r.style.display = r.textContent.toLowerCase().includes(q) ? '' : 'none'; });
832
+ tableRows.forEach(r => { r.style.display = r.textContent.toLowerCase().includes(q) ? '' : 'none'; });
748
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
+ }
749
843
  </script>
750
844
  </body>
751
845
  </html>`;
@@ -779,8 +873,11 @@ var FlagLintConfigSchema = z.object({
779
873
  ]),
780
874
  provider: z.enum(["launchdarkly", "unleash", "growthbook", "custom"]).default("launchdarkly"),
781
875
  // TODO v0.3: replace minFileCount with real date-based staleness via git log
782
- minFileCount: z.number().int().min(0).default(1),
876
+ minFileCount: z.number().int().min(0).default(0),
783
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([]),
784
881
  reportTitle: z.string().optional(),
785
882
  outputDir: z.string().default(".")
786
883
  });
@@ -955,7 +1052,7 @@ Examples:
955
1052
  } else {
956
1053
  process.stdout.write(report + "\n");
957
1054
  }
958
- process.exit(staleCount > 0 ? 1 : 0);
1055
+ process.exitCode = staleCount > 0 ? 1 : 0;
959
1056
  }
960
1057
  );
961
1058
  }
@@ -1011,6 +1108,8 @@ function reasonLabel(reason) {
1011
1108
  return "dynamic key";
1012
1109
  case "unknown-fallback":
1013
1110
  return "unsupported or unknown fallback";
1111
+ case "detail-method":
1112
+ return "detail method";
1014
1113
  case "bulk-inventory-call":
1015
1114
  return "bulk inventory call";
1016
1115
  default:
@@ -1094,7 +1193,7 @@ function formatMigrationReport(analysis) {
1094
1193
  unsupportedUnknownCount
1095
1194
  } = analysis;
1096
1195
  const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
1097
- const version = true ? "0.4.0" : "0.1.0";
1196
+ const version = true ? "0.5.0" : "0.1.0";
1098
1197
  const lines = [];
1099
1198
  lines.push("# OpenFeature Migration Inventory");
1100
1199
  lines.push(`Generated by FlagLint v${version} on ${date}`);
@@ -1179,10 +1278,13 @@ function methodForType(valueType) {
1179
1278
  function manualReason(item) {
1180
1279
  if (item.manualReviewReason === "dynamic-key") return "dynamic key requires manual review";
1181
1280
  if (item.manualReviewReason === "unknown-fallback") return "unknown fallback type requires manual review";
1281
+ if (item.manualReviewReason === "detail-method") {
1282
+ return "detail methods skipped: OpenFeature detail APIs exist, but LaunchDarkly/OpenFeature detail result parity requires manual review";
1283
+ }
1182
1284
  if (item.manualReviewReason === "bulk-inventory-call") return "bulk inventory call has no single-flag codemod";
1183
1285
  return "manual review required";
1184
1286
  }
1185
- function buildReplacement(item) {
1287
+ function buildReplacement(item, clientBindingName) {
1186
1288
  if (DETAIL_METHODS.has(item.launchDarklyMethod)) {
1187
1289
  return {
1188
1290
  item,
@@ -1200,11 +1302,13 @@ function buildReplacement(item) {
1200
1302
  }
1201
1303
  const method = methodForType(item.valueType);
1202
1304
  if (!method) return { item, reason: "unsupported or unknown value type" };
1203
- const call = `openFeatureClient.${method}(${item.flagKeyExpression}, ${item.fallbackExpression}, ${item.evaluationContextExpression})`;
1305
+ const placeholder = "openFeatureClient";
1306
+ const target = clientBindingName ?? placeholder;
1307
+ const call = `${target}.${method}(${item.flagKeyExpression}, ${item.fallbackExpression}, ${item.evaluationContextExpression})`;
1204
1308
  return {
1205
1309
  item,
1206
1310
  replacement: call,
1207
- requiresProviderSetup: true
1311
+ requiresProviderSetup: clientBindingName == null
1208
1312
  };
1209
1313
  }
1210
1314
  function applyReplacements(code, replacements) {
@@ -1268,7 +1372,7 @@ function formatProviderSetupSection() {
1268
1372
  lines.push('import { OpenFeature } from "@openfeature/server-sdk";');
1269
1373
  lines.push('import { LaunchDarklyProvider } from "@launchdarkly/openfeature-node-server";');
1270
1374
  lines.push("");
1271
- lines.push('const ldProvider = new LaunchDarklyProvider("<your-sdk-key>");');
1375
+ lines.push("const ldProvider = new LaunchDarklyProvider(process.env.LD_SDK_KEY!);");
1272
1376
  lines.push("await OpenFeature.setProviderAndWait(ldProvider);");
1273
1377
  lines.push("");
1274
1378
  lines.push("// Share this client across your application.");
@@ -1278,26 +1382,39 @@ function formatProviderSetupSection() {
1278
1382
  lines.push("");
1279
1383
  lines.push("### 3. Evaluation context \u2014 targeting key");
1280
1384
  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:");
1385
+ lines.push("LaunchDarkly requires a targeting key in every evaluation context. The");
1386
+ lines.push("provider accepts either OpenFeature `targetingKey` or an existing");
1387
+ lines.push("LaunchDarkly `key`.");
1388
+ lines.push("Keep your existing LaunchDarkly `key` contexts, or use `targetingKey`");
1389
+ lines.push("for new OpenFeature-native contexts:");
1283
1390
  lines.push("");
1284
1391
  lines.push("```typescript");
1285
- lines.push("{ targetingKey: user.key, ...otherAttributes }");
1392
+ lines.push("{ targetingKey: user.id } // or { key: user.id }");
1286
1393
  lines.push("```");
1287
1394
  lines.push("");
1288
1395
  return lines;
1289
1396
  }
1290
- async function formatDryRunDiff(analysis, source) {
1397
+ async function formatDryRunDiff(analysis, source, allowedBindings = []) {
1291
1398
  const replacementsByFile = /* @__PURE__ */ new Map();
1292
1399
  const skipped = [];
1400
+ const itemsByFile = /* @__PURE__ */ new Map();
1293
1401
  for (const item of analysis.inventoryItems) {
1294
- const result = buildReplacement(item);
1295
- if ("reason" in result) {
1296
- skipped.push(result);
1297
- continue;
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);
1298
1417
  }
1299
- if (!replacementsByFile.has(item.file)) replacementsByFile.set(item.file, []);
1300
- replacementsByFile.get(item.file).push(result);
1301
1418
  }
1302
1419
  const lines = [];
1303
1420
  lines.push("# FlagLint migrate --dry-run");
@@ -1335,205 +1452,6 @@ async function formatDryRunDiff(analysis, source) {
1335
1452
  return lines.join("\n");
1336
1453
  }
1337
1454
 
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
-
1537
1455
  // src/commands/migrate.ts
1538
1456
  function registerMigrateCommand(program2) {
1539
1457
  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(
@@ -1640,14 +1558,14 @@ Examples:
1640
1558
  )
1641
1559
  );
1642
1560
  if (options.dryRun) {
1643
- const report2 = await formatDryRunDiff(analysis, source);
1561
+ const report2 = await formatDryRunDiff(analysis, source, config.openFeatureClientBindings);
1644
1562
  process.stdout.write(report2 + "\n");
1645
1563
  process.exit(0);
1646
1564
  }
1647
1565
  if (options.apply) {
1648
1566
  let result;
1649
1567
  try {
1650
- result = await applyMigration(analysis, source, { allowDirty: options.allowDirty });
1568
+ result = await applyMigration(analysis, source, { allowDirty: options.allowDirty, allowedOpenFeatureClientBindings: config.openFeatureClientBindings });
1651
1569
  } catch (err) {
1652
1570
  if (err instanceof ApplyError && err.kind === "dirty-tree") {
1653
1571
  process.stderr.write(chalk2.red(`
@@ -1710,12 +1628,15 @@ Error: ${err.message}
1710
1628
  }
1711
1629
 
1712
1630
  // src/commands/validate.ts
1631
+ import { writeFile as writeFile4 } from "fs/promises";
1713
1632
  import { stat as stat3 } from "fs/promises";
1714
- import { resolve as resolve6 } from "path";
1633
+ import { resolve as resolve7 } from "path";
1715
1634
  import chalk3 from "chalk";
1716
1635
  import ora3 from "ora";
1717
1636
 
1718
1637
  // src/validator/index.ts
1638
+ import { resolve as resolve6 } from "path";
1639
+ import { pathToFileURL as pathToFileURL2 } from "url";
1719
1640
  function matchesBootstrapPattern(file, patterns) {
1720
1641
  const clean = (s) => s.replace(/^\.\//, "");
1721
1642
  const cleanFile = clean(file);
@@ -1786,8 +1707,106 @@ function formatValidationReport(result, options = {}) {
1786
1707
  lines.push("Run `flaglint migrate --dry-run` to review the migration plan.");
1787
1708
  return lines.join("\n") + "\n";
1788
1709
  }
1710
+ var SARIF_RULE_NO_DIRECT_LD = {
1711
+ id: "flaglint.direct-launchdarkly",
1712
+ name: "DirectLaunchDarklySDKUsage",
1713
+ shortDescription: {
1714
+ text: "Direct LaunchDarkly SDK evaluation call detected"
1715
+ },
1716
+ fullDescription: {
1717
+ text: "A direct LaunchDarkly Node.js server SDK evaluation call was found. Migrate this call to OpenFeature so the codebase is provider-independent."
1718
+ },
1719
+ helpUri: "https://github.com/flaglint/flaglint#flaglint-validate-dir",
1720
+ properties: {
1721
+ tags: ["openfeature", "migration", "launchdarkly"]
1722
+ }
1723
+ };
1724
+ function sarifViolationUri(file) {
1725
+ return file.split(/[\\/]/).join("/");
1726
+ }
1727
+ function sarifScanRootUri(scanRoot) {
1728
+ const uri = pathToFileURL2(resolve6(scanRoot)).href;
1729
+ return uri.endsWith("/") ? uri : `${uri}/`;
1730
+ }
1731
+ function violationSarifMessage(v) {
1732
+ if (v.isDynamic) {
1733
+ 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.`;
1734
+ }
1735
+ if (v.flagKey === "*") {
1736
+ return `Direct LaunchDarkly bulk inventory call ${v.callType}() at ${v.file}:${v.line}. Migrate to OpenFeature using flaglint migrate --dry-run.`;
1737
+ }
1738
+ return `Direct LaunchDarkly SDK call ${v.callType}("${v.flagKey}") at ${v.file}:${v.line}. Migrate to OpenFeature using flaglint migrate --dry-run.`;
1739
+ }
1740
+ function formatValidationSarif(result, scanRoot, scannedAt) {
1741
+ return JSON.stringify(
1742
+ {
1743
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
1744
+ version: "2.1.0",
1745
+ runs: [
1746
+ {
1747
+ tool: {
1748
+ driver: {
1749
+ name: "FlagLint",
1750
+ informationUri: "https://github.com/flaglint/flaglint",
1751
+ rules: [SARIF_RULE_NO_DIRECT_LD]
1752
+ }
1753
+ },
1754
+ invocations: [
1755
+ {
1756
+ executionSuccessful: true,
1757
+ startTimeUtc: scannedAt,
1758
+ properties: {
1759
+ scannedFiles: result.scannedFiles,
1760
+ totalUsages: result.totalUsages,
1761
+ violations: result.violations.length,
1762
+ passed: result.passed
1763
+ }
1764
+ }
1765
+ ],
1766
+ originalUriBaseIds: {
1767
+ "%SRCROOT%": {
1768
+ uri: sarifScanRootUri(scanRoot)
1769
+ }
1770
+ },
1771
+ results: result.violations.map((v) => ({
1772
+ ruleId: SARIF_RULE_NO_DIRECT_LD.id,
1773
+ level: "error",
1774
+ message: {
1775
+ text: violationSarifMessage(v)
1776
+ },
1777
+ locations: [
1778
+ {
1779
+ physicalLocation: {
1780
+ artifactLocation: {
1781
+ uri: sarifViolationUri(v.file),
1782
+ uriBaseId: "%SRCROOT%"
1783
+ },
1784
+ region: {
1785
+ startLine: Math.max(v.line, 1),
1786
+ startColumn: Math.max(v.column + 1, 1)
1787
+ }
1788
+ }
1789
+ }
1790
+ ],
1791
+ partialFingerprints: {
1792
+ "flagKey/v1": v.flagKey
1793
+ },
1794
+ properties: {
1795
+ flagKey: v.flagKey,
1796
+ callType: v.callType,
1797
+ isDynamic: v.isDynamic
1798
+ }
1799
+ }))
1800
+ }
1801
+ ]
1802
+ },
1803
+ null,
1804
+ 2
1805
+ );
1806
+ }
1789
1807
 
1790
1808
  // src/commands/validate.ts
1809
+ var VALID_VALIDATE_FORMATS = ["text", "sarif"];
1791
1810
  function registerValidateCommand(program2) {
1792
1811
  program2.command("validate").description("Validate that your codebase complies with feature flag usage policies").argument("[dir]", "directory to validate", process.cwd()).option(
1793
1812
  "--no-direct-launchdarkly",
@@ -1797,7 +1816,11 @@ function registerValidateCommand(program2) {
1797
1816
  "glob pattern for files allowed to use LaunchDarkly SDK directly (repeatable)",
1798
1817
  (val, prev) => [...prev, val],
1799
1818
  []
1800
- ).option("-c, --config <path>", "path to .flaglintrc config file").addHelpText(
1819
+ ).option(
1820
+ "-f, --format <format>",
1821
+ "output format: text | sarif",
1822
+ "text"
1823
+ ).option("-o, --output <file>", "write report to file instead of stdout").option("-c, --config <path>", "path to .flaglintrc config file").addHelpText(
1801
1824
  "after",
1802
1825
  `
1803
1826
  Examples:
@@ -1807,13 +1830,25 @@ Examples:
1807
1830
  --bootstrap-exclude src/provider/setup.ts allow bootstrap file
1808
1831
  $ flaglint validate --no-direct-launchdarkly \\
1809
1832
  --bootstrap-exclude "src/provider/**" allow all provider files
1833
+ $ flaglint validate --no-direct-launchdarkly \\
1834
+ --format sarif --output flaglint.sarif emit SARIF for GitHub Code Scanning
1810
1835
  $ flaglint validate --no-direct-launchdarkly \\
1811
1836
  --bootstrap-exclude "src/provider/*.ts" \\
1812
1837
  --bootstrap-exclude "src/bootstrap/**" multiple exclusion patterns`
1813
1838
  ).action(
1814
1839
  async (dir, options) => {
1840
+ if (!VALID_VALIDATE_FORMATS.includes(options.format)) {
1841
+ process.stderr.write(
1842
+ chalk3.red(
1843
+ `Error: Invalid format '${options.format}'. Must be one of: ${VALID_VALIDATE_FORMATS.join(", ")}
1844
+ `
1845
+ )
1846
+ );
1847
+ process.exit(2);
1848
+ }
1849
+ const format = options.format;
1815
1850
  try {
1816
- const s = await stat3(resolve6(dir));
1851
+ const s = await stat3(resolve7(dir));
1817
1852
  if (!s.isDirectory()) {
1818
1853
  process.stderr.write(chalk3.red(`Error: Not a directory: ${dir}
1819
1854
  `));
@@ -1868,8 +1903,34 @@ Examples:
1868
1903
  bootstrapExclude
1869
1904
  };
1870
1905
  const validationResult = validateScanResult(scanResult, validateOptions);
1871
- const report = formatValidationReport(validationResult, validateOptions);
1872
- process.stdout.write(report);
1906
+ let report;
1907
+ if (format === "sarif") {
1908
+ report = formatValidationSarif(
1909
+ validationResult,
1910
+ scanResult.scanRoot,
1911
+ scanResult.scannedAt
1912
+ );
1913
+ } else {
1914
+ report = formatValidationReport(validationResult, validateOptions);
1915
+ }
1916
+ if (options.output) {
1917
+ const outPath = resolve7(options.output);
1918
+ try {
1919
+ await writeFile4(outPath, report, "utf8");
1920
+ process.stderr.write(chalk3.dim(` Report written to ${options.output}
1921
+ `));
1922
+ } catch (err) {
1923
+ process.stderr.write(
1924
+ chalk3.red(
1925
+ `Error: Failed to write report to ${options.output}: ${err instanceof Error ? err.message : String(err)}
1926
+ `
1927
+ )
1928
+ );
1929
+ process.exit(1);
1930
+ }
1931
+ } else {
1932
+ process.stdout.write(report);
1933
+ }
1873
1934
  if (!validationResult.passed) {
1874
1935
  process.exit(1);
1875
1936
  }
@@ -1881,7 +1942,7 @@ Examples:
1881
1942
  // src/cli.ts
1882
1943
  function createCLI() {
1883
1944
  const program2 = new Command();
1884
- program2.name("flaglint").description("LaunchDarkly Node.js server SDK -> OpenFeature migration.").version("0.4.0", "-v, --version", "output the current version").addHelpText(
1945
+ program2.name("flaglint").description("LaunchDarkly Node.js server SDK -> OpenFeature migration.").version("0.5.0", "-v, --version", "output the current version").addHelpText(
1885
1946
  "after",
1886
1947
  `
1887
1948
  Examples: