flaglint 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,72 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-06-02
9
+
10
+ ### Added
11
+
12
+ - add flaglint audit command with risk-scored flag debt report
13
+ - migrate homepage into Astro, unify site into single build
14
+ - add dark/light/auto theme toggle + search to homepage
15
+ - add Mermaid diagrams to Safety Model and How FlagLint Works
16
+ - add Mermaid diagrams to Safety Model and How FlagLint Works
17
+ - complete favicon stack for Google Search and mobile
18
+ - replace plain-text architecture diagram with styled HTML flow
19
+ - add JSON-LD SoftwareApplication structured data to homepage
20
+ - align trust and privacy page nav with homepage nav
21
+ - add Further Reading section to quickstart linking to blog posts
22
+ - add cross-links between blog posts
23
+ - rename blog to FlagLint Blog and set focused description
24
+ - add Docs and Blog links to homepage nav and footer
25
+ - add Blog link to marketing homepage nav
26
+ - add blog to flaglint.dev using starlight-blog
27
+ - add terminal demo GIF (scan, migrate --dry-run, validate CI gate)
28
+
29
+ ### Fixed
30
+
31
+ - docs UX series P1-P9 (GIF, TOC, title, sidebar, cards, blog frames)
32
+ - UX and codebase series (A1–A5, B1–B5)
33
+ - sync package-lock.json after rebase (add png-to-ico)
34
+ - UX and codebase series (A1–A5, B1–B5)
35
+ - align dynamic key label with scan reporter terminology
36
+ - restore branded README header and correct docs links
37
+
38
+ ### Changed
39
+
40
+ - trigger cloudflare rebuild
41
+ - untrack www/ build output + fix GIF paths + add Why FlagLint CTA
42
+ - add excerpt markers to both blog posts for blog index truncation
43
+ - rebuild after rebase onto main (PR #83 docs UX changes)
44
+ - rebuild docs after rebase onto main (A1-A5/B1-B5 + favicon state)
45
+ - rebuild docs after rebase onto main (favicon + blog state)
46
+ - rebuild docs site with blog and demo GIF
47
+ - add LinkedIn URL to blog post authors
48
+ - remove debug artifacts from demo fixture folder
49
+ - update version references to 0.5.4
50
+
51
+ ### Other
52
+
53
+ - Merge feat/audit-command: add flaglint audit command
54
+ - Merge pull request #86 from flaglint/feat/docs-ux-fixes
55
+ - Merge pull request #85 from flaglint/fix/blog-excerpt-markers
56
+ - Merge pull request #84 from flaglint/feat/homepage-theme-search
57
+ - Merge pull request #83 from flaglint/fix/complete-series-p1-p12
58
+ - Merge pull request #82 from flaglint/feat/favicon-stack
59
+ - Merge pull request #80 from flaglint/fix/validate-dynamic-key-label
60
+ - Merge pull request #78 from flaglint/feat/architecture-diagram
61
+ - Merge pull request #77 from flaglint/feat/structured-data-jsonld
62
+ - Merge pull request #76 from flaglint/feat/consistent-nav
63
+ - Merge pull request #75 from flaglint/feat/quickstart-further-reading
64
+ - Merge branch 'main' into feat/quickstart-further-reading
65
+ - Merge pull request #74 from flaglint/feat/blog-cross-links
66
+ - Merge pull request #73 from flaglint/feat/blog-title-description
67
+ - Merge pull request #72 from flaglint/feat/nav-footer-blog-docs
68
+ - Merge pull request #71 from flaglint/chore/bump-version-0.5.4
69
+ - Update README.md
70
+ - Merge pull request #70 from flaglint/chore/bump-version-0.5.4
71
+ - Merge pull request #69 from flaglint/chore/bump-version-0.5.4
72
+ - chore/bump-version-0.5.4
73
+
8
74
  ## Unreleased
9
75
 
10
76
  ### Fixed
package/README.md CHANGED
@@ -23,7 +23,8 @@
23
23
 
24
24
  # FlagLint
25
25
 
26
- **Standardize LaunchDarkly usage on OpenFeature.**
26
+ **The argument-order difference between LaunchDarkly and OpenFeature
27
+ silently breaks flag evaluations in production. FlagLint catches it.**
27
28
 
28
29
  FlagLint inventories direct LaunchDarkly Node.js SDK calls, generates reviewable migration plans,
29
30
  and prevents new vendor-coupled flag access from entering your codebase.
@@ -31,6 +32,8 @@ LaunchDarkly remains your provider. OpenFeature becomes the evaluation API your
31
32
 
32
33
  **[Documentation](https://flaglint.dev/docs)** · **[Quickstart](https://flaglint.dev/docs/quickstart)** · **[Enterprise Demo](https://flaglint.dev/docs/enterprise-demo)** · **[npm](https://npmjs.com/package/flaglint)** · **[Issues](https://github.com/flaglint/flaglint/issues)**
33
34
 
35
+ ![FlagLint demo](./flaglint-demo.gif)
36
+
34
37
  ---
35
38
 
36
39
  ## Quick start
@@ -67,6 +70,20 @@ npx flaglint migrate ./src --dry-run
67
70
 
68
71
  ---
69
72
 
73
+ ### `flaglint audit [dir]`
74
+
75
+ Generates a local flag debt audit report. No API keys or credentials needed.
76
+
77
+ ```bash
78
+ flaglint audit ./src
79
+ flaglint audit ./src --format html --output audit.html
80
+ flaglint audit ./src --format json
81
+ ```
82
+
83
+ Produces a risk-scored inventory of every LaunchDarkly flag in your codebase — sorted by risk level (high / medium / low) with the reasons for each rating. Useful for planning migration scope before running `migrate --dry-run`.
84
+
85
+ ---
86
+
70
87
  ## Before → After (real output from enterprise demo)
71
88
 
72
89
  ```diff
@@ -303,6 +303,9 @@ function detectUsages(ast, code, filePath, wrappers) {
303
303
  callType: "isFeatureEnabled",
304
304
  stalenessSignals: sig ? [sig] : []
305
305
  });
306
+ migrationInventory.push(
307
+ buildMigrationInventoryItem(code, filePath, loc, call, "isFeatureEnabled", call.arguments, flagKey, isDynamic)
308
+ );
306
309
  return;
307
310
  }
308
311
  if (LD_HOOKS.has(name)) {
@@ -316,6 +319,25 @@ function detectUsages(ast, code, filePath, wrappers) {
316
319
  callType: name === "useFlags" ? "hook-useFlags" : "hook-useLDClient",
317
320
  stalenessSignals: sig ? [sig] : []
318
321
  });
322
+ const hookCallRange = expressionRange(call);
323
+ migrationInventory.push({
324
+ file: filePath,
325
+ line: loc.line,
326
+ column: loc.column,
327
+ launchDarklyMethod: name === "useFlags" ? "hook-useFlags" : "hook-useLDClient",
328
+ callExpression: expressionText(code, call),
329
+ rangeStart: hookCallRange?.[0],
330
+ rangeEnd: hookCallRange?.[1],
331
+ isAwaited: false,
332
+ flagKeyExpression: void 0,
333
+ staticFlagKey: void 0,
334
+ isDynamic: false,
335
+ valueType: "unknown",
336
+ fallbackExpression: void 0,
337
+ evaluationContextExpression: void 0,
338
+ safelyAutomatable: false,
339
+ manualReviewReason: "dynamic-key"
340
+ });
319
341
  return;
320
342
  }
321
343
  }
@@ -331,6 +353,9 @@ function detectUsages(ast, code, filePath, wrappers) {
331
353
  callType: "variation",
332
354
  stalenessSignals: sig ? [sig] : []
333
355
  });
356
+ migrationInventory.push(
357
+ buildMigrationInventoryItem(code, filePath, loc, call, "variation", call.arguments, flagKey, isDynamic)
358
+ );
334
359
  return;
335
360
  }
336
361
  if (callee.type === "CallExpression" && callee.callee.type === "Identifier" && callee.callee.name === "withLDConsumer") {
@@ -542,8 +567,9 @@ function formatMarkdown(result, options) {
542
567
  lines.push("|----------|--------|-------|------------|--------|");
543
568
  for (const [key, data] of sorted) {
544
569
  const status = data.isStale ? "\u26A0 Stale" : "\u2713 Active";
570
+ const displayKey = key === "*" || data.usages.some((u) => u.isDynamic && u.flagKey === key) ? "(dynamic key)" : key;
545
571
  lines.push(
546
- `| ${key} | ${data.usages.length} | ${data.files.size} | ${[...data.callTypes].join(", ")} | ${status} |`
572
+ `| ${displayKey} | ${data.usages.length} | ${data.files.size} | ${[...data.callTypes].join(", ")} | ${status} |`
547
573
  );
548
574
  }
549
575
  lines.push("");
@@ -763,7 +789,7 @@ function formatHTML(result, options) {
763
789
  <div class="card"><div class="card-num orange">${manualCount}</div><div class="card-label">Manual Review (${manualPct}%)</div></div>
764
790
  <div class="card"><div class="card-num blue">${detailBulkCount}</div><div class="card-label">Detail/Bulk Calls</div></div>` : "";
765
791
  const title = options.title ? esc(options.title) : "FlagLint Scan Report";
766
- const version = true ? "0.5.4" : "0.1.0";
792
+ const version = true ? "0.6.0" : "0.1.0";
767
793
  return `<!DOCTYPE html>
768
794
  <html lang="en">
769
795
  <head>
@@ -894,6 +920,9 @@ var FlagLintConfigSchema = z.object({
894
920
  "**/coverage/**",
895
921
  "**/*.d.ts"
896
922
  ]),
923
+ // Governs which vendor SDK the scanner targets.
924
+ // Currently only "launchdarkly" is wired in the scanner.
925
+ // Other values are accepted for forward compatibility (v0.7+).
897
926
  provider: z.enum(["launchdarkly", "unleash", "growthbook", "custom"]).default("launchdarkly"),
898
927
  // TODO v0.3: replace minFileCount with real date-based staleness via git log
899
928
  minFileCount: z.number().int().min(0).default(0),
@@ -1216,7 +1245,7 @@ function formatMigrationReport(analysis) {
1216
1245
  unsupportedUnknownCount
1217
1246
  } = analysis;
1218
1247
  const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
1219
- const version = true ? "0.5.4" : "0.1.0";
1248
+ const version = true ? "0.6.0" : "0.1.0";
1220
1249
  const lines = [];
1221
1250
  lines.push("# OpenFeature Migration Inventory");
1222
1251
  lines.push(`Generated by FlagLint v${version} on ${date}`);
@@ -1713,7 +1742,7 @@ function validateScanResult(result, options = {}) {
1713
1742
  };
1714
1743
  }
1715
1744
  function violationLabel(v) {
1716
- if (v.isDynamic) return `${v.callType}(dynamic key \u2014 manual review required)`;
1745
+ if (v.isDynamic) return `${v.callType}("(dynamic key)")`;
1717
1746
  if (v.flagKey === "*") return `${v.callType}(bulk inventory)`;
1718
1747
  return `${v.callType}("${v.flagKey}")`;
1719
1748
  }
@@ -1977,10 +2006,442 @@ Examples:
1977
2006
  );
1978
2007
  }
1979
2008
 
2009
+ // src/commands/audit.ts
2010
+ import { writeFile as writeFile5 } from "fs/promises";
2011
+ import { stat as stat4 } from "fs/promises";
2012
+ import { resolve as resolve8 } from "path";
2013
+ import chalk4 from "chalk";
2014
+ import ora4 from "ora";
2015
+
2016
+ // src/auditor/index.ts
2017
+ function scoreFlag(usages, inventoryItems) {
2018
+ const highReasons = [];
2019
+ const mediumReasons = [];
2020
+ if (usages.some((u) => u.isDynamic)) {
2021
+ highReasons.push("dynamic key");
2022
+ }
2023
+ if (inventoryItems.some((i) => i.manualReviewReason === "detail-method")) {
2024
+ highReasons.push("detail evaluation");
2025
+ }
2026
+ if (inventoryItems.some((i) => i.manualReviewReason === "bulk-inventory-call")) {
2027
+ highReasons.push("bulk call");
2028
+ }
2029
+ if (usages.some((u) => u.stalenessSignals.length > 0)) {
2030
+ highReasons.push("stale signal");
2031
+ }
2032
+ if (usages.some((u) => u.callType === "variation" && u.isDynamic)) {
2033
+ if (!highReasons.includes("wrapper usage")) {
2034
+ highReasons.push("wrapper usage");
2035
+ }
2036
+ }
2037
+ if (usages.some(
2038
+ (u) => u.callType === "hook-useFlags" || u.callType === "hook-useLDClient" || u.callType === "hoc" || u.callType === "provider"
2039
+ )) {
2040
+ highReasons.push("react/browser hook");
2041
+ }
2042
+ if (highReasons.length > 0) {
2043
+ return { riskLevel: "high", riskReasons: highReasons };
2044
+ }
2045
+ if (inventoryItems.some((i) => i.safelyAutomatable)) {
2046
+ mediumReasons.push("safely automatable");
2047
+ }
2048
+ if (inventoryItems.some((i) => i.valueType === "object")) {
2049
+ mediumReasons.push("json variation");
2050
+ }
2051
+ if (inventoryItems.some((i) => i.manualReviewReason === "unknown-fallback")) {
2052
+ mediumReasons.push("unknown fallback");
2053
+ }
2054
+ if (mediumReasons.length > 0) {
2055
+ return { riskLevel: "medium", riskReasons: mediumReasons };
2056
+ }
2057
+ return { riskLevel: "low", riskReasons: [] };
2058
+ }
2059
+ function buildAuditReport(scanResult, inventoryItems) {
2060
+ const usagesByFlag = /* @__PURE__ */ new Map();
2061
+ for (const u of scanResult.usages) {
2062
+ const key = u.flagKey;
2063
+ if (!usagesByFlag.has(key)) usagesByFlag.set(key, []);
2064
+ usagesByFlag.get(key).push(u);
2065
+ }
2066
+ const inventoryByFlag = /* @__PURE__ */ new Map();
2067
+ for (const item of inventoryItems) {
2068
+ const key = item.staticFlagKey ?? "*";
2069
+ if (!inventoryByFlag.has(key)) inventoryByFlag.set(key, []);
2070
+ inventoryByFlag.get(key).push(item);
2071
+ }
2072
+ const allFlagKeys = /* @__PURE__ */ new Set([...usagesByFlag.keys()]);
2073
+ const flags = [];
2074
+ for (const flagKey of allFlagKeys) {
2075
+ const usages = usagesByFlag.get(flagKey) ?? [];
2076
+ const items = inventoryByFlag.get(flagKey) ?? [];
2077
+ const { riskLevel, riskReasons } = scoreFlag(usages, items);
2078
+ const files = [...new Set(usages.map((u) => u.file))];
2079
+ const callTypes = [...new Set(usages.map((u) => u.callType))];
2080
+ flags.push({
2081
+ flagKey,
2082
+ riskLevel,
2083
+ riskReasons,
2084
+ callTypes,
2085
+ fileCount: files.length,
2086
+ usageCount: usages.length,
2087
+ safelyAutomatable: items.some((i) => i.safelyAutomatable),
2088
+ hasStaleSignal: usages.some((u) => u.stalenessSignals.length > 0),
2089
+ isDynamic: usages.some((u) => u.isDynamic),
2090
+ files
2091
+ });
2092
+ }
2093
+ const riskOrder = { high: 0, medium: 1, low: 2 };
2094
+ flags.sort((a, b) => {
2095
+ const levelDiff = riskOrder[a.riskLevel] - riskOrder[b.riskLevel];
2096
+ if (levelDiff !== 0) return levelDiff;
2097
+ return b.usageCount - a.usageCount;
2098
+ });
2099
+ const highRisk = flags.filter((f) => f.riskLevel === "high").length;
2100
+ const mediumRisk = flags.filter((f) => f.riskLevel === "medium").length;
2101
+ const lowRisk = flags.filter((f) => f.riskLevel === "low").length;
2102
+ const summary = {
2103
+ totalFlags: flags.length,
2104
+ highRisk,
2105
+ mediumRisk,
2106
+ lowRisk,
2107
+ totalUsages: scanResult.totalUsages,
2108
+ dynamicKeys: scanResult.usages.filter((u) => u.isDynamic).length,
2109
+ detailEvaluations: inventoryItems.filter((i) => i.manualReviewReason === "detail-method").length,
2110
+ bulkCalls: inventoryItems.filter((i) => i.manualReviewReason === "bulk-inventory-call").length,
2111
+ wrapperUsages: scanResult.usages.filter((u) => u.callType === "variation").length,
2112
+ staleSignals: scanResult.usages.filter((u) => u.stalenessSignals.length > 0).length,
2113
+ safelyAutomatable: inventoryItems.filter((i) => i.safelyAutomatable).length,
2114
+ manualReview: inventoryItems.filter((i) => !i.safelyAutomatable).length,
2115
+ scannedFiles: scanResult.scannedFiles,
2116
+ scanDurationMs: scanResult.scanDurationMs,
2117
+ scannedAt: scanResult.scannedAt,
2118
+ scanRoot: scanResult.scanRoot
2119
+ };
2120
+ return { summary, flags };
2121
+ }
2122
+
2123
+ // src/auditor/reporter.ts
2124
+ function esc2(s) {
2125
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2126
+ }
2127
+ function formatAuditJson(report) {
2128
+ return JSON.stringify(report, null, 2);
2129
+ }
2130
+ function formatAuditMarkdown(report) {
2131
+ const { summary, flags } = report;
2132
+ const lines = [];
2133
+ lines.push("# FlagLint Audit Report");
2134
+ lines.push("");
2135
+ lines.push(`**Scanned at:** ${summary.scannedAt} `);
2136
+ lines.push(`**Scan root:** ${summary.scanRoot} `);
2137
+ lines.push(`**Files scanned:** ${summary.scannedFiles} `);
2138
+ lines.push(`**Duration:** ${summary.scanDurationMs}ms`);
2139
+ lines.push("");
2140
+ lines.push("## Summary");
2141
+ lines.push("");
2142
+ lines.push("| Total Flags | High Risk | Medium Risk | Low Risk | Total Usages |");
2143
+ lines.push("|-------------|-----------|-------------|----------|--------------|");
2144
+ lines.push(
2145
+ `| ${summary.totalFlags} | ${summary.highRisk} | ${summary.mediumRisk} | ${summary.lowRisk} | ${summary.totalUsages} |`
2146
+ );
2147
+ lines.push("");
2148
+ lines.push(
2149
+ "| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |"
2150
+ );
2151
+ lines.push(
2152
+ "|--------------|--------------|------------|---------------|-------------------|---------------|"
2153
+ );
2154
+ lines.push(
2155
+ `| ${summary.dynamicKeys} | ${summary.detailEvaluations} | ${summary.bulkCalls} | ${summary.staleSignals} | ${summary.safelyAutomatable} | ${summary.manualReview} |`
2156
+ );
2157
+ lines.push("");
2158
+ lines.push("## Flag Debt Inventory");
2159
+ lines.push("");
2160
+ lines.push("| Flag Key | Risk | Usages | Files | Call Types | Reasons |");
2161
+ lines.push("|----------|------|--------|-------|------------|---------|");
2162
+ const riskLabel = {
2163
+ high: "\u{1F534} High",
2164
+ medium: "\u{1F7E1} Medium",
2165
+ low: "\u{1F7E2} Low"
2166
+ };
2167
+ for (const flag of flags) {
2168
+ const reasons = flag.riskReasons.join(", ") || "\u2014";
2169
+ lines.push(
2170
+ `| \`${flag.flagKey}\` | ${riskLabel[flag.riskLevel]} | ${flag.usageCount} | ${flag.fileCount} | ${flag.callTypes.join(", ")} | ${reasons} |`
2171
+ );
2172
+ }
2173
+ lines.push("");
2174
+ lines.push("## Next Steps");
2175
+ lines.push("");
2176
+ lines.push(
2177
+ "- Run `flaglint migrate --dry-run` to preview safe OpenFeature rewrites"
2178
+ );
2179
+ lines.push(
2180
+ "- Run `flaglint validate --no-direct-launchdarkly` to enforce OF boundary in CI"
2181
+ );
2182
+ lines.push(
2183
+ "- Review HIGH risk flags manually before any automated migration"
2184
+ );
2185
+ lines.push("");
2186
+ return lines.join("\n");
2187
+ }
2188
+ function formatAuditHtml(report) {
2189
+ const { summary, flags } = report;
2190
+ const version = true ? "0.6.0" : "0.1.0";
2191
+ const date = new Date(summary.scannedAt).toLocaleString();
2192
+ const riskBadge = (level) => {
2193
+ if (level === "high")
2194
+ return '<span class="badge badge-high">High</span>';
2195
+ if (level === "medium")
2196
+ return '<span class="badge badge-medium">Medium</span>';
2197
+ return '<span class="badge badge-low">Low</span>';
2198
+ };
2199
+ const rows = flags.map((f) => {
2200
+ const reasons = f.riskReasons.length > 0 ? esc2(f.riskReasons.join(", ")) : "\u2014";
2201
+ return `<tr>
2202
+ <td><code>${esc2(f.flagKey)}</code></td>
2203
+ <td>${riskBadge(f.riskLevel)}</td>
2204
+ <td>${f.usageCount}</td>
2205
+ <td>${f.fileCount}</td>
2206
+ <td>${f.callTypes.map(esc2).join(", ")}</td>
2207
+ <td>${reasons}</td>
2208
+ </tr>`;
2209
+ }).join("\n ");
2210
+ return `<!DOCTYPE html>
2211
+ <html lang="en">
2212
+ <head>
2213
+ <meta charset="UTF-8">
2214
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2215
+ <title>FlagLint Audit Report</title>
2216
+ <style>
2217
+ :root{--bg:#fff;--surface:#f8f9fa;--border:#dee2e6;--text:#212529;--muted:#6c757d;--card-shadow:0 1px 3px rgba(0,0,0,.1)}
2218
+ @media(prefers-color-scheme:dark){:root{--bg:#0f172a;--surface:#1e293b;--border:#334155;--text:#e2e8f0;--muted:#94a3b8;--card-shadow:0 1px 3px rgba(0,0,0,.4)}}
2219
+ *{box-sizing:border-box;margin:0;padding:0}
2220
+ 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}
2221
+ h1{font-size:1.75rem;margin-bottom:.25rem}
2222
+ h2{font-size:1.125rem;margin:2rem 0 .75rem;padding-bottom:.5rem;border-bottom:1px solid var(--border)}
2223
+ .subtitle{color:var(--muted);margin-bottom:1.5rem;font-size:.875rem}
2224
+ .cards{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
2225
+ .card{flex:1;min-width:140px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;box-shadow:var(--card-shadow)}
2226
+ .card-num{font-size:1.875rem;font-weight:700;line-height:1}
2227
+ .card-num.red{color:#dc2626}
2228
+ .card-num.amber{color:#d97706}
2229
+ .card-num.green{color:#16a34a}
2230
+ .card-num.blue{color:#3b82f6}
2231
+ .card-num.purple{color:#7c3aed}
2232
+ .card-num.orange{color:#ea580c}
2233
+ .card-label{color:var(--muted);font-size:.75rem;margin-top:.375rem;text-transform:uppercase;letter-spacing:.05em}
2234
+ table{width:100%;border-collapse:collapse;font-size:.8125rem}
2235
+ th{text-align:left;padding:.625rem .75rem;background:var(--surface);border-bottom:2px solid var(--border);font-weight:600;white-space:nowrap}
2236
+ td{padding:.625rem .75rem;border-bottom:1px solid var(--border);vertical-align:top}
2237
+ code{font-family:ui-monospace,monospace;font-size:.8em;background:var(--surface);padding:.1em .3em;border-radius:3px}
2238
+ .badge{display:inline-block;padding:.2em .6em;border-radius:4px;font-size:.75rem;font-weight:600}
2239
+ .badge-high{background:#fee2e2;color:#991b1b}
2240
+ .badge-medium{background:#fef3c7;color:#92400e}
2241
+ .badge-low{background:#dcfce7;color:#166534}
2242
+ @media(prefers-color-scheme:dark){
2243
+ .badge-high{background:#7f1d1d;color:#fca5a5}
2244
+ .badge-medium{background:#78350f;color:#fcd34d}
2245
+ .badge-low{background:#14532d;color:#86efac}
2246
+ }
2247
+ .steps{margin:.75rem 0 1rem 1.25rem;line-height:2}
2248
+ footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border);color:var(--muted);font-size:.75rem;text-align:center}
2249
+ footer a{color:var(--muted)}
2250
+ </style>
2251
+ </head>
2252
+ <body>
2253
+ <h1>FlagLint Audit Report</h1>
2254
+ <p class="subtitle">
2255
+ ${esc2(summary.scanRoot)} &middot; ${esc2(summary.scannedFiles.toString())} files &middot; ${esc2(summary.scanDurationMs.toString())}ms &middot; ${esc2(date)}
2256
+ </p>
2257
+
2258
+ <h2>Summary</h2>
2259
+ <div class="cards">
2260
+ <div class="card"><div class="card-num">${summary.totalFlags}</div><div class="card-label">Total Flags</div></div>
2261
+ <div class="card"><div class="card-num red">${summary.highRisk}</div><div class="card-label">High Risk</div></div>
2262
+ <div class="card"><div class="card-num amber">${summary.mediumRisk}</div><div class="card-label">Medium Risk</div></div>
2263
+ <div class="card"><div class="card-num green">${summary.lowRisk}</div><div class="card-label">Low Risk</div></div>
2264
+ <div class="card"><div class="card-num purple">${summary.safelyAutomatable}</div><div class="card-label">Safely Automatable</div></div>
2265
+ <div class="card"><div class="card-num orange">${summary.manualReview}</div><div class="card-label">Manual Review</div></div>
2266
+ </div>
2267
+
2268
+ <h2>Flag Debt Inventory</h2>
2269
+ <table>
2270
+ <thead>
2271
+ <tr>
2272
+ <th>Flag Key</th>
2273
+ <th>Risk</th>
2274
+ <th>Usages</th>
2275
+ <th>Files</th>
2276
+ <th>Call Types</th>
2277
+ <th>Risk Reasons</th>
2278
+ </tr>
2279
+ </thead>
2280
+ <tbody>
2281
+ ${rows}
2282
+ </tbody>
2283
+ </table>
2284
+
2285
+ <h2>Next Steps</h2>
2286
+ <ol class="steps">
2287
+ <li>Run <code>flaglint migrate --dry-run</code> to preview safe OpenFeature rewrites</li>
2288
+ <li>Run <code>flaglint validate --no-direct-launchdarkly</code> to enforce OF boundary in CI</li>
2289
+ <li>Review HIGH risk flags manually before any automated migration</li>
2290
+ </ol>
2291
+
2292
+ <footer>
2293
+ Generated by <a href="https://flaglint.dev">FlagLint</a> ${esc2(version)}
2294
+ </footer>
2295
+ </body>
2296
+ </html>`;
2297
+ }
2298
+
2299
+ // src/commands/audit.ts
2300
+ var VALID_AUDIT_FORMATS = ["json", "markdown", "html"];
2301
+ function registerAuditCommand(program2) {
2302
+ program2.command("audit").description("Generate a flag debt audit report").argument("[dir]", "directory to audit", process.cwd()).option("-f, --format <format>", "output format: json | markdown | html", "markdown").option("-o, --output <file>", "write report to file").option("-c, --config <path>", "path to .flaglintrc config file").option("--exclude-tests", "exclude test files (*.test.*, *.spec.*, __tests__/, tests/)").addHelpText(
2303
+ "after",
2304
+ `
2305
+ Examples:
2306
+ $ flaglint audit generate flag debt audit report
2307
+ $ flaglint audit --format html shareable HTML report
2308
+ $ flaglint audit --output audit.md save to file`
2309
+ ).action(
2310
+ async (dir, options) => {
2311
+ if (!VALID_AUDIT_FORMATS.includes(options.format)) {
2312
+ process.stderr.write(
2313
+ chalk4.red(
2314
+ `Error: Invalid format '${options.format}'. Must be one of: ${VALID_AUDIT_FORMATS.join(", ")}
2315
+ `
2316
+ )
2317
+ );
2318
+ process.exit(2);
2319
+ }
2320
+ try {
2321
+ const s = await stat4(resolve8(dir));
2322
+ if (!s.isDirectory()) {
2323
+ process.stderr.write(chalk4.red(`Error: Not a directory: ${dir}
2324
+ `));
2325
+ process.exit(1);
2326
+ }
2327
+ } catch (err) {
2328
+ const code = err.code;
2329
+ if (code === "ENOENT") {
2330
+ process.stderr.write(chalk4.red(`Error: Directory not found: ${dir}
2331
+ `));
2332
+ } else if (code === "EACCES") {
2333
+ process.stderr.write(chalk4.red(`Error: Permission denied: ${dir}
2334
+ `));
2335
+ } else {
2336
+ process.stderr.write(chalk4.red(`Error: Cannot access directory: ${dir}
2337
+ `));
2338
+ }
2339
+ process.exit(1);
2340
+ }
2341
+ let config;
2342
+ try {
2343
+ config = await loadConfig(options.config);
2344
+ } catch (err) {
2345
+ process.stderr.write(
2346
+ chalk4.red(String(err instanceof Error ? err.message : err)) + "\n"
2347
+ );
2348
+ process.exit(1);
2349
+ }
2350
+ const TEST_PATTERNS = [
2351
+ "**/*.test.ts",
2352
+ "**/*.test.tsx",
2353
+ "**/*.spec.ts",
2354
+ "**/*.spec.tsx",
2355
+ "**/__tests__/**",
2356
+ "**/tests/**"
2357
+ ];
2358
+ const scanConfig = options.excludeTests ? { ...config, exclude: [...config.exclude, ...TEST_PATTERNS] } : config;
2359
+ const format = options.format;
2360
+ const spinner = ora4(`Auditing ${dir}...`).start();
2361
+ process.once("SIGINT", () => {
2362
+ spinner.stop();
2363
+ process.exit(130);
2364
+ });
2365
+ let lastSpinnerUpdate = 0;
2366
+ let scanResult;
2367
+ try {
2368
+ scanResult = await scan(new LocalFileSource(dir), scanConfig, (filesScanned) => {
2369
+ if (filesScanned - lastSpinnerUpdate >= 50) {
2370
+ spinner.text = `Scanning... (${filesScanned} files)`;
2371
+ lastSpinnerUpdate = filesScanned;
2372
+ }
2373
+ });
2374
+ spinner.stop();
2375
+ } catch (err) {
2376
+ spinner.fail("Audit scan failed");
2377
+ process.stderr.write(chalk4.red(String(err)) + "\n");
2378
+ process.exit(1);
2379
+ }
2380
+ for (const w of scanResult.warnings) {
2381
+ const msg = w.kind === "read-failure" ? `warn: could not read ${w.file} (${w.fsCode})` : `warn: failed to parse ${w.file}`;
2382
+ process.stderr.write(chalk4.yellow(msg + "\n"));
2383
+ }
2384
+ if (scanResult.scannedFiles === 0) {
2385
+ process.stderr.write(
2386
+ chalk4.yellow("No matching files found. Check your .flaglintrc include patterns.\n")
2387
+ );
2388
+ process.exit(0);
2389
+ }
2390
+ if (scanResult.totalUsages === 0) {
2391
+ process.stderr.write(
2392
+ chalk4.dim(
2393
+ `No LaunchDarkly SDK usage detected in ${scanResult.scannedFiles} files.
2394
+ `
2395
+ )
2396
+ );
2397
+ process.exit(0);
2398
+ }
2399
+ const auditReport = buildAuditReport(
2400
+ scanResult,
2401
+ scanResult.migrationInventory ?? []
2402
+ );
2403
+ let output;
2404
+ if (format === "json") {
2405
+ output = formatAuditJson(auditReport);
2406
+ } else if (format === "html") {
2407
+ output = formatAuditHtml(auditReport);
2408
+ } else {
2409
+ output = formatAuditMarkdown(auditReport);
2410
+ }
2411
+ if (options.output) {
2412
+ const outPath = resolve8(options.output);
2413
+ try {
2414
+ await writeFile5(outPath, output, "utf8");
2415
+ process.stderr.write(chalk4.dim(` Report written to ${options.output}
2416
+ `));
2417
+ } catch (err) {
2418
+ process.stderr.write(
2419
+ chalk4.red(
2420
+ `Error: Failed to write report to ${options.output}: ${err instanceof Error ? err.message : String(err)}
2421
+ `
2422
+ )
2423
+ );
2424
+ process.exit(1);
2425
+ }
2426
+ } else {
2427
+ process.stdout.write(output + "\n");
2428
+ }
2429
+ const { totalFlags, highRisk, mediumRisk, lowRisk } = auditReport.summary;
2430
+ process.stderr.write(
2431
+ chalk4.green(
2432
+ `\u2713 Audit complete: ${totalFlags} flags \u2014 ${highRisk} high risk, ${mediumRisk} medium risk, ${lowRisk} low risk
2433
+ `
2434
+ )
2435
+ );
2436
+ process.exit(0);
2437
+ }
2438
+ );
2439
+ }
2440
+
1980
2441
  // src/cli.ts
1981
2442
  function createCLI() {
1982
2443
  const program2 = new Command();
1983
- program2.name("flaglint").description("LaunchDarkly Node.js server SDK -> OpenFeature migration.").version("0.5.4", "-v, --version", "output the current version").addHelpText(
2444
+ program2.name("flaglint").description("Scan LaunchDarkly Node.js SDK usage, generate safe OpenFeature migration diffs, and enforce the vendor boundary in CI.").version("0.6.0", "-v, --version", "output the current version").addHelpText(
1984
2445
  "after",
1985
2446
  `
1986
2447
  Examples:
@@ -1990,11 +2451,15 @@ Examples:
1990
2451
  $ flaglint scan --output report.md save to file
1991
2452
  $ flaglint migrate generate migration plan
1992
2453
  $ flaglint migrate --dry-run preview without writing
1993
- $ flaglint validate --no-direct-launchdarkly enforce OF migration in CI`
2454
+ $ flaglint validate --no-direct-launchdarkly enforce OF migration in CI
2455
+ $ flaglint audit generate flag debt audit report
2456
+ $ flaglint audit --format html shareable HTML report
2457
+ $ flaglint audit --output audit.md save to file`
1994
2458
  );
1995
2459
  registerScanCommand(program2);
1996
2460
  registerMigrateCommand(program2);
1997
2461
  registerValidateCommand(program2);
2462
+ registerAuditCommand(program2);
1998
2463
  return program2;
1999
2464
  }
2000
2465
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flaglint",
3
- "version": "0.5.4",
4
- "description": "LaunchDarkly Node.js server SDK -> OpenFeature migration.",
3
+ "version": "0.6.0",
4
+ "description": "Scan LaunchDarkly Node.js SDK usage, generate safe OpenFeature migration diffs, and enforce the vendor boundary in CI.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flaglint": "dist/bin/flaglint.js"
@@ -71,9 +71,11 @@
71
71
  "@vitest/coverage-v8": "^4.1.6",
72
72
  "astro": "^6.3.8",
73
73
  "clipboardy": "^4.0.0",
74
+ "png-to-ico": "^3.0.1",
74
75
  "tsup": "^8.2.4",
75
76
  "tsx": "^4.19.0",
76
77
  "typescript": "^5.5.4",
77
- "vitest": "^4.1.6"
78
+ "vitest": "^4.1.6",
79
+ "starlight-blog": "^0.26.1"
78
80
  }
79
81
  }