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.
@@ -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.4.1" : "0.1.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">${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>
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">${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>
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
- <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>
748
826
 
749
827
  <script>
750
828
  const input = document.getElementById('filter');
751
- const rows = document.querySelectorAll('#flags-table tbody tr');
829
+ const tableRows = document.querySelectorAll('#flags-table tbody tr');
752
830
  input.addEventListener('input', () => {
753
831
  const q = input.value.toLowerCase();
754
- 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'; });
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.exit(staleCount > 0 ? 1 : 0);
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.4.1" : "0.1.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 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})`;
1216
1308
  return {
1217
1309
  item,
1218
1310
  replacement: call,
1219
- requiresProviderSetup: true
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 (Required Before Applying Diffs)");
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 the diffs below.");
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
- const result = buildReplacement(item);
1310
- if ("reason" in result) {
1311
- skipped.push(result);
1312
- 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);
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
- lines.push(
1321
- "These diffs use the placeholder `openFeatureClient` and require OpenFeature provider/client setup before they can be applied."
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: ${[...replacementsByFile.values()].reduce((sum, items) => sum + items.length, 0)}`);
1326
- lines.push(
1327
- `Diffs requiring provider setup: ${[...replacementsByFile.values()].reduce((sum, items) => sum + items.filter((item) => item.requiresProviderSetup).length, 0)}`
1328
- );
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
- lines.push(...formatProviderSetupSection());
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 resolve6 } from "path";
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("-c, --config <path>", "path to .flaglintrc config file").addHelpText(
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(resolve6(dir));
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
- const report = formatValidationReport(validationResult, validateOptions);
1887
- process.stdout.write(report);
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.4.1", "-v, --version", "output the current version").addHelpText(
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: